refactor: revamp core API, network handler, XML processing, and examples

This commit is contained in:
Adam 2026-04-26 00:31:26 +01:00
parent fbd8dfacdc
commit 4a9fc50ff3
49 changed files with 5057 additions and 1211 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Python
__pycache__/
*.pyc
# Claude Code
.claude/
CLAUDE.md
.claudeignore

760
README.md
View File

@ -1,178 +1,582 @@
# RSIPI: Robot Sensor Interface - Python Integration # RSIPI: Robot Sensor Interface for Python
RSIPI is a high-performance, Python-based communication and control system designed for real-time interfacing with KUKA robots using the Robot Sensor Interface (RSI) protocol. It provides both a robust **API** for developers and a powerful **Command Line Interface (CLI)** for researchers and engineers who need to monitor, control, and analyse robotic movements in real time. [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
---
RSIPI is a Python library for real-time control of KUKA industrial robots via the Robot Sensor Interface (RSI) protocol. The robot controller sends its state over UDP at a configurable cycle rate (4ms at 250Hz or 12ms at 83Hz), and RSIPI sends back position corrections, I/O commands, and Tech parameters. Communication uses XML packets over a dedicated Ethernet link, managed in a separate process so your control logic never blocks the real-time loop.
🛡️ Safety Notice
RSIPI is a powerful tool that directly interfaces with industrial robotic systems. Improper use can lead to dangerous movements, property damage, or personal injury. ---
⚠️ Safety Guidelines ## Safety Notice
- **Test in Simulation First:** Always verify your RSI communication and trajectories using simulation tools before deploying to a live robot.
- **Enable Emergency Stops:** Ensure all safety hardware (E-Stop, fencing, light curtains) is active and functioning correctly. RSIPI directly controls industrial robot motion. Misuse can cause damage or injury.
- **Supervised Operation Only:** Run RSIPI only in supervised environments with trained personnel present.
- **Limit Movement Ranges:** Use KUKA Workspaces or software limits to constrain movement, especially when testing new code. - **Test offline first** using the built-in echo server before connecting to a real robot.
- **Use Logging for Debugging:** Avoid debugging while RSI is active; instead, enable CSV logging and review logs post-run. - **Hardware E-stops** must be present and functional. RSIPI's software E-stop is not safety-rated.
- **Secure Network Configuration:** Ensure your RSI network is on a closed, isolated interface to avoid external interference or spoofing. - **Limit correction ranges** via `api.safety.set_limit()` and KUKA Workspaces.
- **Never Rely on RSIPI for Safety:** RSIPI is not a safety-rated system. Do not use it in applications where failure could result in harm. - **Isolate the RSI network** -- use a dedicated Ethernet interface with no external access.
- **Never run unattended** without proper risk assessment and safety measures.
---
---
## 📄 Description
## Installation
RSIPI allows users to:
- Communicate with KUKA robots using the RSI XML-based protocol. Requires Python 3.10+.
- Dynamically update control variables (TCP position, joint angles, I/O, external axes, etc.).
- Log and visualise robot movements with live graphs and static plots. ```bash
- Analyse motion data and compare planned vs actual trajectories. # Development install (editable)
- Parse and inject RSI into KRL programs. pip install -e .
- Simulate robot behaviour using a realistic Echo Server.
- Enforce safety limits and manage emergency stops. # Or install dependencies directly
pip install pandas>=2.0 numpy>=1.22 matplotlib>=3.5 lxml>=4.9 scipy>=1.8
### Target Audience ```
- **Researchers** working on advanced robotic applications, control algorithms, and feedback systems.
- **Engineers** developing robotic workflows or automated processes. ---
- **Educators** using real robots in coursework or lab environments.
- **Students** learning about robot control systems and data-driven motion planning. ## Quick Start
--- ```python
from RSIPI import RSIAPI
## 📊 Features
- Real-time network communication with KUKA RSI over UDP. # Context manager handles cleanup on exit
- Structured logging to CSV with British date formatting. with RSIAPI("RSI_EthernetConfig.xml") as api:
- Background execution and live variable updates. api.start()
- Fully-featured Python API for scripting or external integration.
- CLI for interactive control, trajectory planning, and live monitoring. if api.wait_for_connection(timeout=10.0):
- Real-time and post-analysis graphing (live TCP, joints, force, acceleration). # Send a 10mm correction in X
- Safety management: emergency stop, limit enforcement, safety override. api.motion.update_cartesian(X=10.0)
- KUKA KRL `.src/.dat` parsing and RSI injection tools.
- Echo Server and GUI for offline simulation and testing. # Read current TCP position
- Deviation and force spike alerts during live operation. pose = api.motion.get_current_pose()
print(f"TCP: X={pose['X']}, Y={pose['Y']}, Z={pose['Z']}")
--- else:
print("No robot connection within 10s")
## 📊 API Overview (`rsi_api.py`)
# api.stop() called automatically
### Initialization ```
```python
from src.RSIPI import rsi_api Without the context manager:
api = rsi_api.RSIAPI(config_file='examples/RSI_EthernetConfig.xml')
``` ```python
api = RSIAPI("RSI_EthernetConfig.xml", rsi_mode="relative")
### Selected Methods api.start()
| Method | CLI | API | Description | api.wait_for_connection()
|--------|-----|-----|-------------|
| `start_rsi()` | ✅ | ✅ | Starts RSI communication (non-blocking). | api.motion.update_cartesian(X=5.0, Y=-3.0)
| `stop_rsi()` | ✅ | ✅ | Stops RSI communication. |
| `update_variable(path, value)` | ✅ | ✅ | Dynamically updates a send variable (e.g. `RKorr.X`). | api.stop()
| `get_variable(path)` | ✅ | ✅ | Retrieves the latest value of any variable. | ```
| `plan_linear_cartesian(start, end, steps)` | ✅ | ✅ | Create Cartesian paths. |
| `plan_linear_joint(start, end, steps)` | ✅ | ✅ | Create Joint-space paths. | ---
| `execute_trajectory(traj, rate)` | ✅ | ✅ | Execute planned trajectory live. |
| `enable_alerts(True/False)` | ✅ | ✅ | Enable or disable deviation/force alerts. | ## Configuration
| `start_live_plot(mode)` | ✅ | ✅ | Live graph position, velocity, force, etc. |
| `generate_plot(csv, type)` | ✅ | ✅ | Static graphing from CSV files. | RSIPI reads `RSI_EthernetConfig.xml` to determine network settings and which variables are exchanged with the robot.
| `export_movement_data(filename)` | ✅ | ✅ | Export recorded motion as CSV. |
| `parse_krl_to_csv(src, dat, output)` | ✅ | ✅ | Extract TCP points from KRL programs. | ```xml
| `inject_rsi(input, output, config)` | ✅ | ✅ | Add RSI startup code to a KRL file. | <ROOT>
<CONFIG>
_(Full API details available in `rsi_api.py`.)_ <IP_NUMBER>10.10.10.10</IP_NUMBER> <!-- External PC IP -->
<PORT>64000</PORT> <!-- UDP port -->
--- <SENTYPE>ImFree</SENTYPE> <!-- XML root element name -->
<ONLYSEND>FALSE</ONLYSEND> <!-- FALSE = bidirectional -->
## 🔧 CLI Overview (`rsi_cli.py`) </CONFIG>
Start the CLI: <!-- SEND: What the robot sends TO us (read-only from Python) -->
```bash <SEND>
python main.py --cli <ELEMENTS>
``` <ELEMENT TAG="DEF_RIst" TYPE="DOUBLE" INDX="INTERNAL" /> <!-- TCP position -->
<ELEMENT TAG="DEF_RSol" TYPE="DOUBLE" INDX="INTERNAL" /> <!-- Commanded position -->
### Selected Commands <ELEMENT TAG="DEF_Delay" TYPE="LONG" INDX="INTERNAL" /> <!-- Packet delay count -->
| Command | Description | <ELEMENT TAG="Digout.o1" TYPE="BOOL" INDX="2" /> <!-- Digital output state -->
|---------|-------------| </ELEMENTS>
| `start` / `stop` | Start or stop RSI client. | </SEND>
| `set <var> <value>` | Update send variable. |
| `get <var>` | Get latest receive variable. | <!-- RECEIVE: What the robot receives FROM us (writable from Python) -->
| `move_cartesian`, `move_joint` | Move robot using planned trajectories. | <RECEIVE>
| `queue_cartesian`, `queue_joint` | Queue trajectory steps. | <ELEMENTS>
| `execute_queue` | Run queued trajectories. | <ELEMENT TAG="RKorr.X" TYPE="DOUBLE" INDX="1" HOLDON="1" /> <!-- Cartesian correction -->
| `alerts on/off` | Enable or disable alerts. | <ELEMENT TAG="RKorr.Y" TYPE="DOUBLE" INDX="2" HOLDON="1" />
| `graph show/compare` | Plot or compare test runs. | <ELEMENT TAG="RKorr.Z" TYPE="DOUBLE" INDX="3" HOLDON="1" />
| `log start/stop/status` | Manage CSV logging. | <ELEMENT TAG="RKorr.A" TYPE="DOUBLE" INDX="4" HOLDON="1" />
| `plot <type> <csv>` | Static plotting (position, velocity, deviation, etc.). | <ELEMENT TAG="RKorr.B" TYPE="DOUBLE" INDX="5" HOLDON="1" />
| `safety-stop`, `safety-reset`, `safety-status` | Emergency stop and limit management. | <ELEMENT TAG="RKorr.C" TYPE="DOUBLE" INDX="6" HOLDON="1" />
| `krlparse <src> <dat> <out>` | Parse KRL to CSV. | <ELEMENT TAG="DiO" TYPE="LONG" INDX="8" HOLDON="1" /> <!-- Digital I/O -->
| `inject_rsi <src> [out] [config]` | Inject RSI code into KRL file. | </ELEMENTS>
</RECEIVE>
--- </ROOT>
```
## 📃 Example Usage
Key points:
### Update TCP position live - `DEF_` prefixed tags are expanded internally (e.g., `DEF_RIst` becomes `RIst: {X, Y, Z, A, B, C}`).
```python - `HOLDON="1"` means the last value is held if no new value is sent.
api.start_rsi() - SEND variables are read via `api.monitoring`, RECEIVE variables are written via `api.motion`, `api.io`, etc.
api.update_variable('RKorr.X', 100.0) - The config must match the RSI object configuration on the KUKA controller.
api.update_variable('RKorr.Y', 50.0)
``` ---
### Plan and execute a Cartesian move ## API Reference
```python
start_pose = {'X': 0, 'Y': 0, 'Z': 500} ### Core Lifecycle
end_pose = {'X': 200, 'Y': 0, 'Z': 500}
traj = api.plan_linear_cartesian(start_pose, end_pose, steps=100) ```python
api.execute_trajectory(traj, rate=0.012) api = RSIAPI(
``` config_file="RSI_EthernetConfig.xml",
rsi_mode="relative", # "absolute" or "relative" -- must match KRL
### CLI session sample max_cartesian_rate=0.5, # Max mm/cycle for RKorr (0 = unlimited)
```bash max_joint_rate=0.1, # Max deg/cycle for AKorr (0 = unlimited)
> start cycle_time=0.004 # 0.004 = 4ms/250Hz, 0.012 = 12ms/83Hz
> set RKorr.X 150 )
> move_cartesian X=0,Y=0,Z=500 X=200,Y=0,Z=500 steps=100 rate=0.012
> graph show my_log.csv api.start() # Start UDP listener in background thread
> log start api.wait_for_connection(10.0) # Block until first robot packet (returns bool)
> stop api.is_running() # Check if communication is active
``` api.stop() # Graceful shutdown
api.reconnect() # Restart network with fresh resources
--- ```
## 📅 Output and Logs ### `api.motion` -- Motion Control
- CSV logs saved to `logs/` folder.
- Each log includes British timestamp, sent/received variables. ```python
- Static plots exportable as PNG/PDF. # Cartesian corrections (RKorr) -- mm for XYZ, degrees for ABC
- Live plots include alert messages and deviation tracking. api.motion.update_cartesian(X=10.0, Y=-5.0, Z=0.0)
api.motion.update_cartesian(A=2.5, B=0.0, C=0.0)
---
# Joint corrections (AKorr) -- degrees
## 🚀 Getting Started api.motion.update_joints(A1=5.0, A2=-3.0)
1. Connect robot and PC via Ethernet.
2. Deploy KUKA RSI program with matching config. # Read current state
3. Install Python dependencies: pose = api.motion.get_current_pose() # {X, Y, Z, A, B, C}
```bash joints = api.motion.get_current_joints() # {A1, A2, A3, A4, A5, A6}
pip install -r requirements.txt
``` # External axes
4. Run `main.py` or import `RSIAPI` in your Python scripts. api.motion.move_external_axis("E1", 500.0)
--- # Tech parameters (runtime motion adjustment)
api.motion.adjust_speed("Tech.T21", 0.5)
## 🔖 Citation ```
If you use RSIPI in your research, please cite:
```bibtex #### Trajectories
@software{rsipi2025,
author = {RSIPI Development Team}, ```python
title = {RSIPI: Robot Sensor Interface - Python Integration}, # Generate linear trajectory
year = {2025}, traj = api.motion.generate_trajectory(
url = {https://github.com/your-org/rsipi}, start={"X": 0, "Y": 0, "Z": 500},
note = {Accessed: [insert date]} end={"X": 100, "Y": 0, "Z": 500},
} steps=50,
``` space="cartesian"
)
---
# Execute (blocking)
## ⚖️ License api.motion.execute_trajectory(traj, space="cartesian", rate=0.012)
RSIPI is licensed under the MIT License.
# Or generate + execute in one call
--- api.motion.move_cartesian_trajectory(
{"X": 0, "Y": 0, "Z": 500},
## 🚧 Disclaimer {"X": 100, "Y": 0, "Z": 500},
RSIPI is intended for research and experimental purposes only. Always ensure safe operation with appropriate safety measures in place. steps=50, rate=0.02
)
api.motion.move_joint_trajectory(
{"A1": 0, "A2": 0, "A3": 0, "A4": 0, "A5": 0, "A6": 0},
{"A1": 30, "A2": -15, "A3": 45, "A4": 0, "A5": 30, "A6": 0},
steps=100, rate=0.4
)
# Cancel a running trajectory from another thread
api.motion.cancel_trajectory()
```
#### Trajectory Queue
```python
api.motion.queue_cartesian_trajectory(p0, p1, steps=50)
api.motion.queue_cartesian_trajectory(p1, p2, steps=50)
api.motion.queue_joint_trajectory(j0, j1, steps=100, rate=0.4)
print(api.motion.get_queue()) # Metadata for queued items
api.motion.execute_queued_trajectories() # Run all in sequence
api.motion.clear_queue() # Discard without executing
```
#### Geometric Primitives
```python
# Circular arc
arc = api.motion.generate_arc(
center={"X": 100, "Y": 0, "Z": 500},
radius=50.0,
start_angle=0, end_angle=90,
steps=50, plane="XY"
)
# Full circle
circle = api.motion.generate_circle(
center={"X": 100, "Y": 0, "Z": 500},
radius=50.0, steps=100, plane="XY"
)
# Spiral
spiral = api.motion.generate_spiral(
center={"X": 100, "Y": 0, "Z": 500},
start_radius=10.0, end_radius=50.0,
pitch=5.0, revolutions=5,
steps=200, plane="XY", axis="Z"
)
api.motion.execute_trajectory(arc, space="cartesian")
```
#### Velocity Profiles
```python
traj = api.motion.generate_trajectory(p0, p1, steps=100)
# Trapezoidal (bang-bang acceleration)
profiled = api.motion.generate_velocity_profile(
traj, max_velocity=200.0, max_acceleration=500.0,
profile="trapezoidal"
)
# S-curve (jerk-limited, smoother)
profiled = api.motion.generate_velocity_profile(
traj, max_velocity=200.0, max_acceleration=500.0,
profile="s-curve"
)
# Each element is (waypoint_dict, velocity_float)
for waypoint, velocity in profiled:
print(f"Velocity: {velocity:.2f} mm/s")
```
#### Path Blending
```python
traj1 = api.motion.generate_trajectory(p0, p1, 50)
traj2 = api.motion.generate_trajectory(p1, p2, 50)
blended = api.motion.blend_trajectories(
traj1, traj2,
blend_radius=10.0, # mm from junction
blend_steps=20
)
api.motion.execute_trajectory(blended)
```
#### Coordinate Transforms
```python
world_pose = api.motion.transform_coordinates(
pose={"X": 100, "Y": 0, "Z": 500},
from_frame="BASE", to_frame="WORLD",
frame_offset={"X": 500, "Y": 200, "Z": 0}
)
```
### `api.io` -- Digital I/O
```python
# Set output by channel number
api.io.set_output(1, True) # Digout.o1 = ON
api.io.set_output(3, False) # Digout.o3 = OFF
# Generic toggle (any group)
api.io.toggle("DiO", "1", True)
# Read input
if api.io.get_input(1): # Digin.i1
print("Sensor triggered")
# Timed pulse (blocking)
api.io.pulse(2, duration=0.1) # 100ms pulse on Digout.o2
```
### `api.krl` -- KRL Coordination
```python
# Wait for KRL to set a digital input (synchronization)
if api.krl.wait_for_signal(3, timeout=10.0):
print("KRL ready")
# Signal KRL that Python is done
api.krl.signal_complete(2) # Sets Digout.o2 = HIGH
# Pass data to KRL via Tech.C variables (slots 11-199)
api.krl.write_param("C12", 120.0) # KRL reads $TECH.C[12]
api.krl.write_param(13, -50.0)
# Read data from KRL via Tech.T variables
force = api.krl.read_param("T11") # KRL writes $TECH.T[11]
actual_x = api.krl.read_param(12)
# Parse KRL .src/.dat files to CSV
api.krl.parse_to_csv("robot_prog.src", "robot_prog.dat", "output.csv")
# Inject RSI commands into existing KRL program
api.krl.inject_rsi("robot_prog.src", "robot_prog_rsi.src")
```
### `api.safety` -- Safety Management
```python
# Emergency stop (software-level, NOT safety-rated)
api.safety.stop()
api.safety.is_stopped() # True
# Reset E-stop
api.safety.reset()
# Configure correction limits
api.safety.set_limit("RKorr.X", -50.0, 50.0)
api.safety.set_limit("AKorr.A1", -10.0, 10.0)
# View all limits
limits = api.safety.get_limits()
for var, (lo, hi) in limits.items():
print(f"{var}: [{lo}, {hi}]")
# Full status
status = api.safety.status()
# {"emergency_stop": False, "safety_override": False, "limits": {...}}
# Override limits (use with extreme caution)
api.safety.override(True)
# ... calibration work ...
api.safety.override(False)
```
### `api.monitoring` -- Live Data
```python
# Comprehensive snapshot
data = api.monitoring.get_live_data()
# {"position": {X,Y,Z,A,B,C}, "velocity": {...}, "force": {...}, "ipoc": 123456}
# Individual reads
pos = api.monitoring.get_position() # {X, Y, Z, A, B, C}
force = api.monitoring.get_force() # {A1, A2, A3, A4, A5, A6} motor currents
ipoc = api.monitoring.get_ipoc() # Interrupt point counter
# NumPy/Pandas formats
arr = api.monitoring.get_live_data_as_numpy() # shape (4, 6)
df = api.monitoring.get_live_data_as_dataframe() # single-row DataFrame
# Console watch (blocking, Ctrl+C to stop)
api.monitoring.watch_network(duration=10, rate=0.2)
```
### `api.diagnostics` -- Network Health
```python
stats = api.diagnostics.get_stats()
timing = api.diagnostics.get_timing() # cycle time, jitter
quality = api.diagnostics.get_network_quality() # packet loss, IPOC gaps
if not api.diagnostics.is_healthy():
for w in api.diagnostics.get_warnings():
print(f"Warning: {w}")
if api.diagnostics.check_watchdog():
api.reconnect()
print(api.diagnostics.format_stats())
# Network Diagnostics:
# Cycle Time: 4.01ms (+/-0.12ms jitter)
# Packet Loss: 0.05%
# ...
```
### `api.logging` -- CSV Logging
```python
# Start logging (auto-generates filename in logs/)
path = api.logging.start()
print(path) # logs/17-04-2026_14-32-45.csv
# Or specify filename
api.logging.start("my_experiment.csv")
api.logging.is_active() # True
api.logging.stop()
```
Logs include British-format timestamps, all send/receive variables per cycle. Logging runs in a separate process to avoid interfering with the 4 ms control loop.
### `api.viz` -- Visualization
```python
# Static plots from CSV logs
api.viz.plot_static("logs/test.csv", "3d")
api.viz.plot_static("logs/test.csv", "position") # Position vs time
api.viz.plot_static("logs/test.csv", "velocity")
api.viz.plot_static("logs/test.csv", "joints")
api.viz.plot_static("logs/test.csv", "force")
api.viz.plot_static("logs/test.csv", "2d_xy") # 2D projections
# Deviation from planned path
api.viz.plot_static("logs/actual.csv", "deviation", overlay_path="logs/planned.csv")
# Comprehensive multi-plot visualization
api.viz.visualize_csv_log("logs/test.csv")
api.viz.visualize_csv_log("logs/test.csv", export=True) # Save to disk
# Compare two runs
api.viz.compare_runs("run1.csv", "run2.csv")
# Live plotting (runs in background thread)
api.viz.start_live_plot("3d", interval=100) # 100ms update
api.viz.change_live_plot_mode("position")
api.viz.stop_live_plot()
```
### `api.tools` -- Utilities
```python
# Low-level variable access
api.tools.update_variable("RKorr.X", 10.0)
api.tools.show_variables() # Print all available variables
api.tools.show_config() # Network settings + variable structure
api.tools.reset_variables() # Zero out corrections
# Reports and comparison
api.tools.generate_report("logs/test.csv", "pdf")
diffs = api.tools.compare_runs("run1.csv", "run2.csv")
```
---
## RSI Mode and Rate Limiting
### Absolute vs Relative Mode
The `rsi_mode` parameter must match what your KRL program uses with `RSI_MOVECORR()`:
| Mode | Behavior | Use Case |
|------|----------|----------|
| `"relative"` | Corrections are **added** to the programmed path each cycle. Sending `X=1.0` every cycle moves 1mm/cycle continuously. | Continuous adjustments, sensor feedback |
| `"absolute"` | Corrections specify **total offset** from programmed path. Sending `X=10.0` holds 10mm offset regardless of how many cycles. | Target position offsets |
### Cycle Time
KUKA RSI supports two cycle rates, configured on the robot controller side. RSIPI's network loop is reactive (it responds to whatever the robot sends), but the `cycle_time` parameter ensures diagnostics, health checks, and jitter warnings use the correct baseline:
```python
# 4ms cycle / 250Hz (default)
api = RSIAPI("RSI_EthernetConfig.xml", cycle_time=0.004)
# 12ms cycle / 83Hz
api = RSIAPI("RSI_EthernetConfig.xml", cycle_time=0.012)
```
| Cycle Time | Frequency | Use Case |
|------------|-----------|----------|
| `0.004` (4ms) | 250 Hz | High-frequency corrections, sensor feedback loops |
| `0.012` (12ms) | 83 Hz | Standard motion corrections, less demanding applications |
### Rate Limiting
Rate limiting caps the per-cycle change to prevent sudden jumps:
```python
api = RSIAPI(
"RSI_EthernetConfig.xml",
rsi_mode="relative",
max_cartesian_rate=0.5, # Max 0.5 mm per cycle
max_joint_rate=0.1, # Max 0.1 deg per cycle
cycle_time=0.004 # 4ms cycle
)
```
Set rates to `0.0` (default) to disable rate limiting. Clamping is applied in the network process right before the response is sent to the robot.
---
## Testing
### Echo Server
RSIPI includes an echo server that simulates a KUKA controller for offline development:
```bash
python -m RSIPI.rsi_echo_server
```
The echo server binds to the same UDP port as a real robot, sends XML state packets at 250 Hz, and accepts correction responses. Use it to test your control logic without hardware.
### Running with pytest
```bash
pip install -e ".[dev]"
pytest
```
Test files go in the `tests/` directory. The project uses `src` layout with `pythonpath = ["src"]` configured in `pyproject.toml`.
---
## Architecture
```
KUKA Robot Controller
|
UDP/XML (4ms cycle)
|
NetworkProcess <- multiprocessing.Process, owns the socket
|
Manager().dict() <- shared send_variables / receive_variables
|
RSIClient <- orchestrator: config, safety, network
|
RSIAPI <- runs RSIClient in daemon thread
/ | \ \
motion io krl safety monitoring logging viz diagnostics tools
```
- **NetworkProcess** runs in a separate OS process. It receives XML from the robot, parses it into `send_variables` (what the robot tells us), and builds the response XML from `receive_variables` (what we tell the robot). IPOC synchronization (`IPOC + 4`) is handled automatically.
- **RSIClient** creates the `multiprocessing.Manager` dicts for cross-process variable sharing, initializes the `ConfigParser` and `SafetyManager`, and manages the network process lifecycle.
- **RSIAPI** wraps RSIClient in a daemon thread and exposes the namespaced sub-APIs (`motion`, `io`, `krl`, etc.).
The 4 ms cycle is driven by the robot controller, not by RSIPI. If a response is not sent within the cycle window, the robot uses the last held values (for `HOLDON="1"` variables) or drops to zero.
---
## Examples
The `examples/` directory contains runnable scripts:
| Script | Description |
|--------|-------------|
| `example_01_start_stop.py` | Basic lifecycle: connect, wait, disconnect |
| `example_02_send_cartesian.py` | Send Cartesian corrections (RKorr) |
| `example_03_send_joint.py` | Send joint corrections (AKorr) |
| `example_04_external_axes.py` | Control external axes (E1, E2, ...) |
| `example_05_digital_io.py` | Digital I/O: set outputs, read inputs, pulse |
| `example_06_logging_csv.py` | Start/stop CSV logging |
| `example_07_graphing_live.py` | Live 3D plot during operation |
| `example_08_safety_limits.py` | Configure and test safety limits |
| `example_09_trajectory_cartesian.py` | Generate and execute Cartesian trajectory |
| `example_10_shutdown_safe.py` | Graceful shutdown pattern |
Advanced examples:
| Directory | Scripts |
|-----------|---------|
| `examples/advanced_motion/` | Velocity profiles, arcs/circles/spirals, path blending, coordinate transforms |
| `examples/coordination/` | Python-KRL handshake, parameter passing via Tech variables, state machine coordination |
---
## CLI
Start the interactive command-line interface:
```bash
python -m RSIPI.rsi_cli --config RSI_EthernetConfig.xml
```
The CLI provides the same capabilities as the Python API through text commands: `start`, `stop`, `set <var> <value>`, `move_cartesian`, `log start`, `safety-stop`, etc.
---
## License
MIT

139
RSI_EthernetConfig_Full.xml Normal file
View File

@ -0,0 +1,139 @@
<ROOT>
<CONFIG>
<IP_NUMBER>10.10.10.10</IP_NUMBER>
<PORT>64000</PORT>
<SENTYPE>ImFree</SENTYPE>
<ONLYSEND>FALSE</ONLYSEND>
</CONFIG>
<!-- =================================================================
RSI Channel Budget: 64 max across SEND + RECEIVE
INTERNAL tags don't count toward the 64-channel limit
SEND channels used: 12 (DiL, Digout x3, Source1-4, Digin x4)
RECEIVE channels used: 20 (RKorr x6, AKorr x6, DiO x4, FREE x4)
Total: 32 / 64
All DEF_ tags are INTERNAL (free)
================================================================= -->
<!-- ===================== SEND: Robot → PC ========================= -->
<SEND>
<ELEMENTS>
<!-- Cartesian actual position (X,Y,Z,A,B,C in mm/deg) -->
<ELEMENT TAG="DEF_RIst" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Cartesian setpoint position -->
<ELEMENT TAG="DEF_RSol" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Robot axis actual positions (A1-A6 in deg) -->
<ELEMENT TAG="DEF_AIPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Robot axis setpoint positions (A1-A6 in deg) -->
<ELEMENT TAG="DEF_ASPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- External axis actual positions (E1-E6) -->
<ELEMENT TAG="DEF_EIPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- External axis setpoint positions (E1-E6) -->
<ELEMENT TAG="DEF_ESPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Robot motor currents (A1-A6, % of max) -->
<ELEMENT TAG="DEF_MACur" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- External motor currents (E1-E6, % of max) -->
<ELEMENT TAG="DEF_MECur" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Late packet counter -->
<ELEMENT TAG="DEF_Delay" TYPE="LONG" INDX="INTERNAL" />
<!-- Tech channels C1-C6 (main run parameters, robot → PC) -->
<ELEMENT TAG="DEF_Tech.C1" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C2" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C3" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C4" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C5" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C6" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Tech channels T1-T6 (advance parameters, robot → PC) -->
<ELEMENT TAG="DEF_Tech.T1" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T2" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T3" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T4" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T5" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T6" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Digital input latch (channel 1) -->
<ELEMENT TAG="DiL" TYPE="LONG" INDX="1" />
<!-- Digital output readback (channels 2-4) -->
<ELEMENT TAG="Digout.o1" TYPE="BOOL" INDX="2" />
<ELEMENT TAG="Digout.o2" TYPE="BOOL" INDX="3" />
<ELEMENT TAG="Digout.o3" TYPE="BOOL" INDX="4" />
<!-- Analog/general sources (channels 5-8) -->
<ELEMENT TAG="Source1" TYPE="DOUBLE" INDX="5" />
<ELEMENT TAG="Source2" TYPE="DOUBLE" INDX="6" />
<ELEMENT TAG="Source3" TYPE="DOUBLE" INDX="7" />
<ELEMENT TAG="Source4" TYPE="DOUBLE" INDX="8" />
<!-- Digital input readback (channels 9-12) -->
<ELEMENT TAG="Digin.i1" TYPE="BOOL" INDX="9" />
<ELEMENT TAG="Digin.i2" TYPE="BOOL" INDX="10" />
<ELEMENT TAG="Digin.i3" TYPE="BOOL" INDX="11" />
<ELEMENT TAG="Digin.i4" TYPE="BOOL" INDX="12" />
</ELEMENTS>
</SEND>
<!-- =================== RECEIVE: PC → Robot ======================== -->
<RECEIVE>
<ELEMENTS>
<!-- Status/error string to robot -->
<ELEMENT TAG="DEF_EStr" TYPE="STRING" INDX="INTERNAL" />
<!-- Tech channels T1-T6 (advance parameters, PC → robot) -->
<ELEMENT TAG="DEF_Tech.T1" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T2" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T3" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T4" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T5" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T6" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<!-- Tech channels C1-C6 (main run parameters, PC → robot) -->
<ELEMENT TAG="DEF_Tech.C1" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C2" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C3" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C4" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C5" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C6" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<!-- Cartesian corrections (channels 1-6, HOLDON keeps last value) -->
<ELEMENT TAG="RKorr.X" TYPE="DOUBLE" INDX="1" HOLDON="1" />
<ELEMENT TAG="RKorr.Y" TYPE="DOUBLE" INDX="2" HOLDON="1" />
<ELEMENT TAG="RKorr.Z" TYPE="DOUBLE" INDX="3" HOLDON="1" />
<ELEMENT TAG="RKorr.A" TYPE="DOUBLE" INDX="4" HOLDON="1" />
<ELEMENT TAG="RKorr.B" TYPE="DOUBLE" INDX="5" HOLDON="1" />
<ELEMENT TAG="RKorr.C" TYPE="DOUBLE" INDX="6" HOLDON="1" />
<!-- Joint corrections (channels 7-12) -->
<ELEMENT TAG="AKorr.A1" TYPE="DOUBLE" INDX="7" HOLDON="1" />
<ELEMENT TAG="AKorr.A2" TYPE="DOUBLE" INDX="8" HOLDON="1" />
<ELEMENT TAG="AKorr.A3" TYPE="DOUBLE" INDX="9" HOLDON="1" />
<ELEMENT TAG="AKorr.A4" TYPE="DOUBLE" INDX="10" HOLDON="1" />
<ELEMENT TAG="AKorr.A5" TYPE="DOUBLE" INDX="11" HOLDON="1" />
<ELEMENT TAG="AKorr.A6" TYPE="DOUBLE" INDX="12" HOLDON="1" />
<!-- Digital outputs (channels 13-16) -->
<ELEMENT TAG="DiO1" TYPE="LONG" INDX="13" HOLDON="1" />
<ELEMENT TAG="DiO2" TYPE="LONG" INDX="14" HOLDON="1" />
<ELEMENT TAG="DiO3" TYPE="LONG" INDX="15" HOLDON="1" />
<ELEMENT TAG="DiO4" TYPE="LONG" INDX="16" HOLDON="1" />
<!-- Spare channels (17-20) for future use -->
<ELEMENT TAG="FREE1" TYPE="LONG" INDX="17" HOLDON="1" />
<ELEMENT TAG="FREE2" TYPE="LONG" INDX="18" HOLDON="1" />
<ELEMENT TAG="FREE3" TYPE="LONG" INDX="19" HOLDON="1" />
<ELEMENT TAG="FREE4" TYPE="LONG" INDX="20" HOLDON="1" />
</ELEMENTS>
</RECEIVE>
</ROOT>

BIN
TestServer.exe Normal file

Binary file not shown.

View File

@ -174,4 +174,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -221,4 +221,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -278,4 +278,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -302,4 +302,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -394,4 +394,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -110,4 +110,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -130,4 +130,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -205,4 +205,6 @@ def main():
if __name__ == '__main__': if __name__ == '__main__':
from multiprocessing import freeze_support
freeze_support()
main() main()

View File

@ -1,11 +1,15 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
from multiprocessing import freeze_support
rsi.start_rsi() freeze_support()
print("RSI connection started. Press Enter to stop.") api = RSIAPI()
input()
api.start()
rsi.stop_rsi()
print("RSI connection stopped.") print("RSI connection started. Press Enter to stop.")
input()
api.stop()
print("RSI connection stopped.")

View File

@ -1,9 +1,13 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.start_rsi() from multiprocessing import freeze_support
freeze_support()
# Move TCP 50mm along X-axis
rsi.update_cartesian(x=50, y=0, z=0) api = RSIAPI()
api.start()
rsi.stop_rsi()
# Move TCP 50mm along X-axis
api.motion.update_cartesian(X=50, Y=0, Z=0)
api.stop()

View File

@ -1,9 +1,13 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.start_rsi() from multiprocessing import freeze_support
freeze_support()
# Move Joint A1 by 10 degrees
rsi.update_joints(a1=10) api = RSIAPI()
api.start()
rsi.stop_rsi()
# Move Joint A1 by 10 degrees
api.motion.update_joints(A1=10)
api.stop()

View File

@ -1,9 +1,13 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.start_rsi() from multiprocessing import freeze_support
freeze_support()
# Move external axis E1 by 100mm
rsi.update_external(e1=100) api = RSIAPI()
api.start()
rsi.stop_rsi()
# Move external axis E1 by 100mm
api.motion.move_external_axis('E1', 100)
api.stop()

View File

@ -1,9 +1,13 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.start_rsi() from multiprocessing import freeze_support
freeze_support()
# Set digital output (e.g., to open gripper)
rsi.update_digital_io(125) # Example binary pattern api = RSIAPI()
api.start()
rsi.stop_rsi()
# Set digital output (e.g., to open gripper)
api.io.set_output(1, True)
api.stop()

View File

@ -1,11 +1,15 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.enable_csv_logging() from multiprocessing import freeze_support
freeze_support()
rsi.start_rsi()
api = RSIAPI()
print("Logging robot data to CSV. Press Enter to stop.") api.logging.start()
input()
api.start()
rsi.stop_rsi()
print("Logging robot data to CSV. Press Enter to stop.")
input()
api.stop()

View File

@ -1,11 +1,15 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.enable_graphing() from multiprocessing import freeze_support
freeze_support()
rsi.start_rsi()
api = RSIAPI()
print("Live graphing started. Press Enter to stop.") api.viz.start_live_plot()
input()
api.start()
rsi.stop_rsi()
print("Live graphing started. Press Enter to stop.")
input()
api.stop()

View File

@ -1,14 +1,18 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
from multiprocessing import freeze_support
# Set X axis soft limits freeze_support()
rsi.set_safety_limit(axis="X", min_value=-500, max_value=500)
api = RSIAPI()
rsi.start_rsi()
# Set X axis soft limits
try: api.safety.set_limit(axis="X", min_value=-500, max_value=500)
while True:
pass api.start()
except KeyboardInterrupt:
rsi.stop_rsi() try:
while True:
pass
except KeyboardInterrupt:
api.stop()

View File

@ -1,20 +1,24 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
import time import time
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
rsi.start_rsi() from multiprocessing import freeze_support
freeze_support()
# Plan simple trajectory
points = [ api = RSIAPI()
{"x": 0, "y": 0, "z": 0}, api.start()
{"x": 50, "y": 0, "z": 0},
{"x": 50, "y": 50, "z": 0}, # Plan simple trajectory
{"x": 0, "y": 50, "z": 0}, points = [
{"x": 0, "y": 0, "z": 0} {"X": 0, "Y": 0, "Z": 0},
] {"X": 50, "Y": 0, "Z": 0},
{"X": 50, "Y": 50, "Z": 0},
for point in points: {"X": 0, "Y": 50, "Z": 0},
rsi.update_cartesian(**point) {"X": 0, "Y": 0, "Z": 0}
time.sleep(0.5) ]
rsi.stop_rsi() for point in points:
api.motion.update_cartesian(**point)
time.sleep(0.5)
api.stop()

View File

@ -1,14 +1,18 @@
from RSIPI import rsi_api from RSIPI import RSIAPI
rsi = rsi_api.RSIAPI() if __name__ == '__main__':
from multiprocessing import freeze_support
try: freeze_support()
rsi.start_rsi()
print("Press Ctrl+C to stop RSI safely.") api = RSIAPI()
while True:
pass try:
api.start()
except KeyboardInterrupt: print("Press Ctrl+C to stop RSI safely.")
print("\nEmergency stop triggered.") while True:
rsi.safety_stop() pass
rsi.stop_rsi()
except KeyboardInterrupt:
print("\nEmergency stop triggered.")
api.safety.stop()
api.stop()

112
main.py
View File

@ -10,7 +10,7 @@ Usage:
""" """
import argparse import argparse
import time import time # noqa: F401 - used by commented examples
from multiprocessing import freeze_support from multiprocessing import freeze_support
from RSIPI import RSIAPI from RSIPI import RSIAPI
@ -20,9 +20,23 @@ if __name__ == '__main__':
parser = argparse.ArgumentParser(description="RSIPI Test Runner") parser = argparse.ArgumentParser(description="RSIPI Test Runner")
parser.add_argument("--config", type=str, default="RSI_EthernetConfig.xml", parser.add_argument("--config", type=str, default="RSI_EthernetConfig.xml",
help="Path to RSI config XML file") help="Path to RSI config XML file")
parser.add_argument("--mode", type=str, default="relative", choices=["absolute", "relative"],
help="RSI correction mode (must match KRL program)")
parser.add_argument("--max-cart-rate", type=float, default=0.5,
help="Max Cartesian correction per cycle in mm (0 = no limit)")
parser.add_argument("--max-joint-rate", type=float, default=0.2,
help="Max joint correction per cycle in degrees (0 = no limit)")
parser.add_argument("--cycle-time", type=float, default=0.004,
help="RSI cycle time in seconds (0.004 = 4ms/250Hz, 0.012 = 12ms/83Hz)")
args = parser.parse_args() args = parser.parse_args()
api = RSIAPI(args.config) api = RSIAPI(
args.config,
rsi_mode=args.mode,
max_cartesian_rate=args.max_cart_rate,
max_joint_rate=args.max_joint_rate,
cycle_time=args.cycle_time
)
# ========================================================================= # =========================================================================
# Example 01: Start / Stop # Example 01: Start / Stop
@ -35,25 +49,24 @@ if __name__ == '__main__':
# ========================================================================= # =========================================================================
# Example 02: Send Cartesian correction (move TCP 50mm along X) # Example 02: Send Cartesian correction (move TCP 50mm along X)
# ========================================================================= # =========================================================================
api.start() # api.start()
print("Waiting for robot connection...") # print("Waiting for robot connection...")
while not api.is_running(): # if not api.wait_for_connection(timeout=10):
time.sleep(0.1) # print("Timeout waiting for robot. Exiting.")
# Wait for first IPOC to confirm packets are flowing # api.stop()
while api.monitoring.get_ipoc() == "N/A" or api.monitoring.get_ipoc() == 0: # exit(1)
time.sleep(0.1) # print(f"Connected. IPOC: {api.monitoring.get_ipoc()}")
print(f"Connected. IPOC: {api.monitoring.get_ipoc()}") # api.motion.update_cartesian(X=0.01, Y=0, Z=0)
api.motion.update_cartesian(X=0.01, Y=0, Z=0) # print("Sent Cartesian correction. Press Enter to stop.")
print("Sent Cartesian correction. Press Enter to stop.") # input()
input() # api.stop()
api.stop()
# ========================================================================= # =========================================================================
# Example 03: Send Joint correction (move A1 by 10 degrees) # Example 03: Send Joint correction (move A1 by 10 degrees) DOESNT WORK
# ========================================================================= # =========================================================================
# api.start() # api.start()
# time.sleep(1) # time.sleep(1)
# api.motion.update_joints(A1=10) # api.motion.update_joints(A1=50)
# print("Sent joint correction. Press Enter to stop.") # print("Sent joint correction. Press Enter to stop.")
# input() # input()
# api.stop() # api.stop()
@ -74,8 +87,9 @@ if __name__ == '__main__':
# api.start() # api.start()
# time.sleep(1) # time.sleep(1)
# api.io.set_output(1, True) # api.io.set_output(1, True)
# api.io.set_output(2, False) # time.sleep(5)
# api.io.set_output(3, True) # api.io.set_output(1, False)
# print("Set digital outputs. Press Enter to stop.") # print("Set digital outputs. Press Enter to stop.")
# input() # input()
# api.stop() # api.stop()
@ -207,39 +221,53 @@ if __name__ == '__main__':
# Advanced Motion 01: Velocity Profiles (trapezoidal vs S-curve) # Advanced Motion 01: Velocity Profiles (trapezoidal vs S-curve)
# ========================================================================= # =========================================================================
# api.start() # api.start()
# time.sleep(1) # time.sleep(4)
# # Relative mode: each point is a per-cycle delta
# # 200 steps × 0.5mm = 100mm total at max rate limit
# traj = api.motion.generate_trajectory( # traj = api.motion.generate_trajectory(
# {"X": 0, "Y": 0, "Z": 0}, # {"X": 0, "Y": 0, "Z": 0},
# {"X": 100, "Y": 0, "Z": 0}, # {"X": 100, "Y": 0, "Z": 0},
# steps=50) # steps=200,
# trap_profile = api.motion.generate_velocity_profile( # mode="relative")
# traj, max_velocity=50, max_acceleration=100, profile='trapezoidal') # print(f"Executing trajectory: {len(traj)} steps, {traj[0]['X']:.2f}mm per step")
# scurve_profile = api.motion.generate_velocity_profile( # api.motion.execute_trajectory(traj, space="cartesian", rate=0.012)
# traj, max_velocity=50, max_acceleration=100, profile='s-curve') # print("Trajectory executed. Press Enter to stop.")
# print(f"Trapezoidal: {len(trap_profile)} points")
# print(f"S-curve: {len(scurve_profile)} points")
# print("Press Enter to stop.")
# input() # input()
# api.stop() # api.stop()
# ========================================================================= # =========================================================================
# Advanced Motion 02: Geometric Primitives (arc, circle, spiral) # Advanced Motion 02: Geometric Primitives (arc, circle, spiral)
# ========================================================================= # =========================================================================
# api.start() api.start()
# time.sleep(1) print("Waiting for robot connection...")
# arc = api.motion.generate_arc( if not api.wait_for_connection(timeout=10):
# center={"X": 0, "Y": 0, "Z": 0}, print("Timeout waiting for robot. Exiting.")
# radius=50, start_angle=0, end_angle=90, steps=20) api.stop()
# circle = api.motion.generate_circle( exit(1)
# center={"X": 0, "Y": 0, "Z": 0}, print(f"Connected. IPOC: {api.monitoring.get_ipoc()}")
# radius=50, steps=36) input("Press Enter to start movement...")
# spiral = api.motion.generate_spiral(
# center={"X": 0, "Y": 0, "Z": 0}, # Generate absolute circle waypoints, convert to relative deltas
# start_radius=10, end_radius=50, pitch=5.0, revolutions=3, steps=60) circle_abs = api.motion.generate_circle(
# print(f"Arc: {len(arc)} pts, Circle: {len(circle)} pts, Spiral: {len(spiral)} pts") center={"X": 0, "Y": 0, "Z": 0},
# print("Press Enter to stop.") radius=5, steps=200)
# input()
# api.stop() # Convert absolute → relative (delta between consecutive points)
# Start prev at first point so there's no initial jump
circle_rel = []
prev = circle_abs[0]
for pt in circle_abs[1:]:
delta = {k: pt[k] - prev.get(k, 0) for k in pt}
circle_rel.append(delta)
prev = pt
print(f"Executing circle: {len(circle_rel)} relative steps, radius=5mm")
api.motion.execute_trajectory(circle_rel, space="cartesian", rate=0.012)
# Zero out corrections so robot stops moving in relative mode
api.motion.update_cartesian(X=0, Y=0, Z=0)
print("Circle complete. Press Enter to stop.")
input()
api.stop()
# ========================================================================= # =========================================================================
# Advanced Motion 03: Path Blending (smooth corner transitions) # Advanced Motion 03: Path Blending (smooth corner transitions)

517
rsi_config/RSIPI_Full.rsi Normal file
View File

@ -0,0 +1,517 @@
<?xml version="1.0" encoding="utf-8"?>
<rSIModel dslVersion="1.0.0.0" name="" xmlns="http://schemas.microsoft.com/dsltools/RSIVisual">
<rSIObjects>
<!-- =================== Signal Sources (Robot State) =================== -->
<rSIElement name="POSACT1" objType="POSACT" objTypeID="46" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="X" />
<rSIOutPort name="Y" />
<rSIOutPort name="Z" />
<rSIOutPort name="A" />
<rSIOutPort name="B" />
<rSIOutPort name="C" />
<rSIOutPort name="S" signalType="Int" />
<rSIOutPort name="T" signalType="Int" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Type" value="Measured" paramType="KUKA.RSIVisual.RSI_PosActType" minVal="-2147483648" maxVal="2147483647" isEnum="true" isRuntime="false" index="1" />
</rSIParameters>
</rSIElement>
<rSIElement name="AXISACT1" objType="AXISACT" objTypeID="44" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="A1" />
<rSIOutPort name="A2" />
<rSIOutPort name="A3" />
<rSIOutPort name="A4" />
<rSIOutPort name="A5" />
<rSIOutPort name="A6" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Type" value="Measured" paramType="KUKA.RSIVisual.RSI_AxisActType" minVal="-2147483648" maxVal="2147483647" isEnum="true" isRuntime="false" index="1" />
</rSIParameters>
</rSIElement>
<rSIElement name="AXISACTEXT1" objType="AXISACTEXT" objTypeID="69" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="E1" />
<rSIOutPort name="E2" />
<rSIOutPort name="E3" />
<rSIOutPort name="E4" />
<rSIOutPort name="E5" />
<rSIOutPort name="E6" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Type" value="Measured" paramType="KUKA.RSIVisual.RSI_AxisActType" minVal="-2147483648" maxVal="2147483647" isEnum="true" isRuntime="false" index="1" />
</rSIParameters>
</rSIElement>
<rSIElement name="MOTORCURRENT1" objType="MOTORCURRENT" objTypeID="57" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="A1" />
<rSIOutPort name="A2" />
<rSIOutPort name="A3" />
<rSIOutPort name="A4" />
<rSIOutPort name="A5" />
<rSIOutPort name="A6" />
</rSIOutPorts>
</rSIElement>
<rSIElement name="DIGIN1" objType="DIGIN" objTypeID="29" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" signalType="Int" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Index" value="1" paramType="System.Int32" minVal="1" maxVal="4096" isEnum="false" index="1" />
<rSIParameter name="DataSize" value="Byte" paramType="KUKA.RSIVisual.RSI_DataSize" minVal="-2147483648" maxVal="2147483647" isEnum="true" index="2" />
</rSIParameters>
</rSIElement>
<rSIElement name="DIGOUT1" objType="DIGOUT" objTypeID="43" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" signalType="Int" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Index" value="1" paramType="System.Int32" minVal="1" maxVal="4096" isEnum="false" index="1" />
<rSIParameter name="DataSize" value="Bit" paramType="KUKA.RSIVisual.RSI_DataSize" minVal="-2147483648" maxVal="2147483647" isEnum="true" index="2" />
</rSIParameters>
</rSIElement>
<rSIElement name="DIGOUT2" objType="DIGOUT" objTypeID="43" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" signalType="Int" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Index" value="2" paramType="System.Int32" minVal="1" maxVal="4096" isEnum="false" index="1" />
<rSIParameter name="DataSize" value="Bit" paramType="KUKA.RSIVisual.RSI_DataSize" minVal="-2147483648" maxVal="2147483647" isEnum="true" index="2" />
</rSIParameters>
</rSIElement>
<rSIElement name="DIGOUT3" objType="DIGOUT" objTypeID="43" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" signalType="Int" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Index" value="3" paramType="System.Int32" minVal="1" maxVal="4096" isEnum="false" index="1" />
<rSIParameter name="DataSize" value="Bit" paramType="KUKA.RSIVisual.RSI_DataSize" minVal="-2147483648" maxVal="2147483647" isEnum="true" index="2" />
</rSIParameters>
</rSIElement>
<rSIElement name="SOURCE1" objType="SOURCE" objTypeID="45" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="Type" value="Sin" paramType="KUKA.RSIVisual.RSI_SourceType" minVal="-2147483648" maxVal="2147483647" isEnum="true" index="1" />
<rSIParameter name="Offset" value="0" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="2" />
<rSIParameter name="Amplitude" value="0" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="3" />
<rSIParameter name="Period" value="0" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="4" />
</rSIParameters>
</rSIElement>
<rSIElement name="OV_PRO1" objType="OV_PRO" objTypeID="73" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" />
</rSIOutPorts>
</rSIElement>
<rSIElement name="STATUS1" objType="STATUS" objTypeID="72" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="Out1" signalType="Int" />
</rSIOutPorts>
</rSIElement>
<!-- =================== Action Objects (PC to Robot) =================== -->
<rSIElement name="POSCORR1" objType="POSCORR" objTypeID="27" maxInputs="0" maxOutputs="0">
<rSIInPorts>
<rSIInPort name="CorrX" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out1" />
</source>
</rSIInPort>
<rSIInPort name="CorrY" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out2" />
</source>
</rSIInPort>
<rSIInPort name="CorrZ" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out3" />
</source>
</rSIInPort>
<rSIInPort name="CorrA" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out4" />
</source>
</rSIInPort>
<rSIInPort name="CorrB" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out5" />
</source>
</rSIInPort>
<rSIInPort name="CorrC" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out6" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIOutPorts>
<rSIOutPort name="Stat" signalType="Int" />
<rSIOutPort name="X" />
<rSIOutPort name="Y" />
<rSIOutPort name="Z" />
<rSIOutPort name="A" />
<rSIOutPort name="B" />
<rSIOutPort name="C" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="LowerLimX" value="-500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="1" />
<rSIParameter name="LowerLimY" value="-500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="2" />
<rSIParameter name="LowerLimZ" value="-500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="3" />
<rSIParameter name="UpperLimX" value="500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="4" />
<rSIParameter name="UpperLimY" value="500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="5" />
<rSIParameter name="UpperLimZ" value="500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="6" />
<rSIParameter name="MaxRotAngle" value="500" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="7" />
<rSIParameter name="RefCorrSys" value="Base" paramType="KUKA.RSIVisual.RSI_TrafoCosys" minVal="-2147483648" maxVal="2147483647" isEnum="true" isRuntime="false" index="1" />
</rSIParameters>
</rSIElement>
<rSIElement name="AXISCORR1" objType="AXISCORR" objTypeID="24" maxInputs="0" maxOutputs="0">
<rSIInPorts>
<rSIInPort name="CorrA1" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out7" />
</source>
</rSIInPort>
<rSIInPort name="CorrA2" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out8" />
</source>
</rSIInPort>
<rSIInPort name="CorrA3" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out9" />
</source>
</rSIInPort>
<rSIInPort name="CorrA4" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out10" />
</source>
</rSIInPort>
<rSIInPort name="CorrA5" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out11" />
</source>
</rSIInPort>
<rSIInPort name="CorrA6" mandatory="false">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out12" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIOutPorts>
<rSIOutPort name="Stat" signalType="Int" />
<rSIOutPort name="A1" />
<rSIOutPort name="A2" />
<rSIOutPort name="A3" />
<rSIOutPort name="A4" />
<rSIOutPort name="A5" />
<rSIOutPort name="A6" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="LowerLimA1" value="-180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="1" />
<rSIParameter name="LowerLimA2" value="-180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="2" />
<rSIParameter name="LowerLimA3" value="-180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="3" />
<rSIParameter name="LowerLimA4" value="-180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="4" />
<rSIParameter name="LowerLimA5" value="-180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="5" />
<rSIParameter name="LowerLimA6" value="-180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="6" />
<rSIParameter name="UpperLimA1" value="180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="7" />
<rSIParameter name="UpperLimA2" value="180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="8" />
<rSIParameter name="UpperLimA3" value="180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="9" />
<rSIParameter name="UpperLimA4" value="180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="10" />
<rSIParameter name="UpperLimA5" value="180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="11" />
<rSIParameter name="UpperLimA6" value="180" paramType="System.Double" minVal="-2147483648" maxVal="2147483647" isEnum="false" index="12" />
</rSIParameters>
</rSIElement>
<rSIElement name="MAP2DIGOUT1" objType="MAP2DIGOUT" objTypeID="14" maxInputs="0" maxOutputs="0">
<rSIInPorts>
<rSIInPort name="In1" signalType="Int">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out13" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIParameters>
<rSIParameter name="Index" value="20" paramType="System.Int32" minVal="1" maxVal="4096" isEnum="false" index="1" />
<rSIParameter name="DataSize" value="Word" paramType="KUKA.RSIVisual.RSI_DataSizeX" minVal="-2147483648" maxVal="2147483647" isEnum="true" index="2" />
</rSIParameters>
</rSIElement>
<rSIElement name="MAP2SEN_PREA1" objType="MAP2SEN_PREA" objTypeID="17" maxInputs="0" maxOutputs="0">
<rSIInPorts>
<rSIInPort name="In1">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out1" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIParameters>
<rSIParameter name="Index" value="1" paramType="System.Int32" minVal="1" maxVal="20" isEnum="false" index="1" />
</rSIParameters>
</rSIElement>
<rSIElement name="MAP2SEN_PREA2" objType="MAP2SEN_PREA" objTypeID="17" maxInputs="0" maxOutputs="0">
<rSIInPorts>
<rSIInPort name="In1">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out2" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIParameters>
<rSIParameter name="Index" value="2" paramType="System.Int32" minVal="1" maxVal="20" isEnum="false" index="1" />
</rSIParameters>
</rSIElement>
<rSIElement name="MAP2SEN_PREA3" objType="MAP2SEN_PREA" objTypeID="17" maxInputs="0" maxOutputs="0">
<rSIInPorts>
<rSIInPort name="In1">
<source>
<rSIOutPortMoniker name="//ETHERNET1/Out3" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIParameters>
<rSIParameter name="Index" value="3" paramType="System.Int32" minVal="1" maxVal="20" isEnum="false" index="1" />
</rSIParameters>
</rSIElement>
<!-- =================== Monitoring =================== -->
<rSIElement name="POSCORRMON1" objType="POSCORRMON" objTypeID="81" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="X" />
<rSIOutPort name="Y" />
<rSIOutPort name="Z" />
<rSIOutPort name="A" />
<rSIOutPort name="B" />
<rSIOutPort name="C" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="MaxTrans" value="500" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="1" />
<rSIParameter name="MaxRotAngle" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="2" />
</rSIParameters>
</rSIElement>
<rSIElement name="AXISCORRMON1" objType="AXISCORRMON" objTypeID="82" maxInputs="0" maxOutputs="0">
<rSIOutPorts>
<rSIOutPort name="A1" />
<rSIOutPort name="A2" />
<rSIOutPort name="A3" />
<rSIOutPort name="A4" />
<rSIOutPort name="A5" />
<rSIOutPort name="A6" />
<rSIOutPort name="E1" />
<rSIOutPort name="E2" />
<rSIOutPort name="E3" />
<rSIOutPort name="E4" />
<rSIOutPort name="E5" />
<rSIOutPort name="E6" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="MaxA1" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="1" />
<rSIParameter name="MaxA2" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="2" />
<rSIParameter name="MaxA3" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="3" />
<rSIParameter name="MaxA4" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="4" />
<rSIParameter name="MaxA5" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="5" />
<rSIParameter name="MaxA6" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="6" />
<rSIParameter name="MaxE1" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="7" />
<rSIParameter name="MaxE2" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="8" />
<rSIParameter name="MaxE3" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="9" />
<rSIParameter name="MaxE4" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="10" />
<rSIParameter name="MaxE5" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="11" />
<rSIParameter name="MaxE6" value="180" paramType="System.Double" minVal="0" maxVal="2147483647" isEnum="false" index="12" />
</rSIParameters>
</rSIElement>
<!-- =================== ETHERNET Communication =================== -->
<rSIElement name="ETHERNET1" objType="ETHERNET" objTypeID="64" maxInputs="64" maxOutputs="64">
<rSIInPorts>
<rSIInPort name="In1" mandatory="false">
<source>
<rSIOutPortMoniker name="//DIGIN1/Out1" />
</source>
</rSIInPort>
<rSIInPort name="In2" mandatory="false">
<source>
<rSIOutPortMoniker name="//DIGOUT1/Out1" />
</source>
</rSIInPort>
<rSIInPort name="In3" mandatory="false">
<source>
<rSIOutPortMoniker name="//DIGOUT2/Out1" />
</source>
</rSIInPort>
<rSIInPort name="In4" mandatory="false">
<source>
<rSIOutPortMoniker name="//DIGOUT3/Out1" />
</source>
</rSIInPort>
<rSIInPort name="In5" mandatory="false">
<source>
<rSIOutPortMoniker name="//SOURCE1/Out1" />
</source>
</rSIInPort>
<rSIInPort name="In6" mandatory="false" />
<rSIInPort name="In7" mandatory="false" />
<rSIInPort name="In8" mandatory="false" />
<rSIInPort name="In9" mandatory="false" />
<rSIInPort name="In10" mandatory="false" />
<rSIInPort name="In11" mandatory="false" />
<rSIInPort name="In12" mandatory="false" />
<rSIInPort name="In13" mandatory="false">
<source>
<rSIOutPortMoniker name="//POSACT1/X" />
</source>
</rSIInPort>
<rSIInPort name="In14" mandatory="false">
<source>
<rSIOutPortMoniker name="//POSACT1/Y" />
</source>
</rSIInPort>
<rSIInPort name="In15" mandatory="false">
<source>
<rSIOutPortMoniker name="//POSACT1/Z" />
</source>
</rSIInPort>
<rSIInPort name="In16" mandatory="false">
<source>
<rSIOutPortMoniker name="//POSACT1/A" />
</source>
</rSIInPort>
<rSIInPort name="In17" mandatory="false">
<source>
<rSIOutPortMoniker name="//POSACT1/B" />
</source>
</rSIInPort>
<rSIInPort name="In18" mandatory="false">
<source>
<rSIOutPortMoniker name="//POSACT1/C" />
</source>
</rSIInPort>
<rSIInPort name="In19" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACT1/A1" />
</source>
</rSIInPort>
<rSIInPort name="In20" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACT1/A2" />
</source>
</rSIInPort>
<rSIInPort name="In21" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACT1/A3" />
</source>
</rSIInPort>
<rSIInPort name="In22" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACT1/A4" />
</source>
</rSIInPort>
<rSIInPort name="In23" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACT1/A5" />
</source>
</rSIInPort>
<rSIInPort name="In24" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACT1/A6" />
</source>
</rSIInPort>
<rSIInPort name="In25" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACTEXT1/E1" />
</source>
</rSIInPort>
<rSIInPort name="In26" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACTEXT1/E2" />
</source>
</rSIInPort>
<rSIInPort name="In27" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACTEXT1/E3" />
</source>
</rSIInPort>
<rSIInPort name="In28" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACTEXT1/E4" />
</source>
</rSIInPort>
<rSIInPort name="In29" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACTEXT1/E5" />
</source>
</rSIInPort>
<rSIInPort name="In30" mandatory="false">
<source>
<rSIOutPortMoniker name="//AXISACTEXT1/E6" />
</source>
</rSIInPort>
<rSIInPort name="In31" mandatory="false">
<source>
<rSIOutPortMoniker name="//MOTORCURRENT1/A1" />
</source>
</rSIInPort>
<rSIInPort name="In32" mandatory="false">
<source>
<rSIOutPortMoniker name="//MOTORCURRENT1/A2" />
</source>
</rSIInPort>
<rSIInPort name="In33" mandatory="false">
<source>
<rSIOutPortMoniker name="//MOTORCURRENT1/A3" />
</source>
</rSIInPort>
<rSIInPort name="In34" mandatory="false">
<source>
<rSIOutPortMoniker name="//MOTORCURRENT1/A4" />
</source>
</rSIInPort>
<rSIInPort name="In35" mandatory="false">
<source>
<rSIOutPortMoniker name="//MOTORCURRENT1/A5" />
</source>
</rSIInPort>
<rSIInPort name="In36" mandatory="false">
<source>
<rSIOutPortMoniker name="//MOTORCURRENT1/A6" />
</source>
</rSIInPort>
<rSIInPort name="In37" mandatory="false">
<source>
<rSIOutPortMoniker name="//OV_PRO1/Out1" />
</source>
</rSIInPort>
<rSIInPort name="In38" mandatory="false">
<source>
<rSIOutPortMoniker name="//STATUS1/Out1" />
</source>
</rSIInPort>
</rSIInPorts>
<rSIOutPorts>
<rSIOutPort name="Out1" />
<rSIOutPort name="Out2" />
<rSIOutPort name="Out3" />
<rSIOutPort name="Out4" />
<rSIOutPort name="Out5" />
<rSIOutPort name="Out6" />
<rSIOutPort name="Out7" />
<rSIOutPort name="Out8" />
<rSIOutPort name="Out9" />
<rSIOutPort name="Out10" />
<rSIOutPort name="Out11" />
<rSIOutPort name="Out12" />
<rSIOutPort name="Out13" />
<rSIOutPort name="Out14" />
<rSIOutPort name="Out15" />
<rSIOutPort name="Out16" />
<rSIOutPort name="Out17" />
<rSIOutPort name="Out18" />
<rSIOutPort name="Out19" />
<rSIOutPort name="Out20" />
</rSIOutPorts>
<rSIParameters>
<rSIParameter name="ConfigFile" value="RSI_EthernetConfig_Full.xml" paramType="System.FileName" minVal="-2147483648" maxVal="2147483647" isEnum="false" isRuntime="false" index="1" />
<rSIParameter name="Timeout" value="100" paramType="System.Int32" minVal="0" maxVal="2147483647" isEnum="false" index="1" />
<rSIParameter name="Flag" value="1" paramType="System.Int32" minVal="-1" maxVal="999" isEnum="false" index="4" />
<rSIParameter name="Precision" value="1" paramType="System.Int32" minVal="1" maxVal="32" isEnum="false" index="8" />
</rSIParameters>
</rSIElement>
</rSIObjects>
</rSIModel>

View File

@ -0,0 +1,256 @@
<?xml version="1.0" encoding="utf-8"?>
<rSIObjectDiagram dslVersion="1.0.0.0" absoluteBounds="0, 0, 18, 14" name="RSIPI_Full">
<rSIModelMoniker name="/" />
<nestedChildShapes>
<!-- Row 1: Signal source objects -->
<rSIElementShape Id="a1000001-0000-0000-0000-000000000001" absoluteBounds="0.5, 0.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//POSACT1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000001-0001-0000-0000-000000000001" absoluteBounds="2, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//POSACT1/X" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000001-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="0.515, 1.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000002-0000-0000-0000-000000000001" absoluteBounds="2.5, 0.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//AXISACT1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000002-0001-0000-0000-000000000001" absoluteBounds="4, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//AXISACT1/A1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000002-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="2.515, 1.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000003-0000-0000-0000-000000000001" absoluteBounds="4.5, 0.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//AXISACTEXT1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000003-0001-0000-0000-000000000001" absoluteBounds="6, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//AXISACTEXT1/E1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000003-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="4.515, 1.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000004-0000-0000-0000-000000000001" absoluteBounds="6.5, 0.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//MOTORCURRENT1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000004-0001-0000-0000-000000000001" absoluteBounds="8, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//MOTORCURRENT1/A1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000004-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="6.515, 1.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<!-- Row 2: Digital I/O and Source -->
<rSIElementShape Id="a1000005-0000-0000-0000-000000000001" absoluteBounds="0.5, 2, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//DIGIN1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000005-0001-0000-0000-000000000001" absoluteBounds="2, 2.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//DIGIN1/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000005-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="0.515, 2.51, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000006-0000-0000-0000-000000000001" absoluteBounds="2.5, 2, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//DIGOUT1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000006-0001-0000-0000-000000000001" absoluteBounds="4, 2.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//DIGOUT1/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000006-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="2.515, 2.51, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000007-0000-0000-0000-000000000001" absoluteBounds="4.5, 2, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//DIGOUT2" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000007-0001-0000-0000-000000000001" absoluteBounds="6, 2.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//DIGOUT2/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000007-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="4.515, 2.51, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000008-0000-0000-0000-000000000001" absoluteBounds="6.5, 2, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//DIGOUT3" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000008-0001-0000-0000-000000000001" absoluteBounds="8, 2.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//DIGOUT3/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000008-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="6.515, 2.51, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000009-0000-0000-0000-000000000001" absoluteBounds="8.5, 2, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//SOURCE1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000009-0001-0000-0000-000000000001" absoluteBounds="10, 2.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//SOURCE1/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000009-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="8.515, 2.51, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a100000a-0000-0000-0000-000000000001" absoluteBounds="10.5, 0.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//OV_PRO1" />
<relativeChildShapes>
<rSIOutPortShape Id="a100000a-0001-0000-0000-000000000001" absoluteBounds="12, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//OV_PRO1/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a100000a-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="10.515, 1.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a100000b-0000-0000-0000-000000000001" absoluteBounds="12.5, 0.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//STATUS1" />
<relativeChildShapes>
<rSIOutPortShape Id="a100000b-0001-0000-0000-000000000001" absoluteBounds="14, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//STATUS1/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a100000b-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="12.515, 1.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<!-- Row 3: ETHERNET (center, large) -->
<rSIElementShape Id="a100000c-0000-0000-0000-000000000001" absoluteBounds="5, 4, 3, 8" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//ETHERNET1" />
<relativeChildShapes>
<rSIInPortShape Id="a100000c-0001-0000-0000-000000000001" absoluteBounds="4.6, 4.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIInPortMoniker name="//ETHERNET1/In1" />
<relativeChildShapes />
</rSIInPortShape>
<rSIOutPortShape Id="a100000c-0002-0000-0000-000000000001" absoluteBounds="8, 4.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//ETHERNET1/Out1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a100000c-0003-0000-0000-000000000001" absoluteBounds="5.015, 4.51, 2.9700000000000002, 1.0185953776041665" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<!-- Row 4: Action objects -->
<rSIElementShape Id="a100000d-0000-0000-0000-000000000001" absoluteBounds="10, 4, 2, 2.875" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//POSCORR1" />
<relativeChildShapes>
<rSIInPortShape Id="a100000d-0001-0000-0000-000000000001" absoluteBounds="9.6, 4.25, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIInPortMoniker name="//POSCORR1/CorrX" />
<relativeChildShapes />
</rSIInPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a100000d-0002-0000-0000-000000000001" absoluteBounds="10.015, 4.51, 1.9700000000000002, 1.7878011067708333" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a100000e-0000-0000-0000-000000000001" absoluteBounds="10, 7.5, 2, 2.875" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//AXISCORR1" />
<relativeChildShapes>
<rSIInPortShape Id="a100000e-0001-0000-0000-000000000001" absoluteBounds="9.6, 7.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIInPortMoniker name="//AXISCORR1/CorrA1" />
<relativeChildShapes />
</rSIInPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a100000e-0002-0000-0000-000000000001" absoluteBounds="10.015, 8.01, 1.9700000000000002, 2.5570068359375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a100000f-0000-0000-0000-000000000001" absoluteBounds="10, 11, 2, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//MAP2DIGOUT1" />
<relativeChildShapes>
<rSIInPortShape Id="a100000f-0001-0000-0000-000000000001" absoluteBounds="9.6, 11.25, 0.40000000596046448, 0.075000002980232239" fillColor="BlanchedAlmond">
<rSIInPortMoniker name="//MAP2DIGOUT1/In1" />
<relativeChildShapes />
</rSIInPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a100000f-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="10.015, 11.51, 1.9700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<!-- Row 5: MAP2SEN_PREA objects -->
<rSIElementShape Id="a1000010-0000-0000-0000-000000000001" absoluteBounds="13, 4, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//MAP2SEN_PREA1" />
<relativeChildShapes>
<rSIInPortShape Id="a1000010-0001-0000-0000-000000000001" absoluteBounds="12.6, 4.25, 0.40000000596046448, 0.075000002980232239" fillColor="BlanchedAlmond">
<rSIInPortMoniker name="//MAP2SEN_PREA1/In1" />
<relativeChildShapes />
</rSIInPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000010-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="13.015, 4.51, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000011-0000-0000-0000-000000000001" absoluteBounds="13, 5.25, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//MAP2SEN_PREA2" />
<relativeChildShapes>
<rSIInPortShape Id="a1000011-0001-0000-0000-000000000001" absoluteBounds="12.6, 5.5, 0.40000000596046448, 0.075000002980232239" fillColor="BlanchedAlmond">
<rSIInPortMoniker name="//MAP2SEN_PREA2/In1" />
<relativeChildShapes />
</rSIInPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000011-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="13.015, 5.76, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000012-0000-0000-0000-000000000001" absoluteBounds="13, 6.5, 1.5, 0.8593896484375001" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//MAP2SEN_PREA3" />
<relativeChildShapes>
<rSIInPortShape Id="a1000012-0001-0000-0000-000000000001" absoluteBounds="12.6, 6.75, 0.40000000596046448, 0.075000002980232239" fillColor="BlanchedAlmond">
<rSIInPortMoniker name="//MAP2SEN_PREA3/In1" />
<relativeChildShapes />
</rSIInPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000012-0002-0000-0000-000000000001" isExpanded="false" absoluteBounds="13.015, 7.01, 1.4700000000000002, 0.2493896484375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<!-- Row 6: Monitoring objects -->
<rSIElementShape Id="a1000013-0000-0000-0000-000000000001" absoluteBounds="14.5, 0.5, 1.5, 2.475" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//POSCORRMON1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000013-0001-0000-0000-000000000001" absoluteBounds="16, 0.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//POSCORRMON1/X" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000013-0002-0000-0000-000000000001" absoluteBounds="14.515, 1.01, 1.4700000000000002, 0.63399251302083326" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
<rSIElementShape Id="a1000014-0000-0000-0000-000000000001" absoluteBounds="14.5, 3.5, 1.5, 4.875" fillColor="BlanchedAlmond">
<rSIElementMoniker name="//AXISCORRMON1" />
<relativeChildShapes>
<rSIOutPortShape Id="a1000014-0001-0000-0000-000000000001" absoluteBounds="16, 3.75, 0.40000000596046448, 0.075000002980232239" fillColor="White">
<rSIOutPortMoniker name="//AXISCORRMON1/A1" />
<relativeChildShapes />
</rSIOutPortShape>
</relativeChildShapes>
<nestedChildShapes>
<elementListCompartment Id="a1000014-0002-0000-0000-000000000001" absoluteBounds="14.515, 4.01, 1.4700000000000002, 2.5570068359375" name="RSIParameters" titleTextColor="Black" itemTextColor="Black" />
</nestedChildShapes>
</rSIElementShape>
</nestedChildShapes>
</rSIObjectDiagram>

View File

@ -0,0 +1,203 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<RSIObjects xsi:noNamespaceSchemaLocation="/Roboter/Config/System/Common/Schemes/RSIContext.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- =================== Signal Sources (Robot State) =================== -->
<RSIObject ObjType="POSACT" ObjTypeID="46" ObjID="POSACT1">
<Parameters>
<Parameter Name="Type" ParamID="1" ParamValue="1" IsRuntime="false" />
</Parameters>
</RSIObject>
<RSIObject ObjType="AXISACT" ObjTypeID="44" ObjID="AXISACT1">
<Parameters>
<Parameter Name="Type" ParamID="1" ParamValue="1" IsRuntime="false" />
</Parameters>
</RSIObject>
<RSIObject ObjType="AXISACTEXT" ObjTypeID="69" ObjID="AXISACTEXT1">
<Parameters>
<Parameter Name="Type" ParamID="1" ParamValue="1" IsRuntime="false" />
</Parameters>
</RSIObject>
<RSIObject ObjType="MOTORCURRENT" ObjTypeID="57" ObjID="MOTORCURRENT1">
</RSIObject>
<RSIObject ObjType="DIGIN" ObjTypeID="29" ObjID="DIGIN1">
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="1" />
<Parameter Name="DataSize" ParamID="2" ParamValue="2" />
</Parameters>
</RSIObject>
<RSIObject ObjType="DIGOUT" ObjTypeID="43" ObjID="DIGOUT1">
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="1" />
<Parameter Name="DataSize" ParamID="2" ParamValue="0" />
</Parameters>
</RSIObject>
<RSIObject ObjType="DIGOUT" ObjTypeID="43" ObjID="DIGOUT2">
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="2" />
<Parameter Name="DataSize" ParamID="2" ParamValue="0" />
</Parameters>
</RSIObject>
<RSIObject ObjType="DIGOUT" ObjTypeID="43" ObjID="DIGOUT3">
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="3" />
<Parameter Name="DataSize" ParamID="2" ParamValue="0" />
</Parameters>
</RSIObject>
<RSIObject ObjType="SOURCE" ObjTypeID="45" ObjID="SOURCE1">
<Parameters>
<Parameter Name="Type" ParamID="1" ParamValue="1" />
<Parameter Name="Offset" ParamID="2" ParamValue="0" />
<Parameter Name="Amplitude" ParamID="3" ParamValue="0" />
<Parameter Name="Period" ParamID="4" ParamValue="0" />
</Parameters>
</RSIObject>
<RSIObject ObjType="OV_PRO" ObjTypeID="73" ObjID="OV_PRO1">
</RSIObject>
<RSIObject ObjType="STATUS" ObjTypeID="72" ObjID="STATUS1">
</RSIObject>
<!-- =================== Action Objects (PC → Robot) =================== -->
<RSIObject ObjType="POSCORR" ObjTypeID="27" ObjID="POSCORR1">
<Inputs>
<Input InIdx="1" OutObjID="ETHERNET1" OutIdx="1" />
<Input InIdx="2" OutObjID="ETHERNET1" OutIdx="2" />
<Input InIdx="3" OutObjID="ETHERNET1" OutIdx="3" />
<Input InIdx="4" OutObjID="ETHERNET1" OutIdx="4" />
<Input InIdx="5" OutObjID="ETHERNET1" OutIdx="5" />
<Input InIdx="6" OutObjID="ETHERNET1" OutIdx="6" />
</Inputs>
<Parameters>
<Parameter Name="LowerLimX" ParamID="1" ParamValue="-500" />
<Parameter Name="LowerLimY" ParamID="2" ParamValue="-500" />
<Parameter Name="LowerLimZ" ParamID="3" ParamValue="-500" />
<Parameter Name="UpperLimX" ParamID="4" ParamValue="500" />
<Parameter Name="UpperLimY" ParamID="5" ParamValue="500" />
<Parameter Name="UpperLimZ" ParamID="6" ParamValue="500" />
<Parameter Name="MaxRotAngle" ParamID="7" ParamValue="500" />
<Parameter Name="RefCorrSys" ParamID="1" ParamValue="1" IsRuntime="false" />
</Parameters>
</RSIObject>
<RSIObject ObjType="AXISCORR" ObjTypeID="24" ObjID="AXISCORR1">
<Inputs>
<Input InIdx="1" OutObjID="ETHERNET1" OutIdx="7" />
<Input InIdx="2" OutObjID="ETHERNET1" OutIdx="8" />
<Input InIdx="3" OutObjID="ETHERNET1" OutIdx="9" />
<Input InIdx="4" OutObjID="ETHERNET1" OutIdx="10" />
<Input InIdx="5" OutObjID="ETHERNET1" OutIdx="11" />
<Input InIdx="6" OutObjID="ETHERNET1" OutIdx="12" />
</Inputs>
<Parameters>
<Parameter Name="LowerLimA1" ParamID="1" ParamValue="-180" />
<Parameter Name="LowerLimA2" ParamID="2" ParamValue="-180" />
<Parameter Name="LowerLimA3" ParamID="3" ParamValue="-180" />
<Parameter Name="LowerLimA4" ParamID="4" ParamValue="-180" />
<Parameter Name="LowerLimA5" ParamID="5" ParamValue="-180" />
<Parameter Name="LowerLimA6" ParamID="6" ParamValue="-180" />
<Parameter Name="UpperLimA1" ParamID="7" ParamValue="180" />
<Parameter Name="UpperLimA2" ParamID="8" ParamValue="180" />
<Parameter Name="UpperLimA3" ParamID="9" ParamValue="180" />
<Parameter Name="UpperLimA4" ParamID="10" ParamValue="180" />
<Parameter Name="UpperLimA5" ParamID="11" ParamValue="180" />
<Parameter Name="UpperLimA6" ParamID="12" ParamValue="180" />
</Parameters>
</RSIObject>
<RSIObject ObjType="MAP2DIGOUT" ObjTypeID="14" ObjID="MAP2DIGOUT1">
<Inputs>
<Input InIdx="1" OutObjID="ETHERNET1" OutIdx="13" />
</Inputs>
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="20" />
<Parameter Name="DataSize" ParamID="2" ParamValue="2" />
</Parameters>
</RSIObject>
<RSIObject ObjType="MAP2SEN_PREA" ObjTypeID="17" ObjID="MAP2SEN_PREA1">
<Inputs>
<Input InIdx="1" OutObjID="ETHERNET1" OutIdx="1" />
</Inputs>
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="1" />
</Parameters>
</RSIObject>
<RSIObject ObjType="MAP2SEN_PREA" ObjTypeID="17" ObjID="MAP2SEN_PREA2">
<Inputs>
<Input InIdx="1" OutObjID="ETHERNET1" OutIdx="2" />
</Inputs>
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="2" />
</Parameters>
</RSIObject>
<RSIObject ObjType="MAP2SEN_PREA" ObjTypeID="17" ObjID="MAP2SEN_PREA3">
<Inputs>
<Input InIdx="1" OutObjID="ETHERNET1" OutIdx="3" />
</Inputs>
<Parameters>
<Parameter Name="Index" ParamID="1" ParamValue="3" />
</Parameters>
</RSIObject>
<!-- =================== Monitoring =================== -->
<RSIObject ObjType="POSCORRMON" ObjTypeID="81" ObjID="POSCORRMON1">
<Parameters>
<Parameter Name="MaxTrans" ParamID="1" ParamValue="500" />
<Parameter Name="MaxRotAngle" ParamID="2" ParamValue="180" />
</Parameters>
</RSIObject>
<RSIObject ObjType="AXISCORRMON" ObjTypeID="82" ObjID="AXISCORRMON1">
<Parameters>
<Parameter Name="MaxA1" ParamID="1" ParamValue="180" />
<Parameter Name="MaxA2" ParamID="2" ParamValue="180" />
<Parameter Name="MaxA3" ParamID="3" ParamValue="180" />
<Parameter Name="MaxA4" ParamID="4" ParamValue="180" />
<Parameter Name="MaxA5" ParamID="5" ParamValue="180" />
<Parameter Name="MaxA6" ParamID="6" ParamValue="180" />
<Parameter Name="MaxE1" ParamID="7" ParamValue="180" />
<Parameter Name="MaxE2" ParamID="8" ParamValue="180" />
<Parameter Name="MaxE3" ParamID="9" ParamValue="180" />
<Parameter Name="MaxE4" ParamID="10" ParamValue="180" />
<Parameter Name="MaxE5" ParamID="11" ParamValue="180" />
<Parameter Name="MaxE6" ParamID="12" ParamValue="180" />
</Parameters>
</RSIObject>
<!-- =================== ETHERNET Communication =================== -->
<RSIObject ObjType="ETHERNET" ObjTypeID="64" ObjID="ETHERNET1">
<Inputs>
<Input InIdx="1" OutObjID="DIGIN1" OutIdx="1" />
<Input InIdx="2" OutObjID="DIGOUT1" OutIdx="1" />
<Input InIdx="3" OutObjID="DIGOUT2" OutIdx="1" />
<Input InIdx="4" OutObjID="DIGOUT3" OutIdx="1" />
<Input InIdx="5" OutObjID="SOURCE1" OutIdx="1" />
<Input InIdx="13" OutObjID="POSACT1" OutIdx="1" />
<Input InIdx="14" OutObjID="POSACT1" OutIdx="2" />
<Input InIdx="15" OutObjID="POSACT1" OutIdx="3" />
<Input InIdx="16" OutObjID="POSACT1" OutIdx="4" />
<Input InIdx="17" OutObjID="POSACT1" OutIdx="5" />
<Input InIdx="18" OutObjID="POSACT1" OutIdx="6" />
<Input InIdx="19" OutObjID="AXISACT1" OutIdx="1" />
<Input InIdx="20" OutObjID="AXISACT1" OutIdx="2" />
<Input InIdx="21" OutObjID="AXISACT1" OutIdx="3" />
<Input InIdx="22" OutObjID="AXISACT1" OutIdx="4" />
<Input InIdx="23" OutObjID="AXISACT1" OutIdx="5" />
<Input InIdx="24" OutObjID="AXISACT1" OutIdx="6" />
<Input InIdx="25" OutObjID="AXISACTEXT1" OutIdx="1" />
<Input InIdx="26" OutObjID="AXISACTEXT1" OutIdx="2" />
<Input InIdx="27" OutObjID="AXISACTEXT1" OutIdx="3" />
<Input InIdx="28" OutObjID="AXISACTEXT1" OutIdx="4" />
<Input InIdx="29" OutObjID="AXISACTEXT1" OutIdx="5" />
<Input InIdx="30" OutObjID="AXISACTEXT1" OutIdx="6" />
<Input InIdx="31" OutObjID="MOTORCURRENT1" OutIdx="1" />
<Input InIdx="32" OutObjID="MOTORCURRENT1" OutIdx="2" />
<Input InIdx="33" OutObjID="MOTORCURRENT1" OutIdx="3" />
<Input InIdx="34" OutObjID="MOTORCURRENT1" OutIdx="4" />
<Input InIdx="35" OutObjID="MOTORCURRENT1" OutIdx="5" />
<Input InIdx="36" OutObjID="MOTORCURRENT1" OutIdx="6" />
<Input InIdx="37" OutObjID="OV_PRO1" OutIdx="1" />
<Input InIdx="38" OutObjID="STATUS1" OutIdx="1" />
</Inputs>
<Parameters>
<Parameter Name="ConfigFile" ParamID="1" ParamValue="RSI_EthernetConfig_Full.xml" IsRuntime="false" />
<Parameter Name="Timeout" ParamID="1" ParamValue="100" />
<Parameter Name="Flag" ParamID="4" ParamValue="1" />
<Parameter Name="Precision" ParamID="8" ParamValue="1" />
</Parameters>
</RSIObject>
</RSIObjects>

207
rsi_config/RSIPI_Test.src Normal file
View File

@ -0,0 +1,207 @@
&H NOBOUNDSCHECK
DEF RSIPI_Test()
; =========================================================================
; RSIPI Comprehensive Test Program
; =========================================================================
; Tests all RSIPI Python library functionality:
; 1. RSI initialisation and connection
; 2. Cartesian correction (RSI_MOVECORR)
; 3. Tech variable exchange (Python <-> KRL)
; 4. $SEN_PREA variable reading
; 5. Digital I/O coordination
; 6. Handshake patterns (wait for Python, signal back)
;
; Matching Python script: rsi_config/rsipi_test.py
;
; Protocol (Tech.C11 = state from KRL, Tech.T11 = command from Python):
; State 0: Idle
; State 1: RSI connected, waiting for Python
; State 2: Running corrections (RSI_MOVECORR active)
; State 3: Corrections complete, reading SEN_PREA
; State 4: I/O test phase
; State 5: Complete
; State 99: Error
;
; Python commands via Tech.T11:
; 0: No command
; 1: Python ready, start corrections
; 2: Stop corrections
; 3: SEN_PREA values written, read them
; 4: Start I/O test
; 5: Shutdown
; =========================================================================
DECL INT ret, CONTID
DECL INT python_cmd
DECL REAL sen_val1, sen_val2, sen_val3
DECL E6POS start_pos
INI
; -- Store start position ------------------------------------------------
start_pos = $POS_ACT
; -- Move to a safe starting position ------------------------------------
; (Adjust these coordinates for your robot/cell)
PTP start_pos
; ========================================================================
; PHASE 1: Initialise RSI
; ========================================================================
; Load RSI signal flow configuration
ret = RSI_CREATE("RSIPI_Full.rsi", CONTID, TRUE)
IF (ret <> RSIOK) THEN
MsgNotify("RSI_CREATE failed", "RSIPI_Test")
HALT
ENDIF
; Activate RSI in RELATIVE mode at 4ms cycle
; Change to #ABSOLUTE / #IPO as needed
ret = RSI_ON(#RELATIVE, #IPO_FAST)
IF (ret <> RSIOK) THEN
MsgNotify("RSI_ON failed", "RSIPI_Test")
HALT
ENDIF
; Signal state 1: RSI connected, waiting for Python
$TECH.C[11] = 1
MsgNotify("RSI active - waiting for Python...", "RSIPI_Test")
; ========================================================================
; PHASE 2: Wait for Python to connect and signal ready
; ========================================================================
; Poll Tech.T11 for Python's "ready" command (value = 1)
python_cmd = 0
WHILE (python_cmd <> 1)
python_cmd = $TECH.T[11]
WAIT SEC 0.012 ; Check every 12ms
ENDWHILE
MsgNotify("Python connected - starting corrections", "RSIPI_Test")
; Send current position to Python via Tech.C12-C17
$TECH.C[12] = $POS_ACT.X
$TECH.C[13] = $POS_ACT.Y
$TECH.C[14] = $POS_ACT.Z
$TECH.C[15] = $POS_ACT.A
$TECH.C[16] = $POS_ACT.B
$TECH.C[17] = $POS_ACT.C
; ========================================================================
; PHASE 3: Sensor-guided motion (Python sends corrections)
; ========================================================================
; Signal state 2: corrections active
$TECH.C[11] = 2
; RSI_MOVECORR - robot is now purely controlled by Python corrections
; The robot will hold position and apply RKorr corrections from Python
; Python sends corrections via api.motion.update_cartesian()
; This blocks until Python sends stop command (Tech.T11 = 2)
; or until RSI is turned off
RSI_MOVECORR()
MsgNotify("Corrections phase complete", "RSIPI_Test")
; ========================================================================
; PHASE 4: Read $SEN_PREA values from Python
; ========================================================================
; Signal state 3: ready to read SEN_PREA
$TECH.C[11] = 3
; Wait for Python to write SEN_PREA values (command = 3)
python_cmd = 0
WHILE (python_cmd <> 3)
python_cmd = $TECH.T[11]
WAIT SEC 0.012
ENDWHILE
; Read values that Python wrote via MAP2SEN_PREA
sen_val1 = $SEN_PREA[1]
sen_val2 = $SEN_PREA[2]
sen_val3 = $SEN_PREA[3]
; Echo them back to Python via Tech.C18-C20
$TECH.C[18] = sen_val1
$TECH.C[19] = sen_val2
$TECH.C[110] = sen_val3
MsgNotify("SEN_PREA read complete", "RSIPI_Test")
; ========================================================================
; PHASE 5: Digital I/O test
; ========================================================================
; Signal state 4: I/O test phase
$TECH.C[11] = 4
; Wait for Python to start I/O test (command = 4)
python_cmd = 0
WHILE (python_cmd <> 4)
python_cmd = $TECH.T[11]
WAIT SEC 0.012
ENDWHILE
; Activate gripper (digital output 1)
$OUT[1] = TRUE
MsgNotify("Gripper ON - $OUT[1] = TRUE", "RSIPI_Test")
WAIT SEC 1.0
; Python should see Digout.o1 = TRUE in its send_variables
; Signal to Python that gripper is on
$TECH.C[11] = 41 ; Sub-state: gripper activated
; Wait for Python acknowledgement (command = 41)
python_cmd = 0
WHILE (python_cmd <> 41)
python_cmd = $TECH.T[11]
WAIT SEC 0.012
ENDWHILE
; Deactivate gripper
$OUT[1] = FALSE
MsgNotify("Gripper OFF - $OUT[1] = FALSE", "RSIPI_Test")
WAIT SEC 0.5
; Signal gripper off
$TECH.C[11] = 42 ; Sub-state: gripper deactivated
; Wait for Python acknowledgement
python_cmd = 0
WHILE (python_cmd <> 42)
python_cmd = $TECH.T[11]
WAIT SEC 0.012
ENDWHILE
; ========================================================================
; PHASE 6: Shutdown
; ========================================================================
; Signal state 5: complete
$TECH.C[11] = 5
MsgNotify("Test complete - shutting down RSI", "RSIPI_Test")
; Wait for Python to acknowledge shutdown (command = 5)
python_cmd = 0
WHILE (python_cmd <> 5)
python_cmd = $TECH.T[11]
WAIT SEC 0.012
ENDWHILE
; Clean up RSI
ret = RSI_OFF()
IF (ret <> RSIOK) THEN
MsgNotify("RSI_OFF warning", "RSIPI_Test")
ENDIF
ret = RSI_DELETE(CONTID)
; Return to start
PTP start_pos
MsgNotify("RSIPI_Test complete!", "RSIPI_Test")
END

View File

@ -0,0 +1,159 @@
<ROOT>
<CONFIG>
<IP_NUMBER>10.10.10.10</IP_NUMBER>
<PORT>64000</PORT>
<SENTYPE>ImFree</SENTYPE>
<ONLYSEND>FALSE</ONLYSEND>
</CONFIG>
<!-- =================================================================
RSI Channel Budget: 64 max across SEND + RECEIVE
INTERNAL tags don't count toward the 64-channel limit
SEND channels used: 38 (DiL, Digout x3, Source1, PosAct x6,
AxisAct x6, ExtAct x6, MotCur x6,
OvPro, Status)
RECEIVE channels used: 13 (RKorr x6, AKorr x6, DiO)
Total: 51 / 64
All DEF_ tags are INTERNAL (free)
================================================================= -->
<!-- ===================== SEND: Robot to PC ========================= -->
<SEND>
<ELEMENTS>
<!-- INTERNAL: Cartesian actual position (expanded to X,Y,Z,A,B,C) -->
<ELEMENT TAG="DEF_RIst" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: Cartesian setpoint position -->
<ELEMENT TAG="DEF_RSol" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: Robot axis actual positions (A1-A6 in deg) -->
<ELEMENT TAG="DEF_AIPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: Robot axis setpoint positions (A1-A6 in deg) -->
<ELEMENT TAG="DEF_ASPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: External axis actual positions (E1-E6) -->
<ELEMENT TAG="DEF_EIPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: External axis setpoint positions (E1-E6) -->
<ELEMENT TAG="DEF_ESPos" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: Robot motor currents (A1-A6, % of max) -->
<ELEMENT TAG="DEF_MACur" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: External motor currents (E1-E6, % of max) -->
<ELEMENT TAG="DEF_MECur" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- INTERNAL: Late packet counter -->
<ELEMENT TAG="DEF_Delay" TYPE="LONG" INDX="INTERNAL" />
<!-- INTERNAL: Tech channels -->
<ELEMENT TAG="DEF_Tech.C1" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C2" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C3" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C4" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C5" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.C6" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T1" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T2" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T3" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T4" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T5" TYPE="DOUBLE" INDX="INTERNAL" />
<ELEMENT TAG="DEF_Tech.T6" TYPE="DOUBLE" INDX="INTERNAL" />
<!-- Channel 1: Digital input latch (from DIGIN1) -->
<ELEMENT TAG="DiL" TYPE="LONG" INDX="1" />
<!-- Channels 2-4: Digital output readback (from DIGOUT1-3) -->
<ELEMENT TAG="Digout.o1" TYPE="BOOL" INDX="2" />
<ELEMENT TAG="Digout.o2" TYPE="BOOL" INDX="3" />
<ELEMENT TAG="Digout.o3" TYPE="BOOL" INDX="4" />
<!-- Channel 5: Signal source (from SOURCE1) -->
<ELEMENT TAG="Source1" TYPE="DOUBLE" INDX="5" />
<!-- Channels 13-18: Cartesian actual position (from POSACT1) -->
<ELEMENT TAG="PosAct.X" TYPE="DOUBLE" INDX="13" />
<ELEMENT TAG="PosAct.Y" TYPE="DOUBLE" INDX="14" />
<ELEMENT TAG="PosAct.Z" TYPE="DOUBLE" INDX="15" />
<ELEMENT TAG="PosAct.A" TYPE="DOUBLE" INDX="16" />
<ELEMENT TAG="PosAct.B" TYPE="DOUBLE" INDX="17" />
<ELEMENT TAG="PosAct.C" TYPE="DOUBLE" INDX="18" />
<!-- Channels 19-24: Joint axis actual positions (from AXISACT1) -->
<ELEMENT TAG="AxisAct.A1" TYPE="DOUBLE" INDX="19" />
<ELEMENT TAG="AxisAct.A2" TYPE="DOUBLE" INDX="20" />
<ELEMENT TAG="AxisAct.A3" TYPE="DOUBLE" INDX="21" />
<ELEMENT TAG="AxisAct.A4" TYPE="DOUBLE" INDX="22" />
<ELEMENT TAG="AxisAct.A5" TYPE="DOUBLE" INDX="23" />
<ELEMENT TAG="AxisAct.A6" TYPE="DOUBLE" INDX="24" />
<!-- Channels 25-30: External axis actual positions (from AXISACTEXT1) -->
<ELEMENT TAG="ExtAct.E1" TYPE="DOUBLE" INDX="25" />
<ELEMENT TAG="ExtAct.E2" TYPE="DOUBLE" INDX="26" />
<ELEMENT TAG="ExtAct.E3" TYPE="DOUBLE" INDX="27" />
<ELEMENT TAG="ExtAct.E4" TYPE="DOUBLE" INDX="28" />
<ELEMENT TAG="ExtAct.E5" TYPE="DOUBLE" INDX="29" />
<ELEMENT TAG="ExtAct.E6" TYPE="DOUBLE" INDX="30" />
<!-- Channels 31-36: Motor currents (from MOTORCURRENT1) -->
<ELEMENT TAG="MotCur.A1" TYPE="DOUBLE" INDX="31" />
<ELEMENT TAG="MotCur.A2" TYPE="DOUBLE" INDX="32" />
<ELEMENT TAG="MotCur.A3" TYPE="DOUBLE" INDX="33" />
<ELEMENT TAG="MotCur.A4" TYPE="DOUBLE" INDX="34" />
<ELEMENT TAG="MotCur.A5" TYPE="DOUBLE" INDX="35" />
<ELEMENT TAG="MotCur.A6" TYPE="DOUBLE" INDX="36" />
<!-- Channel 37: Program override percentage (from OV_PRO1) -->
<ELEMENT TAG="OvPro" TYPE="DOUBLE" INDX="37" />
<!-- Channel 38: Robot status (from STATUS1) -->
<ELEMENT TAG="Status" TYPE="LONG" INDX="38" />
</ELEMENTS>
</SEND>
<!-- =================== RECEIVE: PC to Robot ======================== -->
<RECEIVE>
<ELEMENTS>
<!-- INTERNAL: Status/error string to robot -->
<ELEMENT TAG="DEF_EStr" TYPE="STRING" INDX="INTERNAL" />
<!-- INTERNAL: Tech channels (advance parameters, PC to robot) -->
<ELEMENT TAG="DEF_Tech.T1" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T2" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T3" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T4" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T5" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.T6" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<!-- INTERNAL: Tech channels (main run parameters, PC to robot) -->
<ELEMENT TAG="DEF_Tech.C1" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C2" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C3" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C4" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C5" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<ELEMENT TAG="DEF_Tech.C6" TYPE="DOUBLE" INDX="INTERNAL" HOLDON="0" />
<!-- Channels 1-6: Cartesian corrections (to POSCORR1, HOLDON keeps last value) -->
<ELEMENT TAG="RKorr.X" TYPE="DOUBLE" INDX="1" HOLDON="1" />
<ELEMENT TAG="RKorr.Y" TYPE="DOUBLE" INDX="2" HOLDON="1" />
<ELEMENT TAG="RKorr.Z" TYPE="DOUBLE" INDX="3" HOLDON="1" />
<ELEMENT TAG="RKorr.A" TYPE="DOUBLE" INDX="4" HOLDON="1" />
<ELEMENT TAG="RKorr.B" TYPE="DOUBLE" INDX="5" HOLDON="1" />
<ELEMENT TAG="RKorr.C" TYPE="DOUBLE" INDX="6" HOLDON="1" />
<!-- Channels 7-12: Joint corrections (to AXISCORR1) -->
<ELEMENT TAG="AKorr.A1" TYPE="DOUBLE" INDX="7" HOLDON="1" />
<ELEMENT TAG="AKorr.A2" TYPE="DOUBLE" INDX="8" HOLDON="1" />
<ELEMENT TAG="AKorr.A3" TYPE="DOUBLE" INDX="9" HOLDON="1" />
<ELEMENT TAG="AKorr.A4" TYPE="DOUBLE" INDX="10" HOLDON="1" />
<ELEMENT TAG="AKorr.A5" TYPE="DOUBLE" INDX="11" HOLDON="1" />
<ELEMENT TAG="AKorr.A6" TYPE="DOUBLE" INDX="12" HOLDON="1" />
<!-- Channel 13: Digital output word (to MAP2DIGOUT1) -->
<ELEMENT TAG="DiO" TYPE="LONG" INDX="13" HOLDON="1" />
</ELEMENTS>
</RECEIVE>
</ROOT>

196
rsi_config/rsipi_test.py Normal file
View File

@ -0,0 +1,196 @@
"""
RSIPI Comprehensive Test Script
================================
Matching Python counterpart for RSIPI_Test.src KRL program.
Tests all RSIPI functionality in coordination with the robot.
Protocol (Tech.C11 = state from KRL, Tech.T11 = command from Python):
KRL States: 0=Idle, 1=Waiting, 2=Corrections, 3=SEN_PREA, 4=I/O, 5=Done
Python Cmds: 1=Ready, 2=Stop, 3=SEN_PREA written, 4=Start I/O, 5=Shutdown
Usage:
1. Load RSIPI_Test.src on the robot controller
2. Run this script: python rsipi_test.py
3. Start the KRL program on the pendant
"""
import time
import sys
import os
from multiprocessing import freeze_support
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from RSIPI import RSIAPI
# ── Helpers ──────────────────────────────────────────────────────────────
def wait_for_state(api, state, timeout=30):
"""Wait for KRL to reach a specific state via Tech.C11."""
start = time.time()
while time.time() - start < timeout:
try:
krl_state = int(api.krl.read_param('C11'))
if krl_state == state:
return True
except Exception:
pass
time.sleep(0.05)
print(f" TIMEOUT waiting for KRL state {state}")
return False
def send_command(api, cmd):
"""Send a command to KRL via Tech.T11."""
api.krl.write_param('T11', cmd)
print(f" -> Sent command: {cmd}")
# ── Main Test Sequence ───────────────────────────────────────────────────
if __name__ == '__main__':
freeze_support()
api = RSIAPI(
os.path.join(os.path.dirname(__file__), '..', 'RSI_EthernetConfig_Full.xml'),
rsi_mode='relative',
max_cartesian_rate=0.5,
max_joint_rate=0.2,
cycle_time=0.004
)
print("=" * 60)
print("RSIPI Comprehensive Test")
print("=" * 60)
# ── Start RSI and wait for robot ────────────────────────────────
print("\n[1] Starting RSI connection...")
api.start()
if not api.wait_for_connection(timeout=30):
print(" FAILED: No connection. Is the KRL program running?")
api.stop()
sys.exit(1)
print(f" Connected! IPOC: {api.monitoring.get_ipoc()}")
# ── Wait for KRL to reach state 1 (RSI active, waiting) ────────
print("\n[2] Waiting for KRL program to initialise RSI...")
if not wait_for_state(api, 1):
print(" FAILED: KRL did not reach state 1")
api.stop()
sys.exit(1)
print(" KRL is ready and waiting for us")
# Read the position KRL sent us
try:
pos_x = api.krl.read_param('C12')
pos_y = api.krl.read_param('C13')
pos_z = api.krl.read_param('C14')
print(f" Robot position from KRL: X={pos_x:.1f} Y={pos_y:.1f} Z={pos_z:.1f}")
except Exception as e:
print(f" Could not read position: {e}")
# ── Signal ready and start corrections ──────────────────────────
print("\n[3] Signalling ready, starting correction phase...")
send_command(api, 1)
if not wait_for_state(api, 2):
print(" FAILED: KRL did not enter correction mode")
api.stop()
sys.exit(1)
print(" KRL is in RSI_MOVECORR - sending corrections...")
# Send a small circle pattern as corrections
circle = api.motion.generate_circle(
center={"X": 0, "Y": 0, "Z": 0},
radius=3, steps=100)
# Convert to relative deltas
circle_rel = []
prev = circle[0]
for pt in circle[1:]:
delta = {k: pt[k] - prev.get(k, 0) for k in pt}
circle_rel.append(delta)
prev = pt
print(f" Executing circle: {len(circle_rel)} steps, radius=3mm")
api.motion.execute_trajectory(circle_rel, space="cartesian", rate=0.012)
print(" Circle complete!")
time.sleep(1)
# Tell KRL to stop corrections
print(" Stopping corrections...")
send_command(api, 2)
time.sleep(1)
# ── SEN_PREA test ───────────────────────────────────────────────
print("\n[4] Testing SEN_PREA variable exchange...")
if not wait_for_state(api, 3):
print(" FAILED: KRL did not reach SEN_PREA phase")
api.stop()
sys.exit(1)
# Write test values to SEN_PREA via the corrections
# (MAP2SEN_PREA in the RSI config maps ETHERNET Out1-3 to SEN_PREA[1-3])
# These go through RKorr.X/Y/Z → ETHERNET Out1-3 → MAP2SEN_PREA
test_vals = [42.0, 123.456, -99.9]
print(f" Writing SEN_PREA test values: {test_vals}")
api.motion.update_cartesian(X=test_vals[0], Y=test_vals[1], Z=test_vals[2])
time.sleep(0.5)
# Signal KRL to read them
send_command(api, 3)
time.sleep(1)
# Read back what KRL echoed via Tech.C18-C110
try:
echo1 = api.krl.read_param('C18')
echo2 = api.krl.read_param('C19')
echo3 = api.krl.read_param('C110')
print(f" KRL echoed back: [{echo1}, {echo2}, {echo3}]")
print(f" Match: {abs(echo1 - test_vals[0]) < 0.1 and abs(echo2 - test_vals[1]) < 0.1}")
except Exception as e:
print(f" Could not read echo values: {e}")
# Zero corrections
api.motion.update_cartesian(X=0, Y=0, Z=0)
# ── Digital I/O test ────────────────────────────────────────────
print("\n[5] Testing Digital I/O coordination...")
if not wait_for_state(api, 4, timeout=10):
print(" Skipping I/O test (KRL not in I/O phase)")
else:
send_command(api, 4)
# Wait for KRL to activate gripper (state 41)
if wait_for_state(api, 41, timeout=10):
# Read digital output state from robot
live = api.monitoring.get_live_data()
print(f" KRL activated gripper ($OUT[1])")
print(f" Live position: {live['position']}")
# Acknowledge
send_command(api, 41)
# Wait for KRL to deactivate (state 42)
if wait_for_state(api, 42, timeout=10):
print(f" KRL deactivated gripper ($OUT[1])")
send_command(api, 42)
else:
print(" TIMEOUT waiting for gripper off")
else:
print(" TIMEOUT waiting for gripper on")
# ── Shutdown ────────────────────────────────────────────────────
print("\n[6] Shutting down...")
if wait_for_state(api, 5, timeout=10):
send_command(api, 5)
print(" KRL acknowledged shutdown")
else:
print(" KRL did not reach shutdown state, stopping anyway")
time.sleep(1)
api.stop()
print("\n" + "=" * 60)
print("RSIPI Test Complete!")
print("=" * 60)

View File

@ -251,6 +251,8 @@ Requires-Dist: numpy>=1.22
Requires-Dist: matplotlib>=3.5 Requires-Dist: matplotlib>=3.5
Requires-Dist: lxml>=4.9 Requires-Dist: lxml>=4.9
Requires-Dist: scipy>=1.8 Requires-Dist: scipy>=1.8
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: author Dynamic: author
Dynamic: license-file Dynamic: license-file
Dynamic: requires-python Dynamic: requires-python

View File

@ -4,12 +4,20 @@ README.md
pyproject.toml pyproject.toml
setup.py setup.py
src/RSIPI/__init__.py src/RSIPI/__init__.py
src/RSIPI/auto_reconnect.py
src/RSIPI/config_parser.py src/RSIPI/config_parser.py
src/RSIPI/diagnostics_api.py
src/RSIPI/echo_server_gui.py src/RSIPI/echo_server_gui.py
src/RSIPI/exceptions.py
src/RSIPI/inject_rsi_to_krl.py src/RSIPI/inject_rsi_to_krl.py
src/RSIPI/io_api.py
src/RSIPI/krl_api.py
src/RSIPI/krl_to_csv_parser.py src/RSIPI/krl_to_csv_parser.py
src/RSIPI/kuka_visualiser.py src/RSIPI/kuka_visualiser.py
src/RSIPI/live_plotter.py src/RSIPI/live_plotter.py
src/RSIPI/logging_api.py
src/RSIPI/monitoring_api.py
src/RSIPI/motion_api.py
src/RSIPI/network_handler.py src/RSIPI/network_handler.py
src/RSIPI/rsi_api.py src/RSIPI/rsi_api.py
src/RSIPI/rsi_cli.py src/RSIPI/rsi_cli.py
@ -18,13 +26,19 @@ src/RSIPI/rsi_config.py
src/RSIPI/rsi_echo_server.py src/RSIPI/rsi_echo_server.py
src/RSIPI/rsi_graphing.py src/RSIPI/rsi_graphing.py
src/RSIPI/rsi_limit_parser.py src/RSIPI/rsi_limit_parser.py
src/RSIPI/safety_api.py
src/RSIPI/safety_manager.py src/RSIPI/safety_manager.py
src/RSIPI/static_plotter.py src/RSIPI/static_plotter.py
src/RSIPI/timing_metrics.py
src/RSIPI/tools_api.py
src/RSIPI/trajectory_planner.py src/RSIPI/trajectory_planner.py
src/RSIPI/viz_api.py
src/RSIPI/xml_handler.py src/RSIPI/xml_handler.py
src/RSIPI.egg-info/PKG-INFO src/RSIPI.egg-info/PKG-INFO
src/RSIPI.egg-info/SOURCES.txt src/RSIPI.egg-info/SOURCES.txt
src/RSIPI.egg-info/dependency_links.txt src/RSIPI.egg-info/dependency_links.txt
src/RSIPI.egg-info/requires.txt src/RSIPI.egg-info/requires.txt
src/RSIPI.egg-info/top_level.txt src/RSIPI.egg-info/top_level.txt
tests/test_rsipi.py tests/test_safety_manager.py
tests/test_trajectory_planner.py
tests/test_xml_handler.py

View File

@ -3,3 +3,6 @@ numpy>=1.22
matplotlib>=3.5 matplotlib>=3.5
lxml>=4.9 lxml>=4.9
scipy>=1.8 scipy>=1.8
[dev]
pytest>=7.0

View File

@ -24,6 +24,8 @@ class IOAPI:
client: RSIClient instance for variable access client: RSIClient instance for variable access
""" """
self.client = client self.client = client
from .tools_api import ToolsAPI
self._tools = ToolsAPI(client)
def toggle(self, group: str, name: str, state: Union[bool, int]) -> str: def toggle(self, group: str, name: str, state: Union[bool, int]) -> str:
""" """
@ -54,21 +56,14 @@ class IOAPI:
var_name = f"{group}.{name}" var_name = f"{group}.{name}"
state_value = int(bool(state)) # Ensure binary 0 or 1 state_value = int(bool(state)) # Ensure binary 0 or 1
# Import here to avoid circular dependency result = self._tools.update_variable(var_name, state_value)
from .tools_api import ToolsAPI logging.debug("I/O %s set to %d", var_name, state_value)
tools = ToolsAPI(self.client)
result = tools.update_variable(var_name, state_value)
logging.debug(f"I/O {var_name} set to {state_value}")
return result return result
def set_output(self, channel: int, value: bool, group: str = 'Digout') -> str: def set_output(self, channel: int, value: bool, group: str = 'Digout') -> str:
""" """
Set digital output by channel number. Set digital output by channel number.
High-level wrapper for setting digital outputs. More convenient than
toggle() when working with standard digital output channels.
Args: Args:
channel: Output channel number (1-based, e.g., 1 for o1) channel: Output channel number (1-based, e.g., 1 for o1)
value: Desired state (True = ON, False = OFF) value: Desired state (True = ON, False = OFF)
@ -83,16 +78,11 @@ class IOAPI:
Example: Example:
>>> api.io.set_output(1, True) # Turn ON output 1 >>> api.io.set_output(1, True) # Turn ON output 1
'Updated Digout.o1 to 1'
>>> api.io.set_output(3, False) # Turn OFF output 3 >>> api.io.set_output(3, False) # Turn OFF output 3
'Updated Digout.o3 to 0'
>>> api.io.set_output(5, True, group='DiO') # Custom group
'Updated DiO.5 to 1'
Note: Note:
The default group 'Digout' corresponds to standard KUKA digital Digout must be configured in the RSI config RECEIVE section
outputs configured in RSI. Channel numbering starts at 1 to match for this to work. Check your RSI_EthernetConfig.xml.
KUKA controller conventions.
""" """
channel_name = f"o{channel}" channel_name = f"o{channel}"
return self.toggle(group, channel_name, value) return self.toggle(group, channel_name, value)
@ -192,13 +182,13 @@ class IOAPI:
# Turn ON # Turn ON
self.set_output(channel, True, group=group) self.set_output(channel, True, group=group)
logging.debug(f"Pulse started on {var_name}") logging.debug("Pulse started on %s", var_name)
# Wait for duration # Wait for duration
time.sleep(duration) time.sleep(duration)
# Turn OFF # Turn OFF
self.set_output(channel, False, group=group) self.set_output(channel, False, group=group)
logging.info(f"Pulse completed on {var_name} (duration: {duration}s)") logging.info("Pulse completed on %s (duration: %ss)", var_name, duration)
return f"Pulse completed on {var_name} (duration: {duration}s)" return f"Pulse completed on {var_name} (duration: {duration}s)"

View File

@ -178,7 +178,7 @@ class MonitoringAPI:
try: try:
while True: while True:
live_data = self.get_live_data() live_data = self.get_live_data()
ipoc = live_data.get("IPOC", "N/A") ipoc = live_data.get("ipoc", "N/A")
rpos = live_data.get("position", {}) rpos = live_data.get("position", {})
timestamp = datetime.datetime.now().strftime('%H:%M:%S') timestamp = datetime.datetime.now().strftime('%H:%M:%S')
print(f"[{timestamp}] IPOC: {ipoc} | RIst: {rpos}") print(f"[{timestamp}] IPOC: {ipoc} | RIst: {rpos}")

View File

@ -1,7 +1,7 @@
"""Motion control API namespace for RSIPI.""" """Motion control API namespace for RSIPI."""
import logging import logging
import asyncio import threading
import math import math
import numpy as np import numpy as np
from typing import Dict, List, Any, Optional, Tuple, TYPE_CHECKING from typing import Dict, List, Any, Optional, Tuple, TYPE_CHECKING
@ -29,6 +29,9 @@ class MotionAPI:
self.client = client self.client = client
self.trajectory_queue: List[Dict[str, Any]] = [] self.trajectory_queue: List[Dict[str, Any]] = []
from .tools_api import ToolsAPI
self._tools = ToolsAPI(client)
def update_cartesian(self, **kwargs: float) -> None: def update_cartesian(self, **kwargs: float) -> None:
""" """
Update Cartesian correction values (RKorr). Update Cartesian correction values (RKorr).
@ -62,13 +65,9 @@ class MotionAPI:
logging.warning("RKorr not configured in receive_variables. Skipping Cartesian update.") logging.warning("RKorr not configured in receive_variables. Skipping Cartesian update.")
return return
# Import here to avoid circular dependency
from .tools_api import ToolsAPI
tools = ToolsAPI(self.client)
for axis, value in kwargs.items(): for axis, value in kwargs.items():
tools.update_variable(f"RKorr.{axis}", float(value)) self._tools.update_variable(f"RKorr.{axis}", float(value))
logging.debug(f"RKorr.{axis} set to {value}") logging.debug("RKorr.%s set to %s", axis, value)
def update_joints(self, **kwargs: float) -> None: def update_joints(self, **kwargs: float) -> None:
""" """
@ -99,12 +98,29 @@ class MotionAPI:
logging.warning("AKorr not configured in receive_variables. Skipping Joint update.") logging.warning("AKorr not configured in receive_variables. Skipping Joint update.")
return return
from .tools_api import ToolsAPI
tools = ToolsAPI(self.client)
for axis, value in kwargs.items(): for axis, value in kwargs.items():
tools.update_variable(f"AKorr.{axis}", float(value)) self._tools.update_variable(f"AKorr.{axis}", float(value))
logging.debug(f"AKorr.{axis} set to {value}") logging.debug("AKorr.%s set to %s", axis, value)
def get_current_pose(self) -> Dict[str, float]:
"""
Get current TCP position from robot.
Returns:
Dict with X, Y, Z (mm) and A, B, C (degrees)
"""
rist = self.client.send_variables.get("RIst", {})
return dict(rist) if isinstance(rist, dict) else {"X": 0, "Y": 0, "Z": 0, "A": 0, "B": 0, "C": 0}
def get_current_joints(self) -> Dict[str, float]:
"""
Get current joint positions from robot.
Returns:
Dict with A1-A6 in degrees
"""
aspos = self.client.send_variables.get("ASPos", {})
return dict(aspos) if isinstance(aspos, dict) else {"A1": 0, "A2": 0, "A3": 0, "A4": 0, "A5": 0, "A6": 0}
def correct_position(self, correction_type: str, axis: str, value: float) -> str: def correct_position(self, correction_type: str, axis: str, value: float) -> str:
""" """
@ -130,9 +146,7 @@ class MotionAPI:
>>> api.motion.correct_position('AKorr', 'A1', 5.0) >>> api.motion.correct_position('AKorr', 'A1', 5.0)
'Updated AKorr.A1 to 5.0' 'Updated AKorr.A1 to 5.0'
""" """
from .tools_api import ToolsAPI return self._tools.update_variable(f"{correction_type}.{axis}", value)
tools = ToolsAPI(self.client)
return tools.update_variable(f"{correction_type}.{axis}", value)
def move_external_axis(self, axis: str, value: float) -> str: def move_external_axis(self, axis: str, value: float) -> str:
""" """
@ -156,9 +170,7 @@ class MotionAPI:
>>> api.motion.move_external_axis('E1', 500.0) >>> api.motion.move_external_axis('E1', 500.0)
'Updated ELPos.E1 to 500.0' 'Updated ELPos.E1 to 500.0'
""" """
from .tools_api import ToolsAPI return self._tools.update_variable(f"ELPos.{axis}", value)
tools = ToolsAPI(self.client)
return tools.update_variable(f"ELPos.{axis}", value)
def adjust_speed(self, tech_param: str, value: float) -> str: def adjust_speed(self, tech_param: str, value: float) -> str:
""" """
@ -183,9 +195,7 @@ class MotionAPI:
Tech variable meanings depend on your KRL program implementation. Tech variable meanings depend on your KRL program implementation.
Coordinate with your KRL developer on parameter assignments. Coordinate with your KRL developer on parameter assignments.
""" """
from .tools_api import ToolsAPI return self._tools.update_variable(tech_param, value)
tools = ToolsAPI(self.client)
return tools.update_variable(tech_param, value)
@staticmethod @staticmethod
def generate_trajectory( def generate_trajectory(
@ -245,10 +255,10 @@ class MotionAPI:
rate: float = 0.012 rate: float = 0.012
) -> None: ) -> None:
""" """
Execute a trajectory asynchronously. Execute a trajectory, blocking until complete.
Sends waypoints sequentially to the robot at the specified rate. Sends waypoints sequentially to the robot at the specified rate.
Uses asyncio for non-blocking execution. Can be cancelled via cancel_trajectory().
Args: Args:
trajectory: List of waypoint dictionaries trajectory: List of waypoint dictionaries
@ -257,96 +267,98 @@ class MotionAPI:
Raises: Raises:
RSITrajectoryError: If space is invalid RSITrajectoryError: If space is invalid
Example:
>>> # Generate and execute Cartesian trajectory
>>> traj = api.motion.generate_trajectory(
... {"X":0, "Y":0, "Z":500},
... {"X":100, "Y":0, "Z":500},
... steps=50
... )
>>> api.motion.execute_trajectory(traj, space="cartesian", rate=0.02)
Note:
This method uses asyncio. If no event loop is running, one will
be created automatically. The trajectory executes in the background.
""" """
import time
from .exceptions import RSITrajectoryError from .exceptions import RSITrajectoryError
async def runner(): self._trajectory_cancel = threading.Event()
def runner():
for idx, point in enumerate(trajectory): for idx, point in enumerate(trajectory):
if self._trajectory_cancel.is_set():
logging.info("Trajectory cancelled at step %d/%d", idx, len(trajectory))
return
if space == "cartesian": if space == "cartesian":
self.update_cartesian(**point) self.update_cartesian(**point)
elif space == "joint": elif space == "joint":
self.update_joints(**point) self.update_joints(**point)
else: else:
raise RSITrajectoryError("space must be 'cartesian' or 'joint'") raise RSITrajectoryError("space must be 'cartesian' or 'joint'")
logging.debug(f"Trajectory step {idx + 1}/{len(trajectory)}") logging.debug("Trajectory step %d/%d", idx + 1, len(trajectory))
await asyncio.sleep(rate) time.sleep(rate)
try: self._trajectory_thread = threading.Thread(target=runner, daemon=True)
loop = asyncio.get_running_loop() self._trajectory_thread.start()
asyncio.create_task(runner()) self._trajectory_thread.join() # Block until complete
except RuntimeError:
# No event loop running, create one # Zero corrections after trajectory to stop motion in relative mode
asyncio.run(runner()) if space == "cartesian":
self.update_cartesian(**{k: 0.0 for k in trajectory[0]})
elif space == "joint":
self.update_joints(**{k: 0.0 for k in trajectory[0]})
def cancel_trajectory(self) -> None:
"""Cancel a running trajectory execution."""
if hasattr(self, '_trajectory_cancel'):
self._trajectory_cancel.set()
def move_cartesian_trajectory( def move_cartesian_trajectory(
self, self,
start_pose: Dict[str, float],
end_pose: Dict[str, float], end_pose: Dict[str, float],
start_pose: Optional[Dict[str, float]] = None,
steps: int = 50, steps: int = 50,
rate: float = 0.012 rate: float = 0.012
) -> None: ) -> None:
""" """
Generate and execute Cartesian trajectory in one call. Generate and execute Cartesian trajectory in one call.
Convenience method that combines generate_trajectory() and
execute_trajectory() for Cartesian motion.
Args: Args:
start_pose: Starting Cartesian pose end_pose: Target Cartesian pose
end_pose: Ending Cartesian pose start_pose: Starting pose (default: current robot position)
steps: Number of waypoints (default: 50) steps: Number of waypoints (default: 50)
rate: Time between waypoints in seconds (default: 0.012) rate: Time between waypoints in seconds (default: 0.012)
Example: Example:
>>> # Move to target from current position
>>> api.motion.move_cartesian_trajectory({"X":100, "Y":0, "Z":500})
>>> # Explicit start
>>> api.motion.move_cartesian_trajectory( >>> api.motion.move_cartesian_trajectory(
... {"X":0, "Y":0, "Z":500},
... {"X":100, "Y":0, "Z":500}, ... {"X":100, "Y":0, "Z":500},
... steps=50, ... start_pose={"X":0, "Y":0, "Z":500}
... rate=0.02
... ) ... )
""" """
if start_pose is None:
start_pose = self.get_current_pose()
trajectory = self.generate_trajectory(start_pose, end_pose, steps=steps, space="cartesian") trajectory = self.generate_trajectory(start_pose, end_pose, steps=steps, space="cartesian")
self.execute_trajectory(trajectory, space="cartesian", rate=rate) self.execute_trajectory(trajectory, space="cartesian", rate=rate)
def move_joint_trajectory( def move_joint_trajectory(
self, self,
start_joints: Dict[str, float],
end_joints: Dict[str, float], end_joints: Dict[str, float],
start_joints: Optional[Dict[str, float]] = None,
steps: int = 50, steps: int = 50,
rate: float = 0.4 rate: float = 0.4
) -> None: ) -> None:
""" """
Generate and execute joint-space trajectory in one call. Generate and execute joint-space trajectory in one call.
Convenience method for joint-space motion with sensible defaults
(slower rate typical for joint motion).
Args: Args:
start_joints: Starting joint configuration end_joints: Target joint configuration
end_joints: Ending joint configuration start_joints: Starting joints (default: current robot joints)
steps: Number of waypoints (default: 50) steps: Number of waypoints (default: 50)
rate: Time between waypoints in seconds (default: 0.4 for smooth joint motion) rate: Time between waypoints in seconds (default: 0.4)
Example: Example:
>>> # Move to target from current joints
>>> api.motion.move_joint_trajectory({"A1":30, "A2":-15, "A3":45})
>>> # Explicit start
>>> api.motion.move_joint_trajectory( >>> api.motion.move_joint_trajectory(
... {"A1":0, "A2":0, "A3":0, "A4":0, "A5":0, "A6":0}, ... {"A1":30, "A2":-15, "A3":45},
... {"A1":30, "A2":-15, "A3":45, "A4":0, "A5":30, "A6":0}, ... start_joints={"A1":0, "A2":0, "A3":0}
... steps=100
... ) ... )
""" """
if start_joints is None:
start_joints = self.get_current_joints()
trajectory = self.generate_trajectory(start_joints, end_joints, steps=steps, space="joint") trajectory = self.generate_trajectory(start_joints, end_joints, steps=steps, space="joint")
self.execute_trajectory(trajectory, space="joint", rate=rate) self.execute_trajectory(trajectory, space="joint", rate=rate)
@ -380,7 +392,7 @@ class MotionAPI:
"space": space, "space": space,
"rate": rate, "rate": rate,
}) })
logging.debug(f"Queued trajectory: {len(trajectory)} points, {space} space") logging.debug("Queued trajectory: %d points, %s space", len(trajectory), space)
def queue_cartesian_trajectory( def queue_cartesian_trajectory(
self, self,
@ -465,9 +477,9 @@ class MotionAPI:
>>> api.motion.execute_queued_trajectories() >>> api.motion.execute_queued_trajectories()
>>> # Both trajectories executed sequentially >>> # Both trajectories executed sequentially
""" """
logging.info(f"Executing {len(self.trajectory_queue)} queued trajectories") logging.info("Executing %d queued trajectories", len(self.trajectory_queue))
for idx, item in enumerate(self.trajectory_queue): for idx, item in enumerate(self.trajectory_queue):
logging.debug(f"Executing queued trajectory {idx + 1}/{len(self.trajectory_queue)}") logging.debug("Executing queued trajectory %d/%d", idx + 1, len(self.trajectory_queue))
self.execute_trajectory(item["trajectory"], item["space"], item["rate"]) self.execute_trajectory(item["trajectory"], item["space"], item["rate"])
self.clear_queue() self.clear_queue()
@ -481,7 +493,7 @@ class MotionAPI:
""" """
count = len(self.trajectory_queue) count = len(self.trajectory_queue)
self.trajectory_queue.clear() self.trajectory_queue.clear()
logging.debug(f"Cleared {count} queued trajectories") logging.debug("Cleared %d queued trajectories", count)
def get_queue(self) -> List[Dict[str, Any]]: def get_queue(self) -> List[Dict[str, Any]]:
""" """
@ -945,11 +957,11 @@ def _trapezoidal_profile(
for i in range(n): for i in range(n):
if distance_traveled < total_distance / 2: if distance_traveled < total_distance / 2:
# Acceleration phase # Acceleration phase
velocities[i] = min(peak_velocity, math.sqrt(2 * max_acceleration * distance_traveled)) velocities[i] = min(peak_velocity, math.sqrt(max(0, 2 * max_acceleration * distance_traveled)))
else: else:
# Deceleration phase # Deceleration phase
remaining = total_distance - distance_traveled remaining = total_distance - distance_traveled
velocities[i] = min(peak_velocity, math.sqrt(2 * max_acceleration * remaining)) velocities[i] = min(peak_velocity, math.sqrt(max(0, 2 * max_acceleration * remaining)))
if i < len(distances): if i < len(distances):
distance_traveled += distances[i] distance_traveled += distances[i]
@ -960,14 +972,14 @@ def _trapezoidal_profile(
for i in range(n): for i in range(n):
if distance_traveled < accel_distance: if distance_traveled < accel_distance:
# Acceleration phase # Acceleration phase
velocities[i] = math.sqrt(2 * max_acceleration * distance_traveled) velocities[i] = math.sqrt(max(0, 2 * max_acceleration * distance_traveled))
elif distance_traveled < (total_distance - accel_distance): elif distance_traveled < (total_distance - accel_distance):
# Constant velocity phase # Constant velocity phase
velocities[i] = max_velocity velocities[i] = max_velocity
else: else:
# Deceleration phase # Deceleration phase
remaining = total_distance - distance_traveled remaining = total_distance - distance_traveled
velocities[i] = math.sqrt(2 * max_acceleration * remaining) velocities[i] = math.sqrt(max(0, 2 * max_acceleration * remaining))
if i < len(distances): if i < len(distances):
distance_traveled += distances[i] distance_traveled += distances[i]
@ -1001,10 +1013,10 @@ def _s_curve_profile(
# Smooth acceleration at start, smooth deceleration at end # Smooth acceleration at start, smooth deceleration at end
if s < 0.5: if s < 0.5:
# First half: smooth acceleration # First half: smooth acceleration
v_normalized = 0.5 * (1 - math.cos(math.pi * s)) v_normalized = 0.5 * (1 - math.cos(2 * math.pi * s))
else: else:
# Second half: smooth deceleration # Second half: smooth deceleration
v_normalized = 0.5 * (1 + math.cos(math.pi * (s - 0.5))) v_normalized = 0.5 * (1 - math.cos(2 * math.pi * (1 - s)))
velocities[i] = v_normalized * max_velocity velocities[i] = v_normalized * max_velocity
@ -1043,7 +1055,7 @@ def _cubic_blend(
keys = set(p1.keys()) | set(p2.keys()) keys = set(p1.keys()) | set(p2.keys())
for i in range(steps): for i in range(steps):
t = i / (steps - 1) t = i / max(steps - 1, 1)
# Cubic Hermite spline with zero velocity at endpoints # Cubic Hermite spline with zero velocity at endpoints
h1 = 2 * t**3 - 3 * t**2 + 1 h1 = 2 * t**3 - 3 * t**2 + 1
h2 = -2 * t**3 + 3 * t**2 h2 = -2 * t**3 + 3 * t**2

View File

@ -1,48 +1,33 @@
import multiprocessing import multiprocessing
import socket import socket
import logging import logging
import threading
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import os import os
import datetime import datetime
from queue import Empty from queue import Empty, Queue as ThreadQueue
from typing import Dict, Any, Tuple, Optional from typing import Dict, Any, Tuple, Optional
from .xml_handler import XMLGenerator from .xml_handler import XMLGenerator, FastXMLGenerator
from .safety_manager import SafetyManager from .safety_manager import SafetyManager
from .exceptions import RSINetworkError, RSITimeoutError, RSIPacketError, RSILoggingError from .exceptions import RSINetworkError, RSITimeoutError, RSIPacketError, RSILoggingError
from .timing_metrics import TimingMetrics from .timing_metrics import TimingMetrics
class CSVLogger(multiprocessing.Process): class CSVLogger(threading.Thread):
""" """
Separate process for writing CSV logs without blocking the network loop. Background thread for writing CSV logs without blocking the network loop.
Runs in background and consumes log entries from a queue, writing them Uses a thread instead of a subprocess to avoid Windows restrictions on
to CSV file with British date format timestamps. daemon processes spawning child processes.
""" """
def __init__(self, log_queue: multiprocessing.Queue, stop_event: multiprocessing.Event, filename: str) -> None: def __init__(self, log_queue: ThreadQueue, stop_event: threading.Event, filename: str) -> None:
""" super().__init__(daemon=True)
Initialize CSV logger process. self.log_queue = log_queue
self.stop_event = stop_event
Args: self.filename = filename
log_queue: Queue containing log entry dictionaries
stop_event: Event to signal shutdown
filename: Path to output CSV file
"""
super().__init__()
self.log_queue: multiprocessing.Queue = log_queue
self.stop_event: multiprocessing.Event = stop_event
self.filename: str = filename
self.daemon = True
def run(self) -> None: def run(self) -> None:
"""
Write log entries from queue to CSV file.
Creates directory if needed, writes header on first entry,
timestamps each row with British date format (DD/MM/YYYY HH:MM:SS.mmm).
"""
# Ensure logs directory exists
log_dir = os.path.dirname(self.filename) log_dir = os.path.dirname(self.filename)
if log_dir and not os.path.exists(log_dir): if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
@ -54,16 +39,14 @@ class CSVLogger(multiprocessing.Process):
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
entry = self.log_queue.get(timeout=0.5) entry = self.log_queue.get(timeout=0.5)
if entry is None: # Poison pill if entry is None:
break break
# Write header on first entry
if not header_written: if not header_written:
headers = ['Timestamp'] + list(entry.keys()) headers = ['Timestamp'] + list(entry.keys())
f.write(','.join(headers) + '\n') f.write(','.join(headers) + '\n')
header_written = True header_written = True
# Write data row
timestamp = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S.%f")[:-3] timestamp = datetime.datetime.now().strftime("%d/%m/%Y %H:%M:%S.%f")[:-3]
values = [timestamp] + [str(v) for v in entry.values()] values = [timestamp] + [str(v) for v in entry.values()]
f.write(','.join(values) + '\n') f.write(','.join(values) + '\n')
@ -72,10 +55,10 @@ class CSVLogger(multiprocessing.Process):
except Empty: except Empty:
continue continue
except Exception as e: except Exception as e:
logging.error(f"CSV logging error: {e}") logging.error("CSV logging error: %s", e)
except Exception as e: except Exception as e:
logging.error(f"Failed to open log file {self.filename}: {e}") logging.error("Failed to open log file %s: %s", self.filename, e)
class NetworkProcess(multiprocessing.Process): class NetworkProcess(multiprocessing.Process):
@ -97,22 +80,13 @@ class NetworkProcess(multiprocessing.Process):
config_parser: Any, # ConfigParser type config_parser: Any, # ConfigParser type
start_event: multiprocessing.Event, start_event: multiprocessing.Event,
command_queue: multiprocessing.Queue, command_queue: multiprocessing.Queue,
metrics_dict: Optional[Any] = None # multiprocessing.Manager().dict() metrics_dict: Optional[Any] = None, # multiprocessing.Manager().dict()
connected_event: Optional[multiprocessing.Event] = None,
rsi_mode: str = 'relative',
max_cartesian_rate: float = 0.0,
max_joint_rate: float = 0.0,
cycle_time: float = 0.004
) -> None: ) -> None:
"""
Initialize network process.
Args:
ip: IP address to bind UDP socket to
port: UDP port number
send_variables: Shared dict for variables to send to robot
receive_variables: Shared dict for variables received from robot
stop_event: Event to signal shutdown
config_parser: ConfigParser instance with network settings
start_event: Event to signal when to start communication
command_queue: Queue for receiving commands from parent process
metrics_dict: Optional shared dict for timing metrics
"""
super().__init__() super().__init__()
self.send_variables = send_variables self.send_variables = send_variables
self.receive_variables = receive_variables self.receive_variables = receive_variables
@ -121,16 +95,24 @@ class NetworkProcess(multiprocessing.Process):
self.config_parser = config_parser self.config_parser = config_parser
self.command_queue: multiprocessing.Queue = command_queue self.command_queue: multiprocessing.Queue = command_queue
self.safety_manager: SafetyManager = SafetyManager(config_parser.safety_limits) self.safety_manager: SafetyManager = SafetyManager(config_parser.safety_limits)
self.connected_event = connected_event
# RSI correction mode and rate limiting
self.rsi_mode: str = rsi_mode # 'absolute' or 'relative'
self.max_cartesian_rate: float = max_cartesian_rate # mm/cycle, 0 = disabled
self.max_joint_rate: float = max_joint_rate # degrees/cycle, 0 = disabled
self.cycle_time: float = cycle_time # expected cycle time for metrics
self.client_address: Tuple[str, int] = (ip, port) self.client_address: Tuple[str, int] = (ip, port)
self.logging_active: Any = multiprocessing.Value('b', False) # c_bool wrapper self.logging_active: Any = multiprocessing.Value('b', False) # c_bool wrapper
self.estop_active: Any = multiprocessing.Value('b', False)
self.controller_ip_and_port: Optional[Tuple[str, int]] = None self.controller_ip_and_port: Optional[Tuple[str, int]] = None
self.udp_socket: Optional[socket.socket] = None self.udp_socket: Optional[socket.socket] = None
# Logging infrastructure (created when logging starts) # Logging infrastructure (created when logging starts)
self.log_queue: Optional[multiprocessing.Queue] = None self.log_queue: Optional[ThreadQueue] = None
self.log_stop_event: Optional[multiprocessing.Event] = None self.log_stop_event: Optional[threading.Event] = None
self.csv_logger: Optional[CSVLogger] = None self.csv_logger: Optional[CSVLogger] = None
# Timing metrics (Phase 2) # Timing metrics (Phase 2)
@ -146,8 +128,8 @@ class NetworkProcess(multiprocessing.Process):
""" """
# Initialize timing metrics in child process # Initialize timing metrics in child process
if self.metrics_dict is not None: if self.metrics_dict is not None:
self.timing_metrics = TimingMetrics() self.timing_metrics = TimingMetrics(expected_cycle_time=self.cycle_time)
logging.info("Timing metrics initialized") logging.info("Timing metrics initialized (expected cycle: %.1fms)", self.cycle_time * 1000)
# Wait for start signal, but check stop_event periodically to allow clean shutdown # Wait for start signal, but check stop_event periodically to allow clean shutdown
while not self.start_event.wait(timeout=0.5): while not self.start_event.wait(timeout=0.5):
@ -168,49 +150,74 @@ class NetworkProcess(multiprocessing.Process):
Falls back to 0.0.0.0 if specified IP is invalid. Falls back to 0.0.0.0 if specified IP is invalid.
""" """
if not self.is_valid_ip(self.client_address[0]): if not self.is_valid_ip(self.client_address[0]):
logging.warning(f"Invalid IP address '{self.client_address[0]}'. Falling back to '0.0.0.0'.") logging.warning("Invalid IP address '%s'. Falling back to '0.0.0.0'.", self.client_address[0])
self.client_address = ('0.0.0.0', self.client_address[1]) self.client_address = ('0.0.0.0', self.client_address[1])
self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.udp_socket.settimeout(5)
self.udp_socket.bind(self.client_address) self.udp_socket.bind(self.client_address)
logging.info(f"Network process bound on {self.client_address}") logging.info("Network process bound on %s", self.client_address)
def _run_loop(self) -> None: def _run_loop(self) -> None:
""" """
Main communication loop. Main communication loop.
Receives UDP messages from robot, processes them, sends responses,
and optionally logs data to CSV. Records timing metrics if enabled.
Uses local dict snapshots to avoid per-key IPC overhead on Uses local dict snapshots to avoid per-key IPC overhead on
multiprocessing.Manager dicts within the 4ms cycle. multiprocessing.Manager dicts within the 4ms cycle.
""" """
update_counter = 0 # For periodic metrics updates update_counter = 0
metrics_counter = 0 # For periodic receive_variables sync metrics_counter = 0
cmd_counter = 0
first_packet = True
# Variable naming follows KUKA convention (robot's perspective): # Variable naming follows KUKA convention (robot's perspective):
# send_variables = what the robot SENDS to us (RIst, RSol, IPOC, etc.) # send_variables = what the robot SENDS to us (RIst, RSol, IPOC, etc.)
# receive_variables = what the robot RECEIVES from us (RKorr, DiO, EStr, etc.) # receive_variables = what the robot RECEIVES from us (RKorr, DiO, EStr, etc.)
#
# So: parse incoming XML → send_variables, build response XML ← receive_variables
# Local working copies — avoid Manager IPC in the hot path # Local working copies — avoid Manager IPC in the hot path
local_robot_out = dict(self.send_variables) # robot's outgoing data (we read) local_robot_out = dict(self.send_variables)
local_robot_in = dict(self.receive_variables) # robot's incoming data (we write) local_robot_in = dict(self.receive_variables)
network_settings = self.config_parser.network_settings network_settings = self.config_parser.network_settings
# FastXMLGenerator for comparison testing
fast_gen = FastXMLGenerator(local_robot_in, root_tag="Sen", type_attr=network_settings["sentype"])
xml_mismatch_logged = False
# Cache zero-correction template for E-stop
zero_robot_in = dict(self.receive_variables)
for key, value in zero_robot_in.items():
if isinstance(value, dict):
zero_robot_in[key] = {k: 0.0 for k in value}
# Previous correction state for absolute mode ramping
prev_corrections: Dict[str, Dict[str, float]] = {}
for key in ('RKorr', 'AKorr'):
if key in local_robot_in and isinstance(local_robot_in[key], dict):
prev_corrections[key] = {k: 0.0 for k in local_robot_in[key]}
while not self.stop_event.is_set(): while not self.stop_event.is_set():
# Check for commands (non-blocking) # Check for commands periodically (every 50 cycles ~200ms)
self._process_commands() cmd_counter += 1
if cmd_counter >= 50:
self._process_commands()
cmd_counter = 0
try: try:
self.udp_socket.settimeout(5)
data_received, self.controller_ip_and_port = self.udp_socket.recvfrom(1024) data_received, self.controller_ip_and_port = self.udp_socket.recvfrom(1024)
message = data_received.decode() message = data_received.decode()
# Parse robot's outgoing data into local dict (no IPC) # Parse robot's outgoing data (ElementTree — handles any attribute order)
self._parse_received_data(message, local_robot_out) try:
self._parse_received_data(message, local_robot_out)
except RSIPacketError:
logging.warning("Parse failed, sending last known good response")
# Signal connection on first valid packet
if first_packet:
first_packet = False
if self.connected_event:
self.connected_event.set()
# Snapshot receive_variables to pick up user changes (single IPC call) # Snapshot receive_variables to pick up user changes (single IPC call)
local_robot_in = dict(self.receive_variables) local_robot_in = dict(self.receive_variables)
@ -219,10 +226,35 @@ class NetworkProcess(multiprocessing.Process):
if "IPOC" in local_robot_out: if "IPOC" in local_robot_out:
ipoc = local_robot_out["IPOC"] ipoc = local_robot_out["IPOC"]
local_robot_in["IPOC"] = ipoc + 4 local_robot_in["IPOC"] = ipoc + 4
self.receive_variables["IPOC"] = ipoc + 4
# Build and send response XML from our corrections (no IPC) # Rate-limit corrections
send_xml = XMLGenerator.generate_send_xml(local_robot_in, network_settings) self._apply_rate_limit(local_robot_in, prev_corrections)
# E-stop: zero all corrections, keep IPOC sync
if self.estop_active.value:
estop_response = dict(zero_robot_in)
estop_response["IPOC"] = local_robot_in.get("IPOC", 0)
send_xml = XMLGenerator.generate_send_xml(estop_response, network_settings)
else:
send_xml = XMLGenerator.generate_send_xml(local_robot_in, network_settings)
# Compare FastXMLGenerator output (debug — log first mismatch only)
if not xml_mismatch_logged:
try:
fast_xml = fast_gen.generate(local_robot_in)
if fast_xml != send_xml:
xml_mismatch_logged = True
logging.warning("XML MISMATCH DETECTED")
logging.warning("ET output: %s", send_xml[:200])
logging.warning("Fast output: %s", fast_xml[:200])
elif metrics_counter == 0:
# Log match confirmation once (on first sync cycle)
logging.info("XML generators match OK")
xml_mismatch_logged = True
except Exception as e:
logging.warning("FastXMLGenerator error: %s", e)
xml_mismatch_logged = True
self.udp_socket.sendto(send_xml.encode(), self.controller_ip_and_port) self.udp_socket.sendto(send_xml.encode(), self.controller_ip_and_port)
# Sync robot's outgoing data → Manager dict periodically (every 10 cycles ~40ms) # Sync robot's outgoing data → Manager dict periodically (every 10 cycles ~40ms)
@ -235,22 +267,20 @@ class NetworkProcess(multiprocessing.Process):
if self.timing_metrics is not None: if self.timing_metrics is not None:
self.timing_metrics.record_cycle(local_robot_out.get("IPOC", 0)) self.timing_metrics.record_cycle(local_robot_out.get("IPOC", 0))
# Update shared metrics dict every 100 cycles (~400ms)
update_counter += 1 update_counter += 1
if update_counter >= 100: if update_counter >= 100:
self._update_metrics_dict() self._update_metrics_dict()
update_counter = 0 update_counter = 0
if self.logging_active.value and self.log_queue: if self.logging_active.value and self.log_queue:
self._queue_log_entry() self._queue_log_entry(local_robot_out, local_robot_in)
except socket.timeout: except socket.timeout:
logging.warning("No message received within timeout period") logging.warning("No message received within timeout period")
# Check watchdog on timeout
if self.timing_metrics and self.timing_metrics.check_watchdog(): if self.timing_metrics and self.timing_metrics.check_watchdog():
logging.error("Watchdog timeout - communication lost!") logging.error("Watchdog timeout - communication lost!")
except Exception as e: except Exception as e:
logging.error(f"Network process error: {e}") logging.error("Network process error: %s", e)
def _process_commands(self) -> None: def _process_commands(self) -> None:
"""Process any pending commands from the parent process.""" """Process any pending commands from the parent process."""
@ -265,14 +295,64 @@ class NetworkProcess(multiprocessing.Process):
self._start_logging(cmd.get('filename')) self._start_logging(cmd.get('filename'))
elif action == 'stop_logging': elif action == 'stop_logging':
self._stop_logging() self._stop_logging()
elif action == 'estop':
self.estop_active.value = True
elif action == 'estop_reset':
self.estop_active.value = False
except Empty: except Empty:
pass pass
except Exception as e: except Exception as e:
logging.error(f"Error processing command: {e}") logging.error("Error processing command: %s", e)
def _apply_rate_limit(self, robot_in: dict, prev: Dict[str, Dict[str, float]]) -> None:
"""
Apply per-cycle rate limiting to correction values in-place.
In relative mode: clamp each value directly (it IS the per-cycle delta).
In absolute mode: clamp the change from previous cycle and ramp toward target.
Args:
robot_in: Current outgoing corrections dict (modified in-place)
prev: Previous cycle's correction values (updated in-place)
"""
cartesian_keys = {'X', 'Y', 'Z', 'A', 'B', 'C'}
joint_keys = {'A1', 'A2', 'A3', 'A4', 'A5', 'A6'}
for corr_key, max_rate, axis_set in [
('RKorr', self.max_cartesian_rate, cartesian_keys),
('AKorr', self.max_joint_rate, joint_keys),
]:
if max_rate <= 0:
continue # Rate limiting disabled for this type
if corr_key not in robot_in or not isinstance(robot_in[corr_key], dict):
continue
corr = robot_in[corr_key]
prev_corr = prev.get(corr_key, {})
if self.rsi_mode == 'relative':
# Each value is a per-cycle delta — clamp directly
for axis in corr:
if axis in axis_set:
val = corr[axis]
corr[axis] = max(-max_rate, min(max_rate, val))
else:
# Absolute mode — clamp the change from previous
for axis in corr:
if axis in axis_set:
target = corr[axis]
previous = prev_corr.get(axis, 0.0)
delta = target - previous
clamped_delta = max(-max_rate, min(max_rate, delta))
corr[axis] = previous + clamped_delta
prev_corr[axis] = corr[axis]
robot_in[corr_key] = corr
prev[corr_key] = prev_corr
def _update_metrics_dict(self) -> None: def _update_metrics_dict(self) -> None:
"""Update shared metrics dictionary with current timing statistics (Phase 2).""" """Update shared metrics dictionary with current timing statistics."""
if self.metrics_dict is None or self.timing_metrics is None: if self.metrics_dict is None or self.timing_metrics is None:
return return
@ -280,7 +360,6 @@ class NetworkProcess(multiprocessing.Process):
stats = self.timing_metrics.get_current_stats() stats = self.timing_metrics.get_current_stats()
health = self.timing_metrics.get_health_status() health = self.timing_metrics.get_health_status()
# Update shared dict (Manager dict supports item assignment)
for key, value in stats.items(): for key, value in stats.items():
self.metrics_dict[key] = value self.metrics_dict[key] = value
@ -289,56 +368,48 @@ class NetworkProcess(multiprocessing.Process):
self.metrics_dict['watchdog_timeout'] = health['watchdog_timeout'] self.metrics_dict['watchdog_timeout'] = health['watchdog_timeout']
except Exception as e: except Exception as e:
logging.debug(f"Failed to update metrics dict: {e}") logging.debug("Failed to update metrics dict: %s", e)
def _queue_log_entry(self) -> None: def _queue_log_entry(self, local_robot_out: dict, local_robot_in: dict) -> None:
"""Queue current state for CSV logging (non-blocking).""" """Queue current state for CSV logging using local dicts (no IPC)."""
try: try:
entry = {} entry = {}
# Flatten send variables for key, value in local_robot_out.items():
for key, value in dict(self.send_variables).items():
if isinstance(value, dict): if isinstance(value, dict):
for subkey, subval in value.items(): for subkey, subval in value.items():
entry[f"Send.{key}.{subkey}"] = subval entry[f"Send.{key}.{subkey}"] = subval
else: else:
entry[f"Send.{key}"] = value entry[f"Send.{key}"] = value
# Flatten receive variables for key, value in local_robot_in.items():
for key, value in dict(self.receive_variables).items():
if isinstance(value, dict): if isinstance(value, dict):
for subkey, subval in value.items(): for subkey, subval in value.items():
entry[f"Receive.{key}.{subkey}"] = subval entry[f"Receive.{key}.{subkey}"] = subval
else: else:
entry[f"Receive.{key}"] = value entry[f"Receive.{key}"] = value
# Non-blocking put - drop entry if queue is full
try: try:
self.log_queue.put_nowait(entry) self.log_queue.put_nowait(entry)
except: except:
pass # Queue full, skip this entry rather than block pass # Queue full, skip this entry rather than block
except Exception as e: except Exception as e:
logging.debug(f"Failed to queue log entry: {e}") logging.debug("Failed to queue log entry: %s", e)
def _start_logging(self, filename: str) -> None: def _start_logging(self, filename: str) -> None:
""" """Start CSV logging to the specified file."""
Start CSV logging to the specified file.
Args:
filename: Path to CSV output file
"""
if self.logging_active.value: if self.logging_active.value:
logging.warning("Logging already active") logging.warning("Logging already active")
return return
self.log_queue = multiprocessing.Queue(maxsize=1000) self.log_queue = ThreadQueue(maxsize=1000)
self.log_stop_event = multiprocessing.Event() self.log_stop_event = threading.Event()
self.csv_logger = CSVLogger(self.log_queue, self.log_stop_event, filename) self.csv_logger = CSVLogger(self.log_queue, self.log_stop_event, filename)
self.csv_logger.start() self.csv_logger.start()
self.logging_active.value = True self.logging_active.value = True
logging.info(f"CSV logging started: {filename}") logging.info("CSV logging started: %s", filename)
def _stop_logging(self) -> None: def _stop_logging(self) -> None:
"""Stop CSV logging and cleanup resources.""" """Stop CSV logging and cleanup resources."""
@ -358,8 +429,6 @@ class NetworkProcess(multiprocessing.Process):
if self.csv_logger and self.csv_logger.is_alive(): if self.csv_logger and self.csv_logger.is_alive():
self.csv_logger.join(timeout=2) self.csv_logger.join(timeout=2)
if self.csv_logger.is_alive():
self.csv_logger.terminate()
self.csv_logger = None self.csv_logger = None
self.log_queue = None self.log_queue = None
@ -368,7 +437,6 @@ class NetworkProcess(multiprocessing.Process):
def _cleanup(self) -> None: def _cleanup(self) -> None:
"""Clean up resources on shutdown.""" """Clean up resources on shutdown."""
# Stop logging first
self._stop_logging() self._stop_logging()
if self.udp_socket: if self.udp_socket:
@ -376,20 +444,11 @@ class NetworkProcess(multiprocessing.Process):
self.udp_socket.close() self.udp_socket.close()
logging.info("Network socket closed") logging.info("Network socket closed")
except Exception as e: except Exception as e:
logging.error(f"Error closing socket: {e}") logging.error("Error closing socket: %s", e)
self.udp_socket = None self.udp_socket = None
@staticmethod @staticmethod
def is_valid_ip(ip: str) -> bool: def is_valid_ip(ip: str) -> bool:
"""
Check if an IP address is valid and bindable.
Args:
ip: IP address string to validate
Returns:
True if IP is valid and can be bound to
"""
try: try:
socket.inet_aton(ip) socket.inet_aton(ip)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
@ -400,43 +459,31 @@ class NetworkProcess(multiprocessing.Process):
@staticmethod @staticmethod
def _parse_received_data(xml_string: str, target: dict) -> None: def _parse_received_data(xml_string: str, target: dict) -> None:
""" """Parse received XML message into a local dict (no IPC)."""
Parse received XML message into a local dict (no IPC).
Args:
xml_string: XML message string from robot controller
target: Plain dict to update with parsed values
Raises:
RSIPacketError: If XML parsing fails
"""
try: try:
root = ET.fromstring(xml_string) root = ET.fromstring(xml_string)
for element in root: for element in root:
if element.tag in target: if element.tag in target:
if len(element.attrib) > 0: if len(element.attrib) > 0:
target[element.tag] = {k: float(v) for k, v in element.attrib.items()} existing = target.get(element.tag)
if isinstance(existing, dict):
for k, v in element.attrib.items():
existing[k] = float(v)
else:
target[element.tag] = {k: float(v) for k, v in element.attrib.items()}
else: else:
target[element.tag] = element.text target[element.tag] = element.text
if element.tag == "IPOC": if element.tag == "IPOC":
target["IPOC"] = int(element.text) target["IPOC"] = int(element.text)
except ET.ParseError as e: except ET.ParseError as e:
logging.error(f"XML parse error in received message: {e}") logging.error("XML parse error in received message: %s", e)
raise RSIPacketError(f"Failed to parse received XML: {e}") from e raise RSIPacketError(f"Failed to parse received XML: {e}") from e
except Exception as e: except Exception as e:
logging.error(f"Error processing received message: {e}") logging.error("Error processing received message: %s", e)
raise RSIPacketError(f"Unexpected error parsing packet: {e}") from e raise RSIPacketError(f"Unexpected error parsing packet: {e}") from e
def process_received_data(self, xml_string: str) -> None: def process_received_data(self, xml_string: str) -> None:
""" """Legacy method kept for compatibility (e.g. echo server)."""
Parse received XML message and update send_variables (Manager dict).
Legacy method kept for compatibility (e.g. echo server). The hot loop
uses _parse_received_data() with local dicts instead.
Args:
xml_string: XML message string from robot controller
"""
self._parse_received_data(xml_string, self.send_variables) self._parse_received_data(xml_string, self.send_variables)
if "IPOC" in self.send_variables: if "IPOC" in self.send_variables:
self.receive_variables["IPOC"] = self.send_variables["IPOC"] + 4 self.receive_variables["IPOC"] = self.send_variables["IPOC"] + 4

View File

@ -1,242 +1,148 @@
""" """
RSIPI - Robot Sensor Interface Python Integration RSIPI - Robot Sensor Interface Python Integration
Main API orchestrator providing namespaced access to all RSI functionality. Main API orchestrator providing namespaced access to all RSI functionality.
""" """
import logging import logging
from threading import Thread from threading import Thread
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
from .motion_api import MotionAPI from .motion_api import MotionAPI
from .io_api import IOAPI from .io_api import IOAPI
from .krl_api import KRLAPI from .krl_api import KRLAPI
from .safety_api import SafetyAPI from .safety_api import SafetyAPI
from .monitoring_api import MonitoringAPI from .monitoring_api import MonitoringAPI
from .logging_api import LoggingAPI from .logging_api import LoggingAPI
from .diagnostics_api import DiagnosticsAPI from .diagnostics_api import DiagnosticsAPI
from .viz_api import VizAPI from .viz_api import VizAPI
from .tools_api import ToolsAPI from .tools_api import ToolsAPI
if TYPE_CHECKING: if TYPE_CHECKING:
from .rsi_client import RSIClient, ClientState from .rsi_client import RSIClient, ClientState
class RSIAPI: class RSIAPI:
""" """
High-level API orchestrator for KUKA RSI robot control. High-level API orchestrator for KUKA RSI robot control.
Provides namespaced access to all RSIPI functionality through specialized Supports context manager usage for safe cleanup:
sub-APIs. This is the main entry point for most users. >>> with RSIAPI('RSI_EthernetConfig.xml') as api:
... api.start()
Namespaces: ... api.motion.update_cartesian(X=10)
- motion: Motion control (Cartesian, joints, trajectories) """
- io: Digital I/O control
- krl: KRL program manipulation utilities def __init__(
- safety: Safety management and limits self,
- monitoring: Live data access and monitoring config_file: str = "RSI_EthernetConfig.xml",
- logging: CSV data logging rsi_mode: str = 'relative',
- diagnostics: Network and performance diagnostics (Phase 2) max_cartesian_rate: float = 0.0,
- viz: Static and live visualization max_joint_rate: float = 0.0,
- tools: Utilities, debugging, and inspection cycle_time: float = 0.004
) -> None:
Core Methods (direct access): """
- start(): Start RSI communication Args:
- stop(): Stop RSI communication config_file: Path to RSI_EthernetConfig.xml
- reconnect(): Restart network connection rsi_mode: 'absolute' or 'relative' must match KRL RSI_MOVECORR() mode
- state: Current client state (property) max_cartesian_rate: Max mm/cycle for RKorr corrections (0 = no limit)
max_joint_rate: Max degrees/cycle for AKorr corrections (0 = no limit)
Example: cycle_time: Expected RSI cycle time in seconds (0.004 = 4ms/250Hz, 0.012 = 12ms/83Hz)
>>> api = RSIAPI('RSI_EthernetConfig.xml') """
>>> api.start() self.config_file: str = config_file
>>> api.motion.update_cartesian(X=10, Y=5, Z=0) self.rsi_mode: str = rsi_mode
>>> api.logging.start('test.csv') self.max_cartesian_rate: float = max_cartesian_rate
>>> # ... robot operation ... self.max_joint_rate: float = max_joint_rate
>>> api.logging.stop() self.cycle_time: float = cycle_time
>>> api.stop() self.client: Optional['RSIClient'] = None
>>> api.viz.plot_static('test.csv', '3d') self._thread: Optional[Thread] = None
"""
self._ensure_client()
def __init__(self, config_file: str = "RSI_EthernetConfig.xml") -> None:
""" self.motion = MotionAPI(self.client)
Initialize RSIAPI with configuration file. self.io = IOAPI(self.client)
self.krl = KRLAPI(self.client)
Creates RSIClient instance (lazy initialization) and sets up all self.safety = SafetyAPI(self.client)
namespace APIs for organized access to functionality. self.monitoring = MonitoringAPI(self.client)
self.logging = LoggingAPI(self.client)
Args: self.diagnostics = DiagnosticsAPI(self.client)
config_file: Path to RSI_EthernetConfig.xml configuration file self.viz = VizAPI(self.client)
self.tools = ToolsAPI(self.client)
Example:
>>> api = RSIAPI('config/RSI_EthernetConfig.xml') logging.info("RSIAPI initialized with namespaced structure")
>>> print(api.state)
ClientState.INITIALIZED def __enter__(self):
""" return self
self.config_file: str = config_file
self.client: Optional['RSIClient'] = None def __exit__(self, exc_type, exc_val, exc_tb):
self._thread: Optional[Thread] = None try:
self.stop()
# Initialize client except Exception:
self._ensure_client() pass
return False
# Initialize namespace APIs
self.motion = MotionAPI(self.client) def _ensure_client(self) -> None:
self.io = IOAPI(self.client) if self.client is None:
self.krl = KRLAPI(self.client) from .rsi_client import RSIClient
self.safety = SafetyAPI(self.client) self.client = RSIClient(
self.monitoring = MonitoringAPI(self.client) self.config_file,
self.logging = LoggingAPI(self.client) rsi_mode=self.rsi_mode,
self.diagnostics = DiagnosticsAPI(self.client) max_cartesian_rate=self.max_cartesian_rate,
self.viz = VizAPI(self.client) max_joint_rate=self.max_joint_rate,
self.tools = ToolsAPI(self.client) cycle_time=self.cycle_time
)
logging.info("RSIAPI initialized with namespaced structure")
@property
def _ensure_client(self) -> None: def state(self) -> 'ClientState':
""" return self.client.state
Ensure RSIClient is initialized (lazy initialization).
def start(self) -> str:
Imports and creates RSIClient only when needed, avoiding circular """Start RSI communication in background thread."""
dependencies and improving startup time. self._thread = Thread(target=self.client.start, daemon=True)
""" self._thread.start()
if self.client is None: logging.info("RSI communication started in background thread")
from .rsi_client import RSIClient return "RSI started in background"
self.client = RSIClient(self.config_file)
logging.debug("RSIClient initialized") def stop(self) -> str:
"""Stop RSI communication gracefully."""
@property self.client.stop()
def state(self) -> 'ClientState': if self._thread and self._thread.is_alive():
""" self._thread.join(timeout=3)
Get current client state. self._thread = None
logging.info("RSI communication stopped")
Returns: return "RSI stopped"
ClientState enum value (INITIALIZED, STARTING, RUNNING, STOPPING, STOPPED, ERROR)
def wait_for_connection(self, timeout: float = 10.0) -> bool:
Example: """
>>> api = RSIAPI() Block until the robot's first packet is received.
>>> print(api.state)
ClientState.INITIALIZED Args:
>>> api.start() timeout: Maximum time to wait in seconds
>>> print(api.state)
ClientState.RUNNING Returns:
""" True if connected, False if timeout
return self.client.state """
return self.client.wait_for_connection(timeout)
def start(self) -> str:
""" def reconnect(self) -> str:
Start RSI communication in background thread. """Restart network connection with fresh resources."""
self.client.reconnect()
Creates a daemon thread that runs the RSI client's main communication # Start client in new thread
loop. The thread handles UDP message exchange with the robot controller. self._thread = Thread(target=self.client.start, daemon=True)
self._thread.start()
Returns: logging.info("Network connection restarted")
Status message return "Network connection restarted"
Raises: def is_running(self) -> bool:
RSIClientNotReady: If client is not in appropriate state to start return self.client.is_running()
Example: def is_stopped(self) -> bool:
>>> api = RSIAPI() return self.client.is_stopped()
>>> api.start()
'RSI started in background' # Deprecated methods
>>> api.state def start_rsi(self) -> str:
ClientState.RUNNING logging.warning("start_rsi() is deprecated. Use api.start() instead.")
return self.start()
Note:
The background thread runs as a daemon, so it will automatically def stop_rsi(self) -> str:
terminate when the main program exits. logging.warning("stop_rsi() is deprecated. Use api.stop() instead.")
""" return self.stop()
self._thread = Thread(target=self.client.start, daemon=True)
self._thread.start()
logging.info("RSI communication started in background thread")
return "RSI started in background"
def stop(self) -> str:
"""
Stop RSI communication.
Gracefully shuts down the network process, closes sockets, and
stops any active logging. Waits for background threads to complete.
Returns:
Status message
Example:
>>> api.stop()
'RSI stopped'
Note:
This method blocks until the network process has fully shut down,
which typically takes 1-3 seconds.
"""
self.client.stop()
logging.info("RSI communication stopped")
return "RSI stopped"
def reconnect(self) -> str:
"""
Restart network connection without stopping RSI client.
Terminates the current network process, creates a fresh one with new
communication resources, and restarts the connection. Useful for
recovering from network errors.
Returns:
Status message
Example:
>>> # Network issue detected
>>> api.reconnect()
'Network connection restarted'
Note:
This creates fresh multiprocessing Events and Queues. Any queued
but unprocessed data in the old network process will be lost.
"""
self.client.reconnect()
logging.info("Network connection restarted")
return "Network connection restarted"
def is_running(self) -> bool:
"""
Check if RSI client is in RUNNING state.
Returns:
True if actively communicating with robot
Example:
>>> api = RSIAPI()
>>> api.is_running()
False
>>> api.start()
>>> api.is_running()
True
"""
return self.client.is_running()
def is_stopped(self) -> bool:
"""
Check if RSI client is fully stopped.
Returns:
True if in STOPPED state
Example:
>>> api.stop()
>>> api.is_stopped()
True
"""
return self.client.is_stopped()
# Deprecated methods for backward compatibility (Phase 5.1 - to be removed)
# These are kept temporarily to ease migration. Use namespaced methods instead.
def start_rsi(self) -> str:
"""DEPRECATED: Use api.start() instead."""
logging.warning("start_rsi() is deprecated. Use api.start() instead.")
return self.start()
def stop_rsi(self) -> str:
"""DEPRECATED: Use api.stop() instead."""
logging.warning("stop_rsi() is deprecated. Use api.stop() instead.")
return self.stop()

View File

@ -1,299 +1,336 @@
import logging import logging
import multiprocessing import multiprocessing
import time import time
from enum import Enum, auto from enum import Enum, auto
from threading import Lock, Thread from threading import Lock, Thread
from typing import Optional from typing import Optional
from .config_parser import ConfigParser from .config_parser import ConfigParser
from .network_handler import NetworkProcess from .network_handler import NetworkProcess
from .safety_manager import SafetyManager from .safety_manager import SafetyManager
from .exceptions import RSIStateError, RSIInvalidTransition, RSIClientNotReady from .exceptions import RSIStateError, RSIInvalidTransition, RSIClientNotReady
from .auto_reconnect import AutoReconnectManager, ReconnectStrategy from .auto_reconnect import AutoReconnectManager, ReconnectStrategy
class ClientState(Enum): class ClientState(Enum):
"""Connection states for RSIClient.""" """Connection states for RSIClient."""
INITIALIZED = auto() # After __init__, network process spawned but not started INITIALIZED = auto() # After __init__, network process spawned but not started
STARTING = auto() # Start signal sent, waiting for network to be ready STARTING = auto() # Start signal sent, waiting for network to be ready
RUNNING = auto() # Actively communicating with robot RUNNING = auto() # Actively communicating with robot
STOPPING = auto() # Shutdown in progress STOPPING = auto() # Shutdown in progress
STOPPED = auto() # Fully stopped, cannot be restarted (use reconnect) STOPPED = auto() # Fully stopped, cannot be restarted (use reconnect)
ERROR = auto() # Error state ERROR = auto() # Error state
class RSIClient: class RSIClient:
"""Main RSI API class that integrates network, config handling, and message processing.""" """Main RSI API class that integrates network, config handling, and message processing."""
# Valid state transitions _VALID_TRANSITIONS = {
_VALID_TRANSITIONS = { ClientState.INITIALIZED: {ClientState.STARTING, ClientState.STOPPING},
ClientState.INITIALIZED: {ClientState.STARTING, ClientState.STOPPING}, ClientState.STARTING: {ClientState.RUNNING, ClientState.STOPPING, ClientState.ERROR},
ClientState.STARTING: {ClientState.RUNNING, ClientState.STOPPING, ClientState.ERROR}, ClientState.RUNNING: {ClientState.STOPPING, ClientState.ERROR},
ClientState.RUNNING: {ClientState.STOPPING, ClientState.ERROR}, ClientState.STOPPING: {ClientState.STOPPED, ClientState.ERROR},
ClientState.STOPPING: {ClientState.STOPPED, ClientState.ERROR}, ClientState.STOPPED: {ClientState.INITIALIZED},
ClientState.STOPPED: {ClientState.INITIALIZED}, # Via reconnect ClientState.ERROR: {ClientState.STOPPING, ClientState.INITIALIZED},
ClientState.ERROR: {ClientState.STOPPING, ClientState.INITIALIZED}, # Via reconnect }
}
def __init__(
def __init__( self,
self, config_file: str,
config_file: str, rsi_limits_file: Optional[str] = None,
rsi_limits_file: Optional[str] = None, enable_auto_reconnect: bool = False,
enable_auto_reconnect: bool = False, auto_reconnect_retries: int = 5,
auto_reconnect_retries: int = 5, auto_reconnect_delay: float = 5.0,
auto_reconnect_delay: float = 5.0 rsi_mode: str = 'relative',
) -> None: max_cartesian_rate: float = 0.0,
""" max_joint_rate: float = 0.0,
Initialize RSI client with configuration and safety limits. cycle_time: float = 0.004
) -> None:
Args: """
config_file: Path to RSI_EthernetConfig.xml Args:
rsi_limits_file: Optional path to .rsi.xml safety limits file config_file: Path to RSI_EthernetConfig.xml
enable_auto_reconnect: Enable automatic reconnection on communication loss rsi_limits_file: Optional path to .rsi.xml safety limits file
auto_reconnect_retries: Maximum reconnection attempts (0 = unlimited) enable_auto_reconnect: Enable automatic reconnection on communication loss
auto_reconnect_delay: Base delay between retries in seconds auto_reconnect_retries: Maximum reconnection attempts (0 = unlimited)
""" auto_reconnect_delay: Base delay between retries in seconds
logging.info(f"Loading RSI configuration from {config_file}...") rsi_mode: 'absolute' or 'relative' must match KRL RSI_MOVECORR() mode
max_cartesian_rate: Max mm/cycle for RKorr corrections (0 = disabled)
self._state: ClientState = ClientState.INITIALIZED max_joint_rate: Max degrees/cycle for AKorr corrections (0 = disabled)
self._state_lock: Lock = Lock() cycle_time: Expected RSI cycle time in seconds (0.004 or 0.012)
"""
self.config_parser: ConfigParser = ConfigParser(config_file, rsi_limits_file) logging.info("Loading RSI configuration from %s...", config_file)
network_settings = self.config_parser.get_network_settings() self.rsi_mode = rsi_mode
self.max_cartesian_rate = max_cartesian_rate
self.manager: multiprocessing.Manager = multiprocessing.Manager() self.max_joint_rate = max_joint_rate
self.send_variables = self.manager.dict(self.config_parser.send_variables) self.cycle_time = cycle_time
self.receive_variables = self.manager.dict(self.config_parser.receive_variables)
self.stop_event: multiprocessing.Event = multiprocessing.Event() self._state: ClientState = ClientState.INITIALIZED
self.start_event: multiprocessing.Event = multiprocessing.Event() self._state_lock: Lock = Lock()
self.command_queue: multiprocessing.Queue = multiprocessing.Queue()
self.config_parser: ConfigParser = ConfigParser(config_file, rsi_limits_file)
self.safety_manager: SafetyManager = SafetyManager(self.config_parser.safety_limits) network_settings = self.config_parser.get_network_settings()
# Shared logging state (readable from parent process) # Validate config on startup
self._logging_active = multiprocessing.Value('b', False) self._validate_config()
# Shared metrics dictionary (Phase 2) self.manager: multiprocessing.Manager = multiprocessing.Manager()
self.metrics_dict = self.manager.dict() self.send_variables = self.manager.dict(self.config_parser.send_variables)
self.receive_variables = self.manager.dict(self.config_parser.receive_variables)
# Create NetworkProcess but don't start communication yet self.stop_event: multiprocessing.Event = multiprocessing.Event()
self.network_process: NetworkProcess = NetworkProcess( self.start_event: multiprocessing.Event = multiprocessing.Event()
network_settings["ip"], self.connected_event: multiprocessing.Event = multiprocessing.Event()
network_settings["port"], self.command_queue: multiprocessing.Queue = multiprocessing.Queue()
self.send_variables,
self.receive_variables, self.safety_manager: SafetyManager = SafetyManager(self.config_parser.safety_limits)
self.stop_event,
self.config_parser, self._logging_active = multiprocessing.Value('b', False)
self.start_event, self._receive_dirty = multiprocessing.Value('b', True) # Dirty flag for IPC optimization
self.command_queue,
self.metrics_dict self.metrics_dict = self.manager.dict()
)
# Share the logging_active flag self._create_network_process(network_settings)
self.network_process.logging_active = self._logging_active
self.network_process.start() self.logger: Optional[any] = None
self.running: bool = False
self.logger: Optional[any] = None # Reserved for future use self.thread: Optional[Thread] = None
self.running: bool = False
self.thread: Optional[Thread] = None self.auto_reconnect_manager: Optional[AutoReconnectManager] = None
if enable_auto_reconnect:
# Auto-reconnect manager (Phase 2) self.auto_reconnect_manager = AutoReconnectManager(
self.auto_reconnect_manager: Optional[AutoReconnectManager] = None client=self,
if enable_auto_reconnect: enabled=True,
self.auto_reconnect_manager = AutoReconnectManager( max_retries=auto_reconnect_retries,
client=self, retry_delay=auto_reconnect_delay,
enabled=True, strategy=ReconnectStrategy.LINEAR_BACKOFF
max_retries=auto_reconnect_retries, )
retry_delay=auto_reconnect_delay, logging.info("Auto-reconnect enabled")
strategy=ReconnectStrategy.LINEAR_BACKOFF
) def _validate_config(self) -> None:
logging.info("Auto-reconnect enabled") """Validate config and warn about common misconfigurations."""
send = self.config_parser.send_variables
@property recv = self.config_parser.receive_variables
def state(self) -> ClientState:
"""Get current client state (thread-safe).""" # Check correction variables are in receive (what we send to robot)
with self._state_lock: if "RKorr" not in recv and "AKorr" not in recv:
return self._state logging.warning(
"Config validation: Neither RKorr nor AKorr found in RECEIVE section. "
def _transition_to(self, new_state: ClientState) -> bool: "You won't be able to send motion corrections to the robot. "
""" "Check your RSI_EthernetConfig.xml <RECEIVE> elements."
Attempt to transition to a new state. )
Args: # Check position feedback is in send (what robot sends to us)
new_state: Target state to transition to if "RIst" not in send:
logging.warning(
Returns: "Config validation: RIst not found in SEND section. "
True if transition was valid and completed, False otherwise "You won't receive Cartesian position feedback from the robot."
""" )
with self._state_lock:
if new_state in self._VALID_TRANSITIONS.get(self._state, set()): if "IPOC" not in send:
old_state = self._state logging.warning(
self._state = new_state "Config validation: IPOC not found in SEND section. "
logging.debug(f"State transition: {old_state.name} -> {new_state.name}") "IPOC synchronisation may not work correctly."
return True )
else:
logging.warning( # Validate RSI mode
f"Invalid state transition attempted: {self._state.name} -> {new_state.name}" if self.rsi_mode not in ('absolute', 'relative'):
) logging.warning(
return False "Config validation: rsi_mode='%s' is not valid. "
"Use 'absolute' or 'relative'. Defaulting to 'relative'.",
def start(self) -> None: self.rsi_mode
""" )
Send start signal to NetworkProcess and run control loop. self.rsi_mode = 'relative'
Transitions through STARTING RUNNING states and maintains # Log summary
control loop until stopped. send_keys = [k for k in send if k != "IPOC"]
recv_keys = [k for k in recv if k not in ("IPOC", "FREE")]
Raises: logging.info(
RSIClientNotReady: If client is not in appropriate state to start "Config validated: SEND=[%s] RECEIVE=[%s] mode=%s",
""" ", ".join(send_keys), ", ".join(recv_keys), self.rsi_mode
if not self._transition_to(ClientState.STARTING): )
error_msg = f"Cannot start from state {self.state.name}"
logging.error(error_msg) def _create_network_process(self, network_settings: dict) -> None:
raise RSIClientNotReady(error_msg) """Create and start the NetworkProcess with current settings."""
self.network_process: NetworkProcess = NetworkProcess(
logging.info("RSIClient sending start signal to NetworkProcess...") network_settings["ip"],
self.start_event.set() network_settings["port"],
self.send_variables,
if not self._transition_to(ClientState.RUNNING): self.receive_variables,
error_msg = "Failed to transition to RUNNING state" self.stop_event,
logging.error(error_msg) self.config_parser,
raise RSIStateError(error_msg) self.start_event,
self.command_queue,
self.running = True self.metrics_dict,
logging.info("RSI Client Started") self.connected_event,
rsi_mode=self.rsi_mode,
# Start auto-reconnect monitor (Phase 2) max_cartesian_rate=self.max_cartesian_rate,
if self.auto_reconnect_manager: max_joint_rate=self.max_joint_rate,
self.auto_reconnect_manager.start() cycle_time=self.cycle_time
)
try: self.network_process.logging_active = self._logging_active
while self.running and not self.stop_event.is_set(): self.network_process.receive_dirty = self._receive_dirty
time.sleep(2) self.network_process.start()
except KeyboardInterrupt:
self.stop() @property
except Exception as e: def state(self) -> ClientState:
logging.error(f"RSI Client encountered an error: {e}") """Get current client state (thread-safe)."""
self._transition_to(ClientState.ERROR) with self._state_lock:
raise return self._state
def stop(self) -> None: def _transition_to(self, new_state: ClientState) -> bool:
"""Stop the network process and the client thread safely.""" with self._state_lock:
if self.state in (ClientState.STOPPED, ClientState.STOPPING): if new_state in self._VALID_TRANSITIONS.get(self._state, set()):
logging.debug("Already stopped or stopping") old_state = self._state
return self._state = new_state
logging.debug("State transition: %s -> %s", old_state.name, new_state.name)
if not self._transition_to(ClientState.STOPPING): return True
logging.warning("Could not transition to STOPPING state") else:
# Continue anyway to ensure cleanup logging.warning(
"Invalid state transition attempted: %s -> %s", self._state.name, new_state.name
logging.info("Stopping RSI Client...") )
return False
self.running = False
self.stop_event.set() def start(self) -> None:
"""
if self.network_process and self.network_process.is_alive(): Send start signal to NetworkProcess and run control loop.
self.network_process.join(timeout=3)
if self.network_process.is_alive(): Raises:
logging.warning("Forcing network process termination...") RSIClientNotReady: If client is not in appropriate state to start
self.network_process.terminate() """
self.network_process.join() if not self._transition_to(ClientState.STARTING):
error_msg = f"Cannot start from state {self.state.name}"
if self.thread and self.thread.is_alive(): logging.error(error_msg)
self.thread.join(timeout=2) raise RSIClientNotReady(error_msg)
self.thread = None
logging.info("RSIClient sending start signal to NetworkProcess...")
# Stop auto-reconnect monitor (Phase 2) self.start_event.set()
if self.auto_reconnect_manager:
self.auto_reconnect_manager.stop() if not self._transition_to(ClientState.RUNNING):
error_msg = "Failed to transition to RUNNING state"
self._transition_to(ClientState.STOPPED) logging.error(error_msg)
logging.info("RSI Client Stopped") raise RSIStateError(error_msg)
def reconnect(self) -> None: self.running = True
""" logging.info("RSI Client Started")
Reconnect the network process safely.
if self.auto_reconnect_manager:
Stops existing connection, resets state, and creates fresh self.auto_reconnect_manager.start()
network process with new communication resources.
""" try:
logging.info("Reconnecting RSI Client network...") while self.running and not self.stop_event.is_set():
time.sleep(2)
# Stop if currently running except KeyboardInterrupt:
if self.state in (ClientState.RUNNING, ClientState.STARTING): self.stop()
self.stop() except Exception as e:
logging.error("RSI Client encountered an error: %s", e)
if self.network_process and self.network_process.is_alive(): self._transition_to(ClientState.ERROR)
self.stop_event.set() raise
self.network_process.terminate()
self.network_process.join() def stop(self) -> None:
"""Stop the network process and the client thread safely."""
# Reset to initialized state if self.state in (ClientState.STOPPED, ClientState.STOPPING):
with self._state_lock: logging.debug("Already stopped or stopping")
self._state = ClientState.INITIALIZED return
# Fresh new events and queue if not self._transition_to(ClientState.STOPPING):
self.stop_event = multiprocessing.Event() logging.warning("Could not transition to STOPPING state")
self.start_event = multiprocessing.Event()
self.command_queue = multiprocessing.Queue() logging.info("Stopping RSI Client...")
# Reset metrics dictionary (Phase 2) self.running = False
self.metrics_dict.clear() self.stop_event.set()
# Create new network process if self.network_process and self.network_process.is_alive():
network_settings = self.config_parser.get_network_settings() self.network_process.join(timeout=3)
self.network_process = NetworkProcess( if self.network_process.is_alive():
network_settings["ip"], logging.warning("Forcing network process termination...")
network_settings["port"], self.network_process.terminate()
self.send_variables, self.network_process.join()
self.receive_variables,
self.stop_event, if self.thread and self.thread.is_alive():
self.config_parser, self.thread.join(timeout=2)
self.start_event, self.thread = None
self.command_queue,
self.metrics_dict if self.auto_reconnect_manager:
) self.auto_reconnect_manager.stop()
self.network_process.logging_active = self._logging_active
self.network_process.start() # Shutdown Manager to avoid resource leaks
try:
# Fresh control thread self.manager.shutdown()
self.thread = Thread(target=self.start, daemon=True) except Exception:
self.thread.start() pass
def is_running(self) -> bool: self._transition_to(ClientState.STOPPED)
""" logging.info("RSI Client Stopped")
Check if client is in running state.
def reconnect(self) -> None:
Returns: """
True if currently running Reconnect the network process safely.
"""
return self.state == ClientState.RUNNING Stops existing connection, resets state, and creates fresh
network process with new communication resources.
def is_stopped(self) -> bool: """
""" logging.info("Reconnecting RSI Client network...")
Check if client is fully stopped.
if self.state in (ClientState.RUNNING, ClientState.STARTING):
Returns: self.stop()
True if in STOPPED state
""" if self.network_process and self.network_process.is_alive():
return self.state == ClientState.STOPPED self.stop_event.set()
self.network_process.terminate()
def start_logging(self, filename: str) -> None: self.network_process.join()
"""
Start CSV logging to the specified file. # Fresh Manager (old one was shut down in stop())
self.manager = multiprocessing.Manager()
Args: self.send_variables = self.manager.dict(self.config_parser.send_variables)
filename: Path to output CSV file self.receive_variables = self.manager.dict(self.config_parser.receive_variables)
""" self.metrics_dict = self.manager.dict()
self.command_queue.put({'action': 'start_logging', 'filename': filename})
with self._state_lock:
def stop_logging(self) -> None: self._state = ClientState.INITIALIZED
"""Stop CSV logging."""
self.command_queue.put({'action': 'stop_logging'}) self.stop_event = multiprocessing.Event()
self.start_event = multiprocessing.Event()
def is_logging_active(self) -> bool: self.connected_event = multiprocessing.Event()
""" self.command_queue = multiprocessing.Queue()
Check if CSV logging is currently active. self._receive_dirty = multiprocessing.Value('b', True)
Returns: network_settings = self.config_parser.get_network_settings()
True if logging is active self._create_network_process(network_settings)
"""
return self._logging_active.value def wait_for_connection(self, timeout: float = 10.0) -> bool:
"""
Block until the first valid packet is received from the robot.
Args:
timeout: Maximum time to wait in seconds
Returns:
True if connected, False if timeout
"""
return self.connected_event.wait(timeout=timeout)
def emergency_stop(self) -> None:
"""Send E-stop command to network process to zero all corrections."""
self.safety_manager.emergency_stop()
self.command_queue.put({'action': 'estop'})
logging.critical("Emergency stop activated")
def emergency_reset(self) -> None:
"""Reset E-stop and resume normal corrections."""
self.safety_manager.reset_stop()
self.command_queue.put({'action': 'estop_reset'})
logging.info("Emergency stop reset")
def is_running(self) -> bool:
return self.state == ClientState.RUNNING
def is_stopped(self) -> bool:
return self.state == ClientState.STOPPED
def start_logging(self, filename: str) -> None:
self.command_queue.put({'action': 'start_logging', 'filename': filename})
def stop_logging(self) -> None:
self.command_queue.put({'action': 'stop_logging'})
def is_logging_active(self) -> bool:
return self._logging_active.value

View File

@ -1,11 +1,12 @@
import copy
import socket import socket
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import logging import logging
import threading import threading
from .rsi_config import RSIConfig from .config_parser import ConfigParser
# Toggle logging for debugging purposes # Toggle logging for debugging purposes
LOGGING_ENABLED = True LOGGING_ENABLED = True
if LOGGING_ENABLED: if LOGGING_ENABLED:
@ -16,6 +17,13 @@ if LOGGING_ENABLED:
datefmt="%Y-%m-%d %H:%M:%S" datefmt="%Y-%m-%d %H:%M:%S"
) )
# Maps correction tags from client <Sen> XML to robot state tags in <Rob> XML
CORRECTION_TO_STATE = {
"RKorr": "RIst",
"AKorr": "AIPos",
"EKorr": "ELPos",
}
class EchoServer: class EchoServer:
""" """
@ -35,7 +43,7 @@ class EchoServer:
delay_ms (int): Delay between messages in milliseconds. delay_ms (int): Delay between messages in milliseconds.
mode (str): Correction mode ("relative" or "absolute"). mode (str): Correction mode ("relative" or "absolute").
""" """
self.config = RSIConfig(config_file) self.config = ConfigParser(config_file)
network_settings = self.config.get_network_settings() network_settings = self.config.get_network_settings()
self.server_address = ("0.0.0.0", 50000) # Local bind self.server_address = ("0.0.0.0", 50000) # Local bind
@ -48,14 +56,12 @@ class EchoServer:
self.delay_ms = delay_ms / 1000 # Convert to seconds self.delay_ms = delay_ms / 1000 # Convert to seconds
self.mode = mode.lower() self.mode = mode.lower()
# Internal state to simulate robot values # Build internal state from config send_variables (what the robot sends out).
self.state = { # Deep copy so mutations to self.state don't affect the parser's data.
"RIst": {k: 0.0 for k in ["X", "Y", "Z", "A", "B", "C"]}, self.state = copy.deepcopy(self.config.send_variables)
"AIPos": {f"A{i}": 0.0 for i in range(1, 7)},
"ELPos": {f"E{i}": 0.0 for i in range(1, 7)}, # Ensure IPOC is managed separately (we increment it ourselves)
"DiO": 0, self.state.pop("IPOC", None)
"DiL": 0
}
self.running = True self.running = True
self.thread = threading.Thread(target=self.send_message, daemon=True) self.thread = threading.Thread(target=self.send_message, daemon=True)
@ -66,7 +72,8 @@ class EchoServer:
def receive_and_process(self): def receive_and_process(self):
""" """
Handles one incoming UDP message and updates the internal state accordingly. Handles one incoming UDP message and updates the internal state accordingly.
Supports RKorr, AKorr, DiO, DiL, and IPOC updates. Supports correction tags (RKorr->RIst, AKorr->AIPos, EKorr->ELPos),
scalar state updates (DiO, DiL, etc.), and IPOC synchronisation.
""" """
try: try:
self.udp_socket.settimeout(self.delay_ms) self.udp_socket.settimeout(self.delay_ms)
@ -77,32 +84,37 @@ class EchoServer:
for elem in root: for elem in root:
tag = elem.tag tag = elem.tag
if tag in ["RKorr", "AKorr"]:
for axis, value in elem.attrib.items(): if tag in CORRECTION_TO_STATE:
value = float(value) # Apply correction (RKorr/AKorr/EKorr) to corresponding state variable
if tag == "RKorr" and axis in self.state["RIst"]: state_key = CORRECTION_TO_STATE[tag]
# Apply Cartesian correction if state_key in self.state and isinstance(self.state[state_key], dict):
if self.mode == "relative": for axis, value in elem.attrib.items():
self.state["RIst"][axis] += value if axis in self.state[state_key]:
else: value = float(value)
self.state["RIst"][axis] = value if self.mode == "relative":
elif tag == "AKorr" and axis in self.state["AIPos"]: self.state[state_key][axis] += value
# Apply joint correction else:
if self.mode == "relative": self.state[state_key][axis] = value
self.state["AIPos"][axis] += value
else:
self.state["AIPos"][axis] = value
elif tag in ["DiO", "DiL"]:
if tag in self.state:
self.state[tag] = int(elem.text.strip())
elif tag == "IPOC": elif tag == "IPOC":
self.ipoc_value = int(elem.text.strip()) self.ipoc_value = int(elem.text.strip())
elif tag in self.state:
# Update scalar state values (DiO, DiL, etc.)
if isinstance(self.state[tag], dict):
# Structured variable sent as attributes
for attr, value in elem.attrib.items():
if attr in self.state[tag]:
self.state[tag][attr] = float(value)
elif isinstance(self.state[tag], (int, float)):
self.state[tag] = int(elem.text.strip()) if isinstance(self.state[tag], int) else float(elem.text.strip())
logging.debug(f"Processed input: {ET.tostring(root).decode()}") logging.debug(f"Processed input: {ET.tostring(root).decode()}")
except socket.timeout: except socket.timeout:
pass # No data within delay window pass # No data within delay window
except ConnectionResetError: except ConnectionResetError:
print("⚠️ Connection was reset by client. Waiting before retry...") print("Connection was reset by client. Waiting before retry...")
time.sleep(0.5) time.sleep(0.5)
except Exception as e: except Exception as e:
print(f"[ERROR] Failed to process input: {e}") print(f"[ERROR] Failed to process input: {e}")
@ -111,16 +123,22 @@ class EchoServer:
""" """
Creates a reply XML message based on current state. Creates a reply XML message based on current state.
Format matches KUKA RSI's expected response structure. Format matches KUKA RSI's expected response structure.
Iterates over all state variables from the config's send_variables.
""" """
root = ET.Element("Rob", Type="KUKA") root = ET.Element("Rob", Type="KUKA")
for key in ["RIst", "AIPos", "ELPos"]: for key, value in self.state.items():
element = ET.SubElement(root, key) if isinstance(value, dict):
for sub_key, value in self.state[key].items(): # Structured variable (RIst, AIPos, etc.) -> XML attributes
element.set(sub_key, f"{value:.2f}") element = ET.SubElement(root, key)
for sub_key, sub_value in value.items():
for key in ["DiO", "DiL"]: element.set(sub_key, f"{float(sub_value):.2f}")
ET.SubElement(root, key).text = str(self.state[key]) elif isinstance(value, bool):
ET.SubElement(root, key).text = "1" if value else "0"
elif isinstance(value, (int, float)):
ET.SubElement(root, key).text = str(value)
elif isinstance(value, str):
ET.SubElement(root, key).text = value
ET.SubElement(root, "IPOC").text = str(self.ipoc_value) ET.SubElement(root, "IPOC").text = str(self.ipoc_value)
return ET.tostring(root, encoding="utf-8").decode() return ET.tostring(root, encoding="utf-8").decode()

View File

@ -35,7 +35,7 @@ class SafetyAPI:
This is NOT a hardware E-stop and should not be relied upon for This is NOT a hardware E-stop and should not be relied upon for
safety-critical applications. Always use proper hardware E-stops. safety-critical applications. Always use proper hardware E-stops.
""" """
self.client.safety_manager.emergency_stop() self.client.emergency_stop()
logging.critical("Emergency stop activated via SafetyAPI") logging.critical("Emergency stop activated via SafetyAPI")
def reset(self) -> None: def reset(self) -> None:
@ -45,7 +45,7 @@ class SafetyAPI:
Clears the E-stop flag, allowing motion commands to proceed again. Clears the E-stop flag, allowing motion commands to proceed again.
Use with caution after ensuring the robot workspace is safe. Use with caution after ensuring the robot workspace is safe.
""" """
self.client.safety_manager.reset_stop() self.client.emergency_reset()
logging.info("Emergency stop reset via SafetyAPI") logging.info("Emergency stop reset via SafetyAPI")
def status(self) -> Dict[str, Any]: def status(self) -> Dict[str, Any]:

View File

@ -73,14 +73,18 @@ class ToolsAPI:
safe_value = self.client.safety_manager.validate(full_path, float(value)) safe_value = self.client.safety_manager.validate(full_path, float(value))
current[child] = safe_value current[child] = safe_value
target[parent] = current target[parent] = current
logging.debug(f"Updated {name} to {safe_value}") if hasattr(self.client, '_receive_dirty'):
self.client._receive_dirty.value = True
logging.debug("Updated %s to %s", name, safe_value)
return f"Updated {name} to {safe_value}" return f"Updated {name} to {safe_value}"
else: else:
raise RSIVariableError(f"Parent variable '{parent}' not found in receive_variables") raise RSIVariableError(f"Parent variable '{parent}' not found in receive_variables")
else: else:
safe_value = self.client.safety_manager.validate(name, float(value)) safe_value = self.client.safety_manager.validate(name, float(value))
target[name] = safe_value target[name] = safe_value
logging.debug(f"Updated {name} to {safe_value}") if hasattr(self.client, '_receive_dirty'):
self.client._receive_dirty.value = True
logging.debug("Updated %s to %s", name, safe_value)
return f"Updated {name} to {safe_value}" return f"Updated {name} to {safe_value}"
def show_variables(self) -> None: def show_variables(self) -> None:
@ -242,7 +246,7 @@ class ToolsAPI:
else: else:
raise ValueError(f"Unsupported format: {format_type}. Use 'csv', 'json', or 'pdf'.") raise ValueError(f"Unsupported format: {format_type}. Use 'csv', 'json', or 'pdf'.")
logging.info(f"Report generated: {output_path}") logging.info("Report generated: %s", output_path)
return f"Report saved as {output_path}" return f"Report saved as {output_path}"
@staticmethod @staticmethod
@ -290,5 +294,5 @@ class ToolsAPI:
"max_diff": float(delta.max()), "max_diff": float(delta.max()),
} }
logging.info(f"Compared {len(shared_cols)} position columns between runs") logging.info("Compared %d position columns between runs", len(shared_cols))
return diffs return diffs

View File

@ -1,58 +1,198 @@
import xml.etree.ElementTree as ET import re
import xml.etree.ElementTree as ET
class XMLGenerator: from typing import Dict, Any, List, Tuple, Optional
"""
Converts structured dictionaries of RSI send/receive variables into
valid XML strings for UDP transmission to/from the robot controller. class XMLGenerator:
""" """
Converts structured dictionaries of RSI send/receive variables into
@staticmethod valid XML strings for UDP transmission to/from the robot controller.
def generate_send_xml(send_variables, network_settings): """
"""
Build an outgoing XML message based on the current send variables. @staticmethod
def generate_send_xml(send_variables, network_settings):
Args: """
send_variables (dict): Structured dictionary of values to send. Build an outgoing XML message based on the current send variables.
network_settings (dict): Contains 'sentype' used for the root element.
Args:
Returns: send_variables (dict): Structured dictionary of values to send.
str: XML-formatted string ready for UDP transmission. network_settings (dict): Contains 'sentype' used for the root element.
"""
root = ET.Element("Sen", Type=network_settings["sentype"]) Returns:
str: XML-formatted string ready for UDP transmission.
# Convert structured keys (e.g. RKorr, Tech) and flat elements (e.g. IPOC) """
for key, value in send_variables.items(): root = ET.Element("Sen", Type=network_settings["sentype"])
if key == "FREE":
continue # Skip unused placeholder fields for key, value in send_variables.items():
if key == "FREE":
if isinstance(value, dict): continue
element = ET.SubElement(root, key)
for sub_key, sub_value in value.items(): if isinstance(value, dict):
element.set(sub_key, f"{float(sub_value):.2f}") element = ET.SubElement(root, key)
else: for sub_key, sub_value in value.items():
ET.SubElement(root, key).text = str(value) element.set(sub_key, f"{float(sub_value):.2f}")
else:
return ET.tostring(root, encoding="utf-8").decode() ET.SubElement(root, key).text = str(value)
@staticmethod return ET.tostring(root, encoding="utf-8").decode()
def generate_receive_xml(receive_variables):
""" @staticmethod
Build an incoming XML message for emulation/testing purposes. def generate_receive_xml(receive_variables):
"""
Args: Build an incoming XML message for emulation/testing purposes.
receive_variables (dict): Structured dictionary of values to simulate reception.
Args:
Returns: receive_variables (dict): Structured dictionary of values to simulate reception.
str: XML-formatted string mimicking a KUKA robot's reply.
""" Returns:
root = ET.Element("Rob", Type="KUKA") str: XML-formatted string mimicking a KUKA robot's reply.
"""
for key, value in receive_variables.items(): root = ET.Element("Rob", Type="KUKA")
if isinstance(value, dict) or hasattr(value, "items"):
element = ET.SubElement(root, key) for key, value in receive_variables.items():
for sub_key, sub_value in value.items(): if isinstance(value, dict) or hasattr(value, "items"):
element.set(sub_key, f"{float(sub_value):.2f}") element = ET.SubElement(root, key)
else: for sub_key, sub_value in value.items():
ET.SubElement(root, key).text = str(value) element.set(sub_key, f"{float(sub_value):.2f}")
else:
return ET.tostring(root, encoding="utf-8").decode() ET.SubElement(root, key).text = str(value)
return ET.tostring(root, encoding="utf-8").decode()
class FastXMLGenerator:
"""
Pre-compiled string template XML generator for the 4ms hot path.
Compiles format strings at init time from the variable structure,
then generates XML via string formatting (no DOM construction).
~5-10x faster than ElementTree for fixed-schema RSI messages.
"""
def __init__(self, variables: dict, root_tag: str = "Sen", type_attr: str = "ImFree") -> None:
"""
Compile a format template from the variable structure.
Args:
variables: Dict of variable names values/dicts (defines the XML schema)
root_tag: Root XML element name ('Sen' for outgoing, 'Rob' for incoming)
type_attr: Value for the Type attribute on root element
"""
self._keys: List[Tuple[str, Optional[List[str]]]] = []
parts = [f'<{root_tag} Type="{type_attr}">']
for key, value in variables.items():
if key == "FREE":
continue
if isinstance(value, dict):
subkeys = list(value.keys())
self._keys.append((key, subkeys))
# Use __ separator to avoid Python format_map treating . as attribute access
attr_template = " ".join(f'{sk}="{{{key}__{sk}:.2f}}"' for sk in subkeys)
parts.append(f"<{key} {attr_template} />")
else:
self._keys.append((key, None))
parts.append(f"<{key}>{{{key}}}</{key}>")
# Ensure IPOC is always in template (required by robot)
if "IPOC" not in variables:
self._keys.append(("IPOC", None))
parts.append("<IPOC>{IPOC}</IPOC>")
parts.append(f"</{root_tag}>")
self._template = "".join(parts)
def generate(self, variables: dict) -> str:
"""
Generate XML string from current variable values using pre-compiled template.
Args:
variables: Current variable values dict
Returns:
XML string ready for UDP transmission
"""
fmt_args = {}
for key, subkeys in self._keys:
if subkeys is not None:
val = variables.get(key, {})
if isinstance(val, dict):
for sk in subkeys:
fmt_args[f"{key}__{sk}"] = float(val.get(sk, 0.0))
else:
for sk in subkeys:
fmt_args[f"{key}__{sk}"] = 0.0
else:
fmt_args[key] = variables.get(key, "")
return self._template.format_map(fmt_args)
class FastXMLParser:
"""
Pre-compiled regex XML parser for the 4ms hot path.
Compiles regex patterns at init time from the known variable structure,
then parses incoming XML via targeted regex extraction (no DOM construction).
~3-5x faster than ElementTree for fixed-schema RSI messages.
"""
def __init__(self, variables: dict) -> None:
"""
Compile regex patterns from the variable structure.
Args:
variables: Dict of expected variable names values/dicts
"""
self._dict_patterns: List[Tuple[str, re.Pattern, List[str]]] = []
self._scalar_patterns: List[Tuple[str, re.Pattern]] = []
for key, value in variables.items():
if isinstance(value, dict):
subkeys = list(value.keys())
attr_pattern = r"\s+".join(
rf'{sk}="([^"]*)"' for sk in subkeys
)
# Also match any order with a looser pattern as fallback
pattern = re.compile(rf"<{re.escape(key)}\s+{attr_pattern}")
self._dict_patterns.append((key, pattern, subkeys))
else:
pattern = re.compile(rf"<{re.escape(key)}>([^<]*)</{re.escape(key)}>")
self._scalar_patterns.append((key, pattern))
# Always parse IPOC specifically
self._ipoc_pattern = re.compile(r"<IPOC>(\d+)</IPOC>")
def parse(self, xml_string: str, target: dict) -> None:
"""
Parse XML string into target dict using pre-compiled patterns.
Args:
xml_string: Raw XML string from robot
target: Dict to update with parsed values (modified in-place)
"""
# Parse dict-type elements (attributes)
for key, pattern, subkeys in self._dict_patterns:
if key not in target:
continue
match = pattern.search(xml_string)
if match:
existing = target.get(key)
if isinstance(existing, dict):
for i, sk in enumerate(subkeys):
existing[sk] = float(match.group(i + 1))
else:
target[key] = {sk: float(match.group(i + 1)) for i, sk in enumerate(subkeys)}
# Parse scalar elements (text content)
for key, pattern in self._scalar_patterns:
if key not in target:
continue
match = pattern.search(xml_string)
if match:
target[key] = match.group(1)
# IPOC always parsed as int
ipoc_match = self._ipoc_pattern.search(xml_string)
if ipoc_match:
target["IPOC"] = int(ipoc_match.group(1))

615
test_server.py Normal file
View File

@ -0,0 +1,615 @@
"""
RSI TestServer - Python equivalent of KUKA's RSI TestServer.exe
Simulates the robot side of an RSI connection. Listens on UDP for <Sen> XML
from a client, and responds with <Rob> XML containing the current robot state.
Usage:
python test_server.py
python test_server.py --config path/to/RSI_EthernetConfig.xml
"""
import socket
import threading
import tkinter as tk
from tkinter import messagebox
import xml.etree.ElementTree as ET
import argparse
import copy
import sys
import os
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from RSIPI.config_parser import ConfigParser
from RSIPI.xml_handler import XMLGenerator
# --- Theme colours ---
BG_DARK = "#1e1e2e"
BG_MID = "#282840"
BG_CARD = "#313150"
BG_INPUT = "#3b3b5c"
FG = "#cdd6f4"
FG_DIM = "#7f849c"
FG_BRIGHT = "#ffffff"
ACCENT = "#f5a623"
ACCENT_HOVER = "#f7bc5e"
GREEN = "#a6e3a1"
RED = "#f38ba8"
BLUE = "#89b4fa"
BORDER = "#45475a"
AXIS_COLOURS = {
"X": "#f38ba8", "Y": "#a6e3a1", "Z": "#89b4fa",
"A": "#fab387", "B": "#cba6f7", "C": "#94e2d5",
}
class RSITestServer:
"""UDP server simulating a KUKA robot controller for RSI testing."""
def __init__(self, config_file: str):
self.config = ConfigParser(config_file)
self.network_settings = self.config.get_network_settings()
self.port = self.network_settings["port"]
self.robot_state = copy.deepcopy(self.config.send_variables)
self.receive_template = copy.deepcopy(self.config.receive_variables)
self.ipoc = 0
self.correction_step = 0.01
self.running = False
self.receive_only = False
self.udp_socket = None
self.listen_thread = None
self.client_address = None
self.last_received_xml = ""
self.last_sent_xml = ""
self.packet_count = 0
self.lock = threading.Lock()
def start(self):
if self.running:
return
try:
self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.udp_socket.bind(("0.0.0.0", self.port))
self.udp_socket.settimeout(0.5)
self.running = True
self.packet_count = 0
self.listen_thread = threading.Thread(target=self._listen_loop, daemon=True)
self.listen_thread.start()
return True
except OSError as e:
self.running = False
return str(e)
def stop(self):
self.running = False
if self.listen_thread:
self.listen_thread.join(timeout=2)
if self.udp_socket:
self.udp_socket.close()
self.udp_socket = None
self.client_address = None
def _listen_loop(self):
while self.running:
try:
data, addr = self.udp_socket.recvfrom(4096)
self.client_address = addr
xml_string = data.decode("utf-8", errors="replace")
with self.lock:
self.last_received_xml = xml_string
self._process_received(xml_string)
self.packet_count += 1
if not self.receive_only:
response = self._generate_response()
self.last_sent_xml = response
self.udp_socket.sendto(response.encode("utf-8"), addr)
except socket.timeout:
continue
except OSError:
break
except Exception as e:
if self.running:
print(f"[TestServer] Error: {e}")
def _process_received(self, xml_string: str):
try:
root = ET.fromstring(xml_string)
ipoc_elem = root.find("IPOC")
if ipoc_elem is not None and ipoc_elem.text:
self.ipoc = int(ipoc_elem.text.strip())
except ET.ParseError:
self.ipoc = self._extract_ipoc_string(xml_string)
def _extract_ipoc_string(self, xml_string: str) -> int:
end_tag = "</IPOC>"
start_tag = "<IPOC>"
end_idx = xml_string.find(end_tag)
if end_idx == -1:
return self.ipoc
start_idx = xml_string.find(start_tag)
if start_idx == -1:
return self.ipoc
try:
return int(xml_string[start_idx + len(start_tag):end_idx].strip())
except ValueError:
return self.ipoc
def _generate_response(self) -> str:
state = copy.deepcopy(self.robot_state)
state["IPOC"] = self.ipoc
return XMLGenerator.generate_receive_xml(state)
def adjust_position(self, axis: str, delta: float):
with self.lock:
if "RIst" in self.robot_state and axis in self.robot_state["RIst"]:
self.robot_state["RIst"][axis] += delta
def reset_positions(self):
with self.lock:
if "RIst" in self.robot_state:
for axis in self.robot_state["RIst"]:
self.robot_state["RIst"][axis] = 0.0
class TestServerGUI:
"""Dark-themed tkinter GUI for the RSI TestServer."""
def __init__(self, config_file: str):
self.server = RSITestServer(config_file)
self._prev_packet_count = 0
self._prev_time = time.time()
self._packets_per_sec = 0.0
self.recv_text: tk.Text = None # type: ignore[assignment]
self.sent_text: tk.Text = None # type: ignore[assignment]
self.root = tk.Tk()
self.root.title("RSI TestServer")
self.root.configure(bg=BG_DARK)
self.root.geometry("1100x750")
self.root.resizable(True, True)
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self._build_ui()
self._update_display()
# ── helpers ──────────────────────────────────────────────────────
def _card(self, parent, **kw):
f = tk.Frame(parent, bg=BG_CARD, highlightbackground=BORDER,
highlightthickness=1, **kw)
return f
def _label(self, parent, text="", font=("Segoe UI", 10), fg=FG, **kw):
return tk.Label(parent, text=text, font=font, fg=fg, bg=parent["bg"], **kw)
def _btn(self, parent, text, command, width=4, accent=False, **kw):
bg = ACCENT if accent else BG_INPUT
fg_c = BG_DARK if accent else FG
hover = ACCENT_HOVER if accent else "#4e4e7a"
b = tk.Button(parent, text=text, command=command, width=width,
font=("Segoe UI", 10, "bold"), fg=fg_c, bg=bg,
activeforeground=fg_c, activebackground=hover,
relief="flat", cursor="hand2", bd=0,
highlightthickness=0, **kw)
b.bind("<Enter>", lambda e, b=b, h=hover: b.config(bg=h))
b.bind("<Leave>", lambda e, b=b, n=bg: b.config(bg=n))
return b
# ── layout ───────────────────────────────────────────────────────
def _build_ui(self):
# ── Header ──
header = tk.Frame(self.root, bg=BG_MID, height=56)
header.grid(row=0, column=0, sticky="ew")
header.grid_columnconfigure(1, weight=1)
self._label(header, "RSI TestServer",
font=("Segoe UI", 16, "bold"), fg=ACCENT).grid(
row=0, column=0, padx=16, pady=10, sticky="w")
self._label(header, f"Port {self.server.port}",
font=("Segoe UI Semibold", 11), fg=FG_DIM).grid(
row=0, column=1, sticky="w")
self.status_dot = tk.Canvas(header, width=14, height=14,
bg=BG_MID, highlightthickness=0)
self.status_dot.create_oval(2, 2, 12, 12, fill=RED, outline="", tags="dot")
self.status_dot.grid(row=0, column=2, padx=(0, 4))
self.status_label = self._label(header, "Offline",
font=("Segoe UI Semibold", 11), fg=RED)
self.status_label.grid(row=0, column=3, padx=(0, 16))
# ── Body ──
body = tk.Frame(self.root, bg=BG_DARK)
body.grid(row=1, column=0, sticky="nsew", padx=12, pady=8)
body.grid_columnconfigure(1, weight=1)
# ── Left column: controls + jog ──
left = tk.Frame(body, bg=BG_DARK)
left.grid(row=0, column=0, sticky="n", padx=(0, 8))
# Controls card
ctrl = self._card(left)
ctrl.grid(row=0, column=0, sticky="ew", pady=(0, 8))
btn_row = tk.Frame(ctrl, bg=BG_CARD)
btn_row.grid(row=0, column=0, padx=10, pady=10, sticky="ew")
self.start_btn = self._btn(btn_row, "Start", self._start,
width=8, accent=True)
self.start_btn.grid(row=0, column=0, padx=(0, 4))
self.stop_btn = self._btn(btn_row, "Stop", self._stop, width=8)
self.stop_btn.config(state="disabled")
self.stop_btn.grid(row=0, column=1, padx=(0, 12))
self.mode_var = tk.StringVar(value="Send + Recv")
mode_btn = tk.Menubutton(btn_row, textvariable=self.mode_var,
font=("Segoe UI", 9), fg=FG, bg=BG_INPUT,
activeforeground=FG, activebackground="#4e4e7a",
relief="flat", highlightthickness=0,
indicatoron=True, cursor="hand2", width=12)
mode_menu = tk.Menu(mode_btn, tearoff=0, bg=BG_INPUT, fg=FG,
activebackground=ACCENT, activeforeground=BG_DARK)
mode_menu.add_command(label="Send + Recv",
command=lambda: self._set_mode("Send + Recv"))
mode_menu.add_command(label="Recv only",
command=lambda: self._set_mode("Recv only"))
mode_btn["menu"] = mode_menu
mode_btn.grid(row=0, column=2, padx=(0, 8))
self._label(btn_row, "Step:", font=("Segoe UI", 9), fg=FG_DIM).grid(
row=0, column=3)
self.step_var = tk.StringVar(value="0.01")
step_e = tk.Entry(btn_row, textvariable=self.step_var, width=7,
font=("Consolas", 10), fg=ACCENT, bg=BG_INPUT,
insertbackground=ACCENT, relief="flat",
highlightthickness=1, highlightcolor=ACCENT,
highlightbackground=BORDER)
step_e.grid(row=0, column=4, padx=4)
step_e.bind("<Return>", self._step_changed)
step_e.bind("<FocusOut>", self._step_changed)
# Jog card
jog = self._card(left)
jog.grid(row=1, column=0, sticky="ew")
jog_title = tk.Frame(jog, bg=BG_CARD)
jog_title.grid(row=0, column=0, columnspan=4, sticky="ew", padx=10,
pady=(8, 4))
self._label(jog_title, "Jog Control",
font=("Segoe UI Semibold", 11), fg=FG_BRIGHT).pack(
side="left")
axes = ["X", "Y", "Z", "A", "B", "C"]
self.pos_labels = {}
for i, axis in enumerate(axes):
row_frame = tk.Frame(jog, bg=BG_CARD)
row_frame.grid(row=i + 1, column=0, sticky="ew", padx=10, pady=2)
row_frame.grid_columnconfigure(1, weight=1)
colour = AXIS_COLOURS[axis]
# Colour pip
pip = tk.Canvas(row_frame, width=8, height=8,
bg=BG_CARD, highlightthickness=0)
pip.create_oval(1, 1, 7, 7, fill=colour, outline="")
pip.grid(row=0, column=0, padx=(0, 6))
self._label(row_frame, f"{axis}",
font=("Segoe UI Semibold", 11), fg=colour).grid(
row=0, column=1, sticky="w")
self.pos_labels[axis] = self._label(
row_frame, "0.0000",
font=("Consolas", 12), fg=FG_BRIGHT)
self.pos_labels[axis].grid(row=0, column=2, padx=8)
self._btn(row_frame, "+",
lambda a=axis: self._adjust(a, 1), width=3).grid(
row=0, column=3, padx=1)
self._btn(row_frame, "-",
lambda a=axis: self._adjust(a, -1), width=3).grid(
row=0, column=4, padx=1)
reset_row = tk.Frame(jog, bg=BG_CARD)
reset_row.grid(row=len(axes) + 1, column=0, sticky="ew",
padx=10, pady=(6, 10))
self._btn(reset_row, "Reset All", self._reset,
width=36, accent=False).pack(fill="x")
# ── Right column: state + xml ──
right = tk.Frame(body, bg=BG_DARK)
right.grid(row=0, column=1, sticky="nsew")
right.grid_rowconfigure(1, weight=1)
# Info bar
info = self._card(right)
info.grid(row=0, column=0, sticky="ew", pady=(0, 8))
info_inner = tk.Frame(info, bg=BG_CARD)
info_inner.grid(padx=10, pady=8, sticky="ew")
info.grid_columnconfigure(0, weight=1)
info_inner.grid_columnconfigure(1, weight=1)
info_inner.grid_columnconfigure(3, weight=1)
self._label(info_inner, "IPOC", font=("Segoe UI", 9), fg=FG_DIM).grid(
row=0, column=0, sticky="w")
self.ipoc_val = self._label(info_inner, "0",
font=("Consolas", 12, "bold"), fg=BLUE)
self.ipoc_val.grid(row=1, column=0, sticky="w")
self._label(info_inner, "Client", font=("Segoe UI", 9), fg=FG_DIM).grid(
row=0, column=1, sticky="w", padx=(20, 0))
self.client_val = self._label(info_inner, "--",
font=("Consolas", 11), fg=FG)
self.client_val.grid(row=1, column=1, sticky="w", padx=(20, 0))
self._label(info_inner, "Packets/s", font=("Segoe UI", 9), fg=FG_DIM).grid(
row=0, column=2, sticky="w", padx=(20, 0))
self.pps_val = self._label(info_inner, "0",
font=("Consolas", 12, "bold"), fg=GREEN)
self.pps_val.grid(row=1, column=2, sticky="w", padx=(20, 0))
# Robot state card — grid layout
state_card = self._card(right)
state_card.grid(row=1, column=0, sticky="nsew", pady=(0, 8))
state_hdr = tk.Frame(state_card, bg=BG_CARD)
state_hdr.grid(row=0, column=0, sticky="ew", padx=10, pady=(8, 4))
self._label(state_hdr, "Robot State",
font=("Segoe UI Semibold", 11), fg=FG_BRIGHT).pack(
side="left")
# Scrollable grid area
state_outer = tk.Frame(state_card, bg=BG_MID)
state_outer.grid(row=1, column=0, sticky="nsew", padx=8, pady=(0, 8))
state_card.grid_rowconfigure(1, weight=1)
state_canvas = tk.Canvas(state_outer, bg=BG_MID, highlightthickness=0,
height=280)
state_canvas.pack(side="left", fill="both", expand=True)
state_scroll = tk.Scrollbar(state_outer, orient="vertical",
command=state_canvas.yview,
bg=BG_MID, troughcolor=BG_MID)
state_scroll.pack(side="right", fill="y")
state_canvas.configure(yscrollcommand=state_scroll.set)
self._state_grid = tk.Frame(state_canvas, bg=BG_MID)
state_canvas.create_window((0, 0), window=self._state_grid, anchor="nw")
self._state_canvas = state_canvas
# Build grid from robot_state
self._state_cells = {} # (group, subkey) -> Label
self._build_state_grid()
# XML card
xml_card = self._card(right)
xml_card.grid(row=2, column=0, sticky="nsew")
xml_hdr = tk.Frame(xml_card, bg=BG_CARD)
xml_hdr.grid(row=0, column=0, columnspan=2, sticky="ew", padx=10,
pady=(8, 4))
self._label(xml_hdr, "XML Traffic",
font=("Segoe UI Semibold", 11), fg=FG_BRIGHT).pack(
side="left")
xml_card.grid_columnconfigure(1, weight=1)
for i, (lbl, attr) in enumerate([("RX", "recv_text"), ("TX", "sent_text")]):
tag_bg = GREEN if lbl == "RX" else BLUE
tag = tk.Label(xml_card, text=f" {lbl} ", font=("Consolas", 9, "bold"),
fg=BG_DARK, bg=tag_bg)
tag.grid(row=i + 1, column=0, sticky="nw", padx=(10, 4), pady=2)
t = tk.Text(xml_card, width=60, height=6, font=("Consolas", 9),
fg=FG_DIM, bg=BG_MID, relief="flat", bd=0,
padx=6, pady=4, wrap="word", state="disabled")
t.grid(row=i + 1, column=1, sticky="nsew", padx=(0, 8), pady=2)
setattr(self, attr, t)
# bottom pad
tk.Frame(xml_card, bg=BG_CARD, height=8).grid(
row=3, column=0, columnspan=2)
# ── state grid builder ────────────────────────────────────────────
def _build_state_grid(self):
"""Build the header row and value cells from robot_state structure."""
grid = self._state_grid
row = 0
for key, value in self.server.robot_state.items():
if isinstance(value, dict):
# Group header
self._label(grid, key, font=("Segoe UI Semibold", 9),
fg=ACCENT).grid(row=row, column=0, sticky="w",
padx=(8, 4), pady=(6, 0))
row += 1
# Sub-key headers + value cells in columns
subkeys = list(value.keys())
# Chunk into rows of 6 for wide dicts like Tech
chunk_size = 6
for chunk_start in range(0, len(subkeys), chunk_size):
chunk = subkeys[chunk_start:chunk_start + chunk_size]
for col, sk in enumerate(chunk):
# Header
self._label(grid, sk, font=("Consolas", 8),
fg=FG_DIM).grid(
row=row, column=col + 1, padx=4, sticky="e")
row += 1
for col, sk in enumerate(chunk):
# Value cell
cell = self._label(grid, "0.0000",
font=("Consolas", 10), fg=FG_BRIGHT)
cell.grid(row=row, column=col + 1, padx=4, sticky="e")
self._state_cells[(key, sk)] = cell
row += 1
else:
# Scalar value
self._label(grid, key, font=("Segoe UI Semibold", 9),
fg=ACCENT).grid(row=row, column=0, sticky="w",
padx=(8, 4), pady=(6, 0))
cell = self._label(grid, str(value),
font=("Consolas", 10), fg=FG_BRIGHT)
cell.grid(row=row, column=1, padx=4, sticky="e")
self._state_cells[(key, None)] = cell
row += 1
# Update scroll region after grid is built
grid.update_idletasks()
self._state_canvas.configure(scrollregion=self._state_canvas.bbox("all"))
def _update_state_grid(self):
"""Update all cell values from current robot_state."""
for key, value in self.server.robot_state.items():
if isinstance(value, dict):
for sk, sv in value.items():
cell = self._state_cells.get((key, sk))
if cell:
try:
cell.config(text=f"{float(sv):>8.4f}")
except (ValueError, TypeError):
cell.config(text=str(sv))
else:
cell = self._state_cells.get((key, None))
if cell:
try:
cell.config(text=f"{float(value):>8.4f}")
except (ValueError, TypeError):
cell.config(text=str(value))
# ── actions ──────────────────────────────────────────────────────
def _start(self):
result = self.server.start()
if result is True:
self.status_label.config(text="Online", fg=GREEN)
self.status_dot.itemconfig("dot", fill=GREEN)
self.start_btn.config(state="disabled", bg=BG_INPUT, cursor="arrow")
self.stop_btn.config(state="normal", cursor="hand2")
self._prev_packet_count = 0
self._prev_time = time.time()
else:
messagebox.showerror("Error",
f"Port {self.server.port} is already in use.\n{result}")
def _stop(self):
self.server.stop()
self.status_label.config(text="Offline", fg=RED)
self.status_dot.itemconfig("dot", fill=RED)
self.client_val.config(text="--")
self.pps_val.config(text="0")
self.start_btn.config(state="normal", bg=ACCENT, cursor="hand2")
self.stop_btn.config(state="disabled", cursor="arrow")
def _set_mode(self, mode):
self.mode_var.set(mode)
self.server.receive_only = (mode == "Recv only")
def _step_changed(self, event=None):
try:
self.server.correction_step = float(self.step_var.get())
except ValueError:
self.step_var.set(str(self.server.correction_step))
def _adjust(self, axis: str, direction: int):
self.server.adjust_position(axis, direction * self.server.correction_step)
def _reset(self):
self.server.reset_positions()
# ── display loop ─────────────────────────────────────────────────
def _update_display(self):
with self.server.lock:
# Position values
rist = self.server.robot_state.get("RIst", {})
for axis, label in self.pos_labels.items():
val = rist.get(axis, 0.0)
label.config(text=f"{val:+.4f}")
# IPOC
self.ipoc_val.config(text=str(self.server.ipoc))
# Client
if self.server.client_address:
self.client_val.config(
text=f"{self.server.client_address[0]}:{self.server.client_address[1]}")
# Packets/sec
now = time.time()
dt = now - self._prev_time
if dt >= 1.0:
delta = self.server.packet_count - self._prev_packet_count
self._packets_per_sec = delta / dt
self._prev_packet_count = self.server.packet_count
self._prev_time = now
self.pps_val.config(text=f"{self._packets_per_sec:.0f}")
# Robot state grid
self._update_state_grid()
# XML traffic (pretty-printed)
self._set_xml(self.recv_text, self.server.last_received_xml)
self._set_xml(self.sent_text, self.server.last_sent_xml)
self.root.after(100, self._update_display)
@staticmethod
def _set_text(widget, text):
widget.config(state="normal")
widget.delete("1.0", "end")
widget.insert("1.0", text)
widget.config(state="disabled")
@staticmethod
def _set_xml(widget, xml_string):
"""Pretty-print XML into a text widget."""
formatted = xml_string
if xml_string.strip():
try:
root = ET.fromstring(xml_string)
ET.indent(root)
formatted = ET.tostring(root, encoding="unicode")
except ET.ParseError:
pass
widget.config(state="normal")
widget.delete("1.0", "end")
widget.insert("1.0", formatted)
widget.config(state="disabled")
def _on_close(self):
self.server.stop()
self.root.destroy()
def run(self):
self.root.mainloop()
def main():
parser = argparse.ArgumentParser(description="RSI TestServer - KUKA Robot Simulator")
parser.add_argument(
"--config", type=str, default="RSI_EthernetConfig.xml",
help="Path to RSI_EthernetConfig.xml"
)
args = parser.parse_args()
if not os.path.exists(args.config):
print(f"Error: Config file not found: {args.config}")
sys.exit(1)
app = TestServerGUI(args.config)
app.run()
if __name__ == "__main__":
main()

261
tests/test_config_parser.py Normal file
View File

@ -0,0 +1,261 @@
"""Tests for ConfigParser."""
import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from RSIPI.config_parser import ConfigParser
# Path to the real config file in the project root
CONFIG_FILE = os.path.join(os.path.dirname(__file__), '..', 'RSI_EthernetConfig.xml')
@pytest.fixture
def parser():
"""Create a ConfigParser from the real config file."""
return ConfigParser(CONFIG_FILE)
class TestParseWithoutError:
"""Verify that the config file parses successfully."""
def test_parser_initializes(self, parser):
"""ConfigParser should initialize without raising."""
assert parser is not None
def test_send_variables_is_dict(self, parser):
"""send_variables should be a dict after parsing."""
assert isinstance(parser.send_variables, dict)
def test_receive_variables_is_dict(self, parser):
"""receive_variables should be a dict after parsing."""
assert isinstance(parser.receive_variables, dict)
class TestNetworkSettings:
"""Verify network_settings extraction from <CONFIG>."""
def test_has_ip(self, parser):
"""network_settings must contain 'ip'."""
assert "ip" in parser.network_settings
assert parser.network_settings["ip"] == "10.10.10.10"
def test_has_port(self, parser):
"""network_settings must contain 'port' as int."""
assert "port" in parser.network_settings
assert parser.network_settings["port"] == 64000
assert isinstance(parser.network_settings["port"], int)
def test_has_sentype(self, parser):
"""network_settings must contain 'sentype'."""
assert "sentype" in parser.network_settings
assert parser.network_settings["sentype"] == "ImFree"
def test_has_onlysend(self, parser):
"""network_settings must contain 'onlysend' as bool."""
assert "onlysend" in parser.network_settings
assert parser.network_settings["onlysend"] is False
def test_get_network_settings_matches(self, parser):
"""get_network_settings() should return the same dict."""
assert parser.get_network_settings() == parser.network_settings
class TestSendVariables:
"""Verify send_variables contains expected keys from the config file."""
def test_contains_rist(self, parser):
"""RIst (robot actual position) should be in send_variables."""
assert "RIst" in parser.send_variables
def test_rist_has_cartesian_keys(self, parser):
"""RIst should expand to X, Y, Z, A, B, C sub-keys."""
rist = parser.send_variables["RIst"]
assert isinstance(rist, dict)
for axis in ["X", "Y", "Z", "A", "B", "C"]:
assert axis in rist
def test_contains_rsol(self, parser):
"""RSol (robot commanded position) should be in send_variables."""
assert "RSol" in parser.send_variables
def test_contains_ipoc(self, parser):
"""IPOC timestamp should always be in send_variables."""
assert "IPOC" in parser.send_variables
def test_contains_delay(self, parser):
"""Delay should be in send_variables."""
assert "Delay" in parser.send_variables
def test_contains_digout(self, parser):
"""Digout group should be parsed from dotted tags."""
assert "Digout" in parser.send_variables
def test_digout_has_channels(self, parser):
"""Digout should contain o1, o2, o3 sub-keys from the config."""
digout = parser.send_variables["Digout"]
assert isinstance(digout, dict)
for ch in ["o1", "o2", "o3"]:
assert ch in digout
def test_contains_source1(self, parser):
"""Source1 should be in send_variables as a DOUBLE default."""
assert "Source1" in parser.send_variables
assert parser.send_variables["Source1"] == 0.0
def test_contains_dil(self, parser):
"""DiL should be in send_variables as a LONG default."""
assert "DiL" in parser.send_variables
assert parser.send_variables["DiL"] == 0
class TestReceiveVariables:
"""Verify receive_variables contains expected keys."""
def test_contains_rkorr(self, parser):
"""RKorr (correction values) should be in receive_variables."""
assert "RKorr" in parser.receive_variables
def test_rkorr_has_cartesian_keys(self, parser):
"""RKorr should have X, Y, Z, A, B, C from individual tags."""
rkorr = parser.receive_variables["RKorr"]
assert isinstance(rkorr, dict)
for axis in ["X", "Y", "Z", "A", "B", "C"]:
assert axis in rkorr
def test_contains_estr(self, parser):
"""EStr should be in receive_variables."""
assert "EStr" in parser.receive_variables
def test_contains_free(self, parser):
"""FREE should be in receive_variables."""
assert "FREE" in parser.receive_variables
def test_contains_dio(self, parser):
"""DiO should be in receive_variables."""
assert "DiO" in parser.receive_variables
class TestDefPrefixStripping:
"""Verify that DEF_ prefix is stripped from tags."""
def test_rist_not_def_rist(self, parser):
"""Key should be 'RIst', not 'DEF_RIst'."""
assert "DEF_RIst" not in parser.send_variables
assert "RIst" in parser.send_variables
def test_rsol_not_def_rsol(self, parser):
"""Key should be 'RSol', not 'DEF_RSol'."""
assert "DEF_RSol" not in parser.send_variables
assert "RSol" in parser.send_variables
def test_estr_not_def_estr(self, parser):
"""Key should be 'EStr', not 'DEF_EStr'."""
assert "DEF_EStr" not in parser.receive_variables
assert "EStr" in parser.receive_variables
def test_tech_not_def_tech(self, parser):
"""Tech keys should not retain DEF_ prefix."""
for key in parser.send_variables:
assert not key.startswith("DEF_")
for key in parser.receive_variables:
assert not key.startswith("DEF_")
class TestIPOCAlwaysPresent:
"""IPOC must always be in send_variables, even if not in config."""
def test_ipoc_present(self, parser):
"""IPOC should be in send_variables."""
assert "IPOC" in parser.send_variables
def test_ipoc_forced_when_missing(self, tmp_path):
"""IPOC should be injected even if the config has no DEF_IPOC."""
# Create a minimal config with no IPOC element
minimal_xml = tmp_path / "minimal.xml"
minimal_xml.write_text("""<ROOT>
<CONFIG>
<IP_NUMBER>127.0.0.1</IP_NUMBER>
<PORT>12345</PORT>
<SENTYPE>Test</SENTYPE>
<ONLYSEND>FALSE</ONLYSEND>
</CONFIG>
<SEND><ELEMENTS>
<ELEMENT TAG="DEF_RIst" TYPE="DOUBLE" INDX="INTERNAL" />
</ELEMENTS></SEND>
<RECEIVE><ELEMENTS>
<ELEMENT TAG="RKorr.X" TYPE="DOUBLE" INDX="1" />
</ELEMENTS></RECEIVE>
</ROOT>""")
p = ConfigParser(str(minimal_xml))
assert "IPOC" in p.send_variables
class TestTechKeyMerging:
"""Verify that Tech.CX and Tech.TX keys merge into a single 'Tech' dict."""
def test_tech_merged_in_send(self, parser):
"""Tech.C1 in send config should merge into a single 'Tech' key."""
assert "Tech" in parser.send_variables
# Should not have the dotted form
for key in parser.send_variables:
assert not key.startswith("Tech.")
def test_tech_merged_in_receive(self, parser):
"""Tech.T2 in receive config should merge into a single 'Tech' key."""
assert "Tech" in parser.receive_variables
for key in parser.receive_variables:
assert not key.startswith("Tech.")
def test_tech_contains_sub_keys(self, parser):
"""Merged Tech dict should contain the sub-keys from the internal structure."""
tech_send = parser.send_variables["Tech"]
assert isinstance(tech_send, dict)
# Tech.C1 should have expanded to C11, C12, ... C110
assert "C11" in tech_send
def test_tech_receive_contains_sub_keys(self, parser):
"""Tech in receive should have T2x keys from Tech.T2."""
tech_recv = parser.receive_variables["Tech"]
assert isinstance(tech_recv, dict)
assert "T21" in tech_recv
class TestGetDefaultValue:
"""Test the static get_default_value helper."""
def test_bool_default(self):
assert ConfigParser.get_default_value("BOOL") is False
def test_string_default(self):
assert ConfigParser.get_default_value("STRING") == ""
def test_long_default(self):
assert ConfigParser.get_default_value("LONG") == 0
def test_double_default(self):
assert ConfigParser.get_default_value("DOUBLE") == 0.0
def test_unknown_default(self):
assert ConfigParser.get_default_value("UNKNOWN") is None
class TestRenameTechKeys:
"""Test the static rename_tech_keys helper."""
def test_merges_tech_keys(self):
d = {"Tech.C1": {"C11": 0}, "Tech.T2": {"T21": 0}, "Other": 5}
ConfigParser.rename_tech_keys(d)
assert "Tech" in d
assert "Tech.C1" not in d
assert "Tech.T2" not in d
assert "C11" in d["Tech"]
assert "T21" in d["Tech"]
assert d["Other"] == 5
def test_no_tech_keys_leaves_dict_unchanged(self):
d = {"RIst": {"X": 0}, "IPOC": 0}
ConfigParser.rename_tech_keys(d)
assert "Tech" not in d
assert "RIst" in d

184
tests/test_io_api.py Normal file
View File

@ -0,0 +1,184 @@
"""Tests for IOAPI."""
import pytest
import sys
import os
from unittest.mock import MagicMock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from RSIPI.io_api import IOAPI
from RSIPI.exceptions import RSIVariableError
def _make_mock_client(send_vars=None, receive_vars=None):
"""
Build a mock RSIClient with plain dict send/receive variables
and a safety_manager that passes everything through.
"""
client = MagicMock()
client.send_variables = send_vars if send_vars is not None else {}
client.receive_variables = receive_vars if receive_vars is not None else {}
# Safety manager: validate returns the value unchanged
client.safety_manager.validate.side_effect = lambda path, val: val
return client
@pytest.fixture
def io_with_digout():
"""IOAPI with Digout in receive_variables and Digin in send_variables."""
send = {
"Digin": {"i1": 1, "i2": 0, "i3": 1},
"IPOC": 12345,
}
recv = {
"Digout": {"o1": 0, "o2": 0, "o3": 0},
"DiO": 0,
}
client = _make_mock_client(send_vars=send, receive_vars=recv)
return IOAPI(client)
# ---------------------------------------------------------------------------
# toggle
# ---------------------------------------------------------------------------
class TestToggle:
"""Tests for IOAPI.toggle()."""
def test_toggle_sets_value_via_update_variable(self, io_with_digout):
"""toggle should call _tools.update_variable with group.name and int state."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "Updated Digout.o1 to 1"
result = io.toggle("Digout", "o1", True)
io._tools.update_variable.assert_called_once_with("Digout.o1", 1)
assert result == "Updated Digout.o1 to 1"
def test_toggle_false_sends_zero(self, io_with_digout):
"""toggle with False should send 0."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "Updated Digout.o2 to 0"
io.toggle("Digout", "o2", False)
io._tools.update_variable.assert_called_once_with("Digout.o2", 0)
def test_toggle_int_one(self, io_with_digout):
"""toggle with int 1 should send 1."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "Updated Digout.o3 to 1"
io.toggle("Digout", "o3", 1)
io._tools.update_variable.assert_called_once_with("Digout.o3", 1)
def test_toggle_truthy_value_normalizes_to_one(self, io_with_digout):
"""Any truthy value should normalize to 1."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "ok"
io.toggle("Digout", "o1", 42)
io._tools.update_variable.assert_called_once_with("Digout.o1", 1)
# ---------------------------------------------------------------------------
# set_output
# ---------------------------------------------------------------------------
class TestSetOutput:
"""Tests for IOAPI.set_output()."""
def test_formats_channel_name(self, io_with_digout):
"""set_output(3, True) should call toggle with 'o3'."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "Updated Digout.o3 to 1"
io.set_output(3, True)
io._tools.update_variable.assert_called_once_with("Digout.o3", 1)
def test_custom_group(self, io_with_digout):
"""set_output with custom group should use that group."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "Updated DiO.o5 to 1"
io.set_output(5, True, group="DiO")
io._tools.update_variable.assert_called_once_with("DiO.o5", 1)
def test_set_output_off(self, io_with_digout):
"""set_output with False should send 0."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "Updated Digout.o1 to 0"
io.set_output(1, False)
io._tools.update_variable.assert_called_once_with("Digout.o1", 0)
# ---------------------------------------------------------------------------
# get_input
# ---------------------------------------------------------------------------
class TestGetInput:
"""Tests for IOAPI.get_input()."""
def test_reads_true_from_send_variables(self, io_with_digout):
"""get_input should read from send_variables (what robot sends us)."""
result = io_with_digout.get_input(1, group="Digin")
assert result is True # i1 = 1
def test_reads_false_from_send_variables(self, io_with_digout):
"""get_input for channel with value 0 should return False."""
result = io_with_digout.get_input(2, group="Digin")
assert result is False # i2 = 0
def test_missing_group_raises(self, io_with_digout):
"""get_input with non-existent group should raise RSIVariableError."""
with pytest.raises(RSIVariableError, match="not found in send_variables"):
io_with_digout.get_input(1, group="NonExistent")
def test_missing_channel_raises(self, io_with_digout):
"""get_input with non-existent channel should raise RSIVariableError."""
with pytest.raises(RSIVariableError, match="not found in group"):
io_with_digout.get_input(99, group="Digin")
def test_default_group_is_digin(self):
"""Default group should be 'Digin'."""
send = {"Digin": {"i1": 1}}
client = _make_mock_client(send_vars=send)
io = IOAPI(client)
assert io.get_input(1) is True
# ---------------------------------------------------------------------------
# pulse (basic structure test, not timing)
# ---------------------------------------------------------------------------
class TestPulse:
"""Tests for IOAPI.pulse()."""
def test_pulse_calls_on_then_off(self, io_with_digout):
"""pulse should turn output on then off."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "ok"
result = io.pulse(1, duration=0.0) # zero duration for fast test
calls = io._tools.update_variable.call_args_list
# First call: ON (value=1), second call: OFF (value=0)
assert calls[0].args == ("Digout.o1", 1)
assert calls[1].args == ("Digout.o1", 0)
assert "Pulse completed" in result
def test_pulse_returns_message_with_duration(self, io_with_digout):
"""Return string should include the duration."""
io = io_with_digout
io._tools = MagicMock()
io._tools.update_variable.return_value = "ok"
result = io.pulse(2, duration=0.0)
assert "0.0s" in result
assert "Digout.o2" in result

439
tests/test_motion_api.py Normal file
View File

@ -0,0 +1,439 @@
"""Tests for MotionAPI static/pure methods."""
import pytest
import math
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from RSIPI.motion_api import (
MotionAPI,
_calculate_distance,
_trapezoidal_profile,
_s_curve_profile,
_cubic_blend,
_find_blend_point,
)
# ---------------------------------------------------------------------------
# generate_arc
# ---------------------------------------------------------------------------
class TestGenerateArc:
"""Tests for MotionAPI.generate_arc()."""
def test_basic_arc_length(self):
"""Arc with 50 steps should return 50 waypoints."""
arc = MotionAPI.generate_arc(
center={"X": 0, "Y": 0, "Z": 0},
radius=10.0,
start_angle=0,
end_angle=90,
steps=50,
)
assert len(arc) == 50
def test_first_point_on_circle(self):
"""First point should be at start_angle on the circle."""
arc = MotionAPI.generate_arc(
center={"X": 100, "Y": 0, "Z": 0},
radius=50.0,
start_angle=0,
end_angle=90,
steps=10,
)
assert arc[0]["X"] == pytest.approx(150.0)
assert arc[0]["Y"] == pytest.approx(0.0)
def test_last_point_at_end_angle(self):
"""Last point should be at end_angle on the circle."""
arc = MotionAPI.generate_arc(
center={"X": 0, "Y": 0, "Z": 0},
radius=10.0,
start_angle=0,
end_angle=90,
steps=10,
)
assert arc[-1]["X"] == pytest.approx(10.0 * math.cos(math.radians(90)))
assert arc[-1]["Y"] == pytest.approx(10.0 * math.sin(math.radians(90)))
def test_xz_plane(self):
"""Arc in XZ plane should vary X and Z, keep Y constant."""
arc = MotionAPI.generate_arc(
center={"X": 0, "Y": 5, "Z": 0},
radius=10.0,
start_angle=0,
end_angle=90,
steps=5,
plane="XZ",
)
for pt in arc:
assert pt["Y"] == 5
def test_yz_plane(self):
"""Arc in YZ plane should vary Y and Z, keep X constant."""
arc = MotionAPI.generate_arc(
center={"X": 7, "Y": 0, "Z": 0},
radius=10.0,
start_angle=0,
end_angle=180,
steps=5,
plane="YZ",
)
for pt in arc:
assert pt["X"] == 7
def test_preserves_orientation(self):
"""Orientation keys A, B, C should carry through from center."""
arc = MotionAPI.generate_arc(
center={"X": 0, "Y": 0, "Z": 0, "A": 10, "B": 20, "C": 30},
radius=5.0,
start_angle=0,
end_angle=45,
steps=3,
)
for pt in arc:
assert pt["A"] == 10
assert pt["B"] == 20
assert pt["C"] == 30
def test_invalid_plane_raises(self):
with pytest.raises(ValueError, match="Unknown plane"):
MotionAPI.generate_arc(
center={"X": 0, "Y": 0, "Z": 0},
radius=5.0, start_angle=0, end_angle=90, steps=5, plane="AB"
)
def test_zero_radius_raises(self):
with pytest.raises(ValueError, match="positive"):
MotionAPI.generate_arc(
center={"X": 0, "Y": 0, "Z": 0},
radius=0.0, start_angle=0, end_angle=90, steps=5
)
def test_one_step_raises(self):
with pytest.raises(ValueError, match="at least 2"):
MotionAPI.generate_arc(
center={"X": 0, "Y": 0, "Z": 0},
radius=5.0, start_angle=0, end_angle=90, steps=1
)
# ---------------------------------------------------------------------------
# generate_circle
# ---------------------------------------------------------------------------
class TestGenerateCircle:
"""Tests for MotionAPI.generate_circle()."""
def test_returns_360_degree_arc(self):
"""Circle should span full 360 degrees."""
circle = MotionAPI.generate_circle(
center={"X": 0, "Y": 0, "Z": 0},
radius=10.0,
steps=100,
)
assert len(circle) == 100
# First and last points should be nearly identical (full revolution)
assert circle[0]["X"] == pytest.approx(circle[-1]["X"], abs=0.5)
assert circle[0]["Y"] == pytest.approx(circle[-1]["Y"], abs=0.5)
def test_circle_points_on_radius(self):
"""All points should lie on the specified radius from center."""
cx, cy = 50.0, 30.0
radius = 20.0
circle = MotionAPI.generate_circle(
center={"X": cx, "Y": cy, "Z": 0},
radius=radius,
steps=36,
)
for pt in circle:
dist = math.sqrt((pt["X"] - cx) ** 2 + (pt["Y"] - cy) ** 2)
assert dist == pytest.approx(radius, abs=1e-10)
# ---------------------------------------------------------------------------
# generate_spiral
# ---------------------------------------------------------------------------
class TestGenerateSpiral:
"""Tests for MotionAPI.generate_spiral()."""
def test_expanding_spiral_radius_increases(self):
"""Radius should increase from start_radius to end_radius."""
spiral = MotionAPI.generate_spiral(
center={"X": 0, "Y": 0, "Z": 0},
start_radius=5.0,
end_radius=50.0,
pitch=0.0,
revolutions=2,
steps=50,
)
first_r = math.sqrt(spiral[0]["X"] ** 2 + spiral[0]["Y"] ** 2)
last_r = math.sqrt(spiral[-1]["X"] ** 2 + spiral[-1]["Y"] ** 2)
assert last_r > first_r
def test_contracting_spiral_radius_decreases(self):
"""Radius should decrease when end_radius < start_radius."""
spiral = MotionAPI.generate_spiral(
center={"X": 0, "Y": 0, "Z": 0},
start_radius=50.0,
end_radius=5.0,
pitch=0.0,
revolutions=2,
steps=50,
)
first_r = math.sqrt(spiral[0]["X"] ** 2 + spiral[0]["Y"] ** 2)
last_r = math.sqrt(spiral[-1]["X"] ** 2 + spiral[-1]["Y"] ** 2)
assert last_r < first_r
def test_pitch_adds_height(self):
"""Positive pitch should increase Z across the spiral."""
spiral = MotionAPI.generate_spiral(
center={"X": 0, "Y": 0, "Z": 100},
start_radius=10.0,
end_radius=10.0,
pitch=20.0,
revolutions=1,
steps=20,
)
assert spiral[-1]["Z"] > spiral[0]["Z"]
def test_step_count(self):
"""Should return exactly the requested number of steps."""
spiral = MotionAPI.generate_spiral(
center={"X": 0, "Y": 0, "Z": 0},
start_radius=10.0,
end_radius=20.0,
pitch=5.0,
steps=33,
)
assert len(spiral) == 33
def test_too_few_steps_raises(self):
with pytest.raises(ValueError, match="at least 2"):
MotionAPI.generate_spiral(
center={"X": 0, "Y": 0, "Z": 0},
start_radius=5, end_radius=10, pitch=1, steps=1
)
# ---------------------------------------------------------------------------
# blend_trajectories
# ---------------------------------------------------------------------------
class TestBlendTrajectories:
"""Tests for MotionAPI.blend_trajectories()."""
@staticmethod
def _line(start_x, end_x, n=20):
"""Helper: straight line along X."""
return [{"X": start_x + i * (end_x - start_x) / (n - 1), "Y": 0, "Z": 0} for i in range(n)]
def test_blended_length(self):
"""Blended trajectory should have points from both segments plus blend zone."""
t1 = self._line(0, 100, 20)
t2 = self._line(100, 200, 20)
blended = MotionAPI.blend_trajectories(t1, t2, blend_radius=10.0, blend_steps=10)
# Should have some points; exact count depends on blend_radius vs traj length
assert len(blended) > 0
def test_blended_starts_at_traj1_start(self):
"""First point of blended should match first point of traj1."""
t1 = self._line(0, 100, 20)
t2 = self._line(100, 200, 20)
blended = MotionAPI.blend_trajectories(t1, t2, blend_radius=5.0, blend_steps=10)
assert blended[0]["X"] == pytest.approx(0.0)
def test_blended_ends_at_traj2_end(self):
"""Last point of blended should match last point of traj2."""
t1 = self._line(0, 100, 20)
t2 = self._line(100, 200, 20)
blended = MotionAPI.blend_trajectories(t1, t2, blend_radius=5.0, blend_steps=10)
assert blended[-1]["X"] == pytest.approx(200.0)
def test_empty_traj_raises(self):
with pytest.raises(ValueError, match="non-empty"):
MotionAPI.blend_trajectories([], [{"X": 0}], blend_radius=1.0)
def test_zero_blend_radius_raises(self):
with pytest.raises(ValueError, match="positive"):
MotionAPI.blend_trajectories(
[{"X": 0}], [{"X": 1}], blend_radius=0.0
)
# ---------------------------------------------------------------------------
# transform_coordinates
# ---------------------------------------------------------------------------
class TestTransformCoordinates:
"""Tests for MotionAPI.transform_coordinates()."""
def test_no_offset_returns_copy(self):
"""No frame_offset should return a copy of the input pose."""
pose = {"X": 10, "Y": 20, "Z": 30}
result = MotionAPI.transform_coordinates(pose)
assert result == pose
# Should be a copy, not the same object
assert result is not pose
def test_translational_offset(self):
"""Frame offset should add to position values."""
pose = {"X": 100, "Y": 0, "Z": 500}
offset = {"X": 500, "Y": 200, "Z": 0}
result = MotionAPI.transform_coordinates(pose, frame_offset=offset)
assert result["X"] == 600
assert result["Y"] == 200
assert result["Z"] == 500
def test_rotational_offset(self):
"""Frame offset should add to orientation values."""
pose = {"X": 0, "Y": 0, "Z": 0, "A": 10, "B": 20, "C": 30}
offset = {"A": 5, "B": -10, "C": 15}
result = MotionAPI.transform_coordinates(pose, frame_offset=offset)
assert result["A"] == 15
assert result["B"] == 10
assert result["C"] == 45
def test_partial_offset(self):
"""Offset with fewer keys should only affect matching axes."""
pose = {"X": 100, "Y": 200, "Z": 300}
offset = {"X": 10}
result = MotionAPI.transform_coordinates(pose, frame_offset=offset)
assert result["X"] == 110
assert result["Y"] == 200 # unchanged
assert result["Z"] == 300 # unchanged
# ---------------------------------------------------------------------------
# generate_velocity_profile
# ---------------------------------------------------------------------------
class TestGenerateVelocityProfile:
"""Tests for MotionAPI.generate_velocity_profile()."""
@staticmethod
def _simple_traj(n=50):
"""Evenly spaced points along X."""
return [{"X": i * 1.0, "Y": 0, "Z": 0} for i in range(n)]
def test_trapezoidal_returns_correct_length(self):
traj = self._simple_traj(50)
result = MotionAPI.generate_velocity_profile(traj, profile='trapezoidal')
assert len(result) == 50
def test_scurve_returns_correct_length(self):
traj = self._simple_traj(50)
result = MotionAPI.generate_velocity_profile(traj, profile='s-curve')
assert len(result) == 50
def test_trapezoidal_starts_and_ends_near_zero(self):
"""Velocity should be near zero at start and end."""
traj = self._simple_traj(100)
result = MotionAPI.generate_velocity_profile(
traj, max_velocity=10.0, max_acceleration=5.0, profile='trapezoidal'
)
_, v_first = result[0]
_, v_last = result[-1]
assert v_first == pytest.approx(0.0, abs=0.5)
assert v_last == pytest.approx(0.0, abs=0.5)
def test_scurve_starts_and_ends_near_zero(self):
traj = self._simple_traj(100)
result = MotionAPI.generate_velocity_profile(
traj, max_velocity=10.0, max_acceleration=5.0, profile='s-curve'
)
_, v_first = result[0]
_, v_last = result[-1]
assert v_first == pytest.approx(0.0, abs=0.5)
assert v_last == pytest.approx(0.0, abs=0.5)
def test_empty_trajectory(self):
assert MotionAPI.generate_velocity_profile([]) == []
def test_single_point(self):
result = MotionAPI.generate_velocity_profile([{"X": 0}])
assert len(result) == 1
assert result[0][1] == 0.0
def test_unknown_profile_raises(self):
traj = self._simple_traj(10)
with pytest.raises(ValueError, match="Unknown profile"):
MotionAPI.generate_velocity_profile(traj, profile='banana')
def test_trapezoidal_velocity_does_not_exceed_max(self):
"""No velocity should exceed max_velocity."""
traj = self._simple_traj(100)
max_v = 5.0
result = MotionAPI.generate_velocity_profile(
traj, max_velocity=max_v, max_acceleration=2.0, profile='trapezoidal'
)
for _, v in result:
assert v <= max_v + 1e-9
# ---------------------------------------------------------------------------
# _cubic_blend edge cases
# ---------------------------------------------------------------------------
class TestCubicBlend:
"""Tests for _cubic_blend helper."""
def test_steps_one_no_crash(self):
"""steps=1 should not cause division by zero."""
result = _cubic_blend({"X": 0, "Y": 0}, {"X": 10, "Y": 10}, steps=1)
assert len(result) == 1
def test_steps_two(self):
"""steps=2 should interpolate from p1 to p2."""
result = _cubic_blend({"X": 0}, {"X": 100}, steps=2)
assert len(result) == 2
assert result[0]["X"] == pytest.approx(0.0)
assert result[-1]["X"] == pytest.approx(100.0)
def test_midpoint_is_blended(self):
"""Midpoint (t=0.5) of cubic Hermite with zero velocities should be average."""
result = _cubic_blend({"X": 0}, {"X": 100}, steps=3)
# At t=0.5: h1=0.5, h2=0.5 -> blended = 50
assert result[1]["X"] == pytest.approx(50.0)
# ---------------------------------------------------------------------------
# _trapezoidal_profile edge cases
# ---------------------------------------------------------------------------
class TestTrapezoidalProfileEdgeCases:
"""Edge-case tests for the _trapezoidal_profile helper."""
def test_single_distance(self):
"""Profile with one distance segment should not crash."""
velocities = _trapezoidal_profile([10.0], 10.0, 5.0, 2.0)
assert len(velocities) == 2 # n = len(distances) + 1
def test_zero_total_distance(self):
"""Zero total distance should produce zero velocities without error."""
velocities = _trapezoidal_profile([0.0], 0.0, 5.0, 2.0)
assert all(v == 0.0 for v in velocities)
def test_triangular_profile_triggered(self):
"""Short distance forces triangular profile (no constant phase)."""
# accel_distance = v^2 / (2*a) = 100/4 = 25
# total = 10 < 2*25 => triangular
velocities = _trapezoidal_profile([5.0, 5.0], 10.0, 10.0, 2.0)
assert len(velocities) == 3
# ---------------------------------------------------------------------------
# _calculate_distance
# ---------------------------------------------------------------------------
class TestCalculateDistance:
"""Tests for _calculate_distance helper."""
def test_zero_distance(self):
assert _calculate_distance({"X": 0, "Y": 0}, {"X": 0, "Y": 0}) == 0.0
def test_unit_distance(self):
assert _calculate_distance({"X": 0}, {"X": 1}) == pytest.approx(1.0)
def test_3d_distance(self):
d = _calculate_distance({"X": 0, "Y": 0, "Z": 0}, {"X": 3, "Y": 4, "Z": 0})
assert d == pytest.approx(5.0)
def test_ignores_non_motion_keys(self):
"""Keys not in the motion set should be ignored."""
d = _calculate_distance({"X": 0, "FOO": 0}, {"X": 3, "FOO": 1000})
assert d == pytest.approx(3.0)

View File

@ -6,6 +6,7 @@ import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from RSIPI.safety_manager import SafetyManager from RSIPI.safety_manager import SafetyManager
from RSIPI.exceptions import RSILimitExceeded, RSIEmergencyStop
class TestValidate: class TestValidate:
@ -32,15 +33,15 @@ class TestValidate:
limits = {"RKorr.X": (-5.0, 5.0)} limits = {"RKorr.X": (-5.0, 5.0)}
sm = SafetyManager(limits) sm = SafetyManager(limits)
with pytest.raises(ValueError, match="out of bounds"): with pytest.raises(RSILimitExceeded, match="out of bounds"):
sm.validate("RKorr.X", 5.1) sm.validate("RKorr.X", 5.1)
def test_validate_below_min(self): def test_validate_below_min(self):
"""Test that values below min raise ValueError.""" """Test that values below min raise RSILimitExceeded."""
limits = {"RKorr.X": (-5.0, 5.0)} limits = {"RKorr.X": (-5.0, 5.0)}
sm = SafetyManager(limits) sm = SafetyManager(limits)
with pytest.raises(ValueError, match="out of bounds"): with pytest.raises(RSILimitExceeded, match="out of bounds"):
sm.validate("RKorr.X", -5.1) sm.validate("RKorr.X", -5.1)
def test_validate_unlisted_path(self): def test_validate_unlisted_path(self):
@ -71,7 +72,7 @@ class TestEmergencyStop:
sm = SafetyManager() sm = SafetyManager()
sm.emergency_stop() sm.emergency_stop()
with pytest.raises(RuntimeError, match="E-STOP"): with pytest.raises(RSIEmergencyStop, match="E-STOP"):
sm.validate("RKorr.X", 0.0) sm.validate("RKorr.X", 0.0)
def test_estop_reset(self): def test_estop_reset(self):
@ -99,7 +100,7 @@ class TestSetLimit:
sm.set_limit("RKorr.Y", -10.0, 10.0) sm.set_limit("RKorr.Y", -10.0, 10.0)
assert sm.validate("RKorr.Y", 5.0) == 5.0 assert sm.validate("RKorr.Y", 5.0) == 5.0
with pytest.raises(ValueError): with pytest.raises(RSILimitExceeded):
sm.validate("RKorr.Y", 15.0) sm.validate("RKorr.Y", 15.0)
def test_override_existing_limit(self): def test_override_existing_limit(self):
@ -108,7 +109,7 @@ class TestSetLimit:
sm = SafetyManager(limits) sm = SafetyManager(limits)
# Original limit blocks this # Original limit blocks this
with pytest.raises(ValueError): with pytest.raises(RSILimitExceeded):
sm.validate("RKorr.X", 8.0) sm.validate("RKorr.X", 8.0)
# Override limit # Override limit