Skip to content

Commit 9291832

Browse files
authored
Explicitly send MODE_STOPPED when returning control to the robot (backport #1678) (#1832)
* Explicitly send MODE_STOPPED when returning control to the robot This way, we can distinguish between an intentional hand-back and a communication loss. * Add an integration test for handing back control
1 parent b6bdb92 commit 9291832

8 files changed

Lines changed: 273 additions & 8 deletions

File tree

.github/workflows/reusable_ici.yml

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ jobs:
4040
name: ${{ inputs.ros_distro }} ${{ inputs.ros_repo }}
4141
runs-on: ubuntu-latest
4242
env:
43-
DOCKER_RUN_OPTS: '-v /var/run/docker.sock:/var/run/docker.sock --network ursim_net'
43+
# /tmp/ur_test_artifacts: collection point for test artifacts (e.g. VNC
44+
# snapshots) written from inside the ICI container.
45+
# /tmp/ursim_urcaps: shared storage for the External Control URCap.
46+
# Mounted at the same path in the ICI container AND used as the bind
47+
# source for the URSim container that start_ursim.sh launches via the
48+
# host docker daemon, so the URCap downloaded inside the ICI container
49+
# is actually visible to URSim's /urcaps mount.
50+
# Both paths are kept outside ${{ github.workspace }} so they do not
51+
# collide with industrial_ci's read-only bind-mount of TARGET_REPO_PATH.
52+
DOCKER_RUN_OPTS: '-v /var/run/docker.sock:/var/run/docker.sock -v /tmp/ur_test_artifacts:/test_artifacts -v /tmp/ursim_urcaps:/tmp/ursim_urcaps -e UR_TEST_ARTIFACTS_DIR=/test_artifacts -e UR_CI_URCAP_FOLDER=/tmp/ursim_urcaps --network ursim_net --ip 192.168.56.1'
4453
CCACHE_DIR: ${{ github.workspace }}/${{ inputs.ccache_dir }}
4554
CACHE_PREFIX: ${{ inputs.ros_distro }}-${{ inputs.upstream_workspace }}-${{ inputs.ros_repo }}
4655
ROSDEP_SKIP_KEYS: ${{ inputs.rosdep_skip_keys }}
@@ -54,8 +63,16 @@ jobs:
5463
uses: actions/checkout@v6
5564
with:
5665
ref: ${{ inputs.ref_for_scheduled_build }}
57-
- run: docker network create --subnet=192.168.56.0/24 ursim_net
66+
- run: docker network create --subnet=192.168.56.0/24 --gateway 192.168.56.254 ursim_net
5867
if: ${{ !env.ACT }}
68+
- name: Create shared host directories
69+
run: |
70+
mkdir -p /tmp/ur_test_artifacts /tmp/ursim_urcaps
71+
# Allow the container (running as root) to write here while keeping
72+
# the directory readable by the runner user for upload-artifact, and
73+
# writable by the host docker daemon when it bind-mounts /tmp/ursim_urcaps
74+
# into the URSim container.
75+
chmod 777 /tmp/ur_test_artifacts /tmp/ursim_urcaps
5976
- name: Cache ccache
6077
uses: actions/cache@v5
6178
with:

ur_robot_driver/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ if(BUILD_TESTING)
305305
TIMEOUT
306306
800
307307
)
308+
add_launch_test(test/integration_test_hand_back_control.py
309+
TIMEOUT
310+
800
311+
)
308312
add_launch_test(test/integration_test_tool_contact.py
309313
TIMEOUT
310314
800

ur_robot_driver/include/ur_robot_driver/hardware_interface.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,8 @@ class URPositionHardwareInterface : public hardware_interface::SystemInterface
357357
std::array<double, 4> robot_status_bits_copy_;
358358
std::array<double, 11> safety_status_bits_copy_;
359359

360-
bool robot_program_running_;
360+
std::atomic<bool> robot_program_running_;
361+
std::atomic<bool> stop_requested_;
361362
bool non_blocking_read_;
362363
double robot_program_running_copy_;
363364

ur_robot_driver/src/hardware_interface.cpp

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ URPositionHardwareInterface::on_init(const hardware_interface::HardwareComponent
213213
trajectory_joint_positions_.reserve(32768);
214214
trajectory_joint_velocities_.reserve(32768);
215215
trajectory_joint_accelerations_.reserve(32768);
216+
stop_requested_ = false;
216217

217218
// Motion primitives stuff
218219
async_moprim_thread_shutdown_ = false;
@@ -1060,7 +1061,12 @@ hardware_interface::return_type URPositionHardwareInterface::write(const rclcpp:
10601061
if ((runtime_state_ == static_cast<uint32_t>(rtde::RUNTIME_STATE::PLAYING) ||
10611062
runtime_state_ == static_cast<uint32_t>(rtde::RUNTIME_STATE::PAUSING)) &&
10621063
robot_program_running_ && (!non_blocking_read_ || packet_read_)) {
1063-
if (position_controller_running_) {
1064+
if (stop_requested_) {
1065+
ur_driver_->writeJointCommand(urcl_position_commands_, urcl::comm::ControlMode::MODE_STOPPED);
1066+
stop_requested_ = false;
1067+
robot_program_running_ = false; // We reset that here, as well to avoid a race condition
1068+
// between the reverse interface callback and the next write.
1069+
} else if (position_controller_running_) {
10641070
ur_driver_->writeJointCommand(urcl_position_commands_, urcl::comm::ControlMode::MODE_SERVOJ, receive_timeout_);
10651071

10661072
} else if (velocity_controller_running_) {
@@ -1181,7 +1187,7 @@ void URPositionHardwareInterface::checkAsyncIO()
11811187
}
11821188

11831189
if (!std::isnan(hand_back_control_cmd_) && ur_driver_ != nullptr) {
1184-
robot_program_running_ = false;
1190+
stop_requested_ = true;
11851191
hand_back_control_async_success_ = true;
11861192
hand_back_control_cmd_ = NO_NEW_CMD_;
11871193
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python
2+
# Copyright 2026, Universal Robots A/S
3+
#
4+
# Redistribution and use in source and binary forms, with or without
5+
# modification, are permitted provided that the following conditions are met:
6+
#
7+
# * Redistributions of source code must retain the above copyright
8+
# notice, this list of conditions and the following disclaimer.
9+
#
10+
# * Redistributions in binary form must reproduce the above copyright
11+
# notice, this list of conditions and the following disclaimer in the
12+
# documentation and/or other materials provided with the distribution.
13+
#
14+
# * Neither the name of the {copyright_holder} nor the names of its
15+
# contributors may be used to endorse or promote products derived from
16+
# this software without specific prior written permission.
17+
#
18+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
22+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28+
# POSSIBILITY OF SUCH DAMAGE.
29+
30+
import logging
31+
import os
32+
import socket
33+
import sys
34+
import subprocess
35+
import time
36+
import unittest
37+
38+
import pytest
39+
import rclpy
40+
from control_msgs.action import FollowJointTrajectory
41+
from controller_manager_msgs.srv import SwitchController
42+
from rclpy.node import Node
43+
from std_msgs.msg import Bool
44+
45+
from ur_dashboard_msgs.msg import ProgramState
46+
47+
sys.path.append(os.path.dirname(__file__))
48+
from test_common import ( # noqa: E402
49+
generate_driver_test_description,
50+
ActionInterface,
51+
ControllerManagerInterface,
52+
DashboardInterface,
53+
IoStatusInterface,
54+
)
55+
56+
57+
@pytest.mark.launch_test
58+
def generate_test_description():
59+
# When set, use a shared urcap folder to be mounted to the ursim container
60+
# This is especially useful when running the test in a Docker DooD setup.
61+
urcap_folder = os.environ.get("UR_CI_URCAP_FOLDER")
62+
return generate_driver_test_description(headless_mode=False, urcap_folder=urcap_folder)
63+
64+
65+
def copy_to_docker_container(container_name, src_path, dest_path):
66+
print(f"Copying {src_path} to container '{container_name}' at {dest_path}")
67+
subprocess.run(["docker", "cp", src_path, f"{container_name}:{dest_path}"], check=True)
68+
69+
70+
class HandBackControlTest(unittest.TestCase):
71+
@classmethod
72+
def setUpClass(cls):
73+
rclpy.init()
74+
cls.node = Node("hand_back_control_test")
75+
time.sleep(1)
76+
cls.init_robot(cls)
77+
try:
78+
copy_to_docker_container(
79+
"ursim",
80+
os.path.join(
81+
os.path.dirname(__file__),
82+
"resources",
83+
"ursim",
84+
"e-series",
85+
"ur5e",
86+
"programs",
87+
"hand_back_control_test_prog.urp",
88+
),
89+
"/ursim/programs/hand_back_control_test_prog.urp",
90+
)
91+
subprocess.run(["docker", "exec", "ursim", "ls", "-l", "/ursim/programs"], check=True)
92+
93+
except Exception as e:
94+
logging.error(f"Failed to copy program to Docker container: {e}")
95+
96+
@classmethod
97+
def tearDownClass(cls):
98+
cls.node.destroy_node()
99+
rclpy.shutdown()
100+
101+
def init_robot(self):
102+
self._dashboard_interface = DashboardInterface(self.node)
103+
self._controller_manager_interface = ControllerManagerInterface(self.node)
104+
self._io_status_controller_interface = IoStatusInterface(self.node)
105+
106+
self._follow_joint_trajectory = ActionInterface(
107+
self.node,
108+
"/scaled_joint_trajectory_controller/follow_joint_trajectory",
109+
FollowJointTrajectory,
110+
)
111+
112+
self._controller_manager_interface.wait_for_controller("scaled_joint_trajectory_controller")
113+
114+
def setUp(self):
115+
self._dashboard_interface.start_robot()
116+
time.sleep(1)
117+
118+
# Open a socket server on port 20957 and add a connection callback
119+
self.serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
120+
self.serversocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
121+
self.serversocket.settimeout(0.2) # timeout for listening
122+
self.serversocket.bind(("0.0.0.0", 20957))
123+
self.serversocket.listen(1)
124+
125+
def tearDown(self):
126+
self.serversocket.close()
127+
128+
def wait_for_connection(self, timeout=5):
129+
end_time = time.time() + timeout
130+
conn = None
131+
while time.time() < end_time and conn is None:
132+
try:
133+
conn, address = self.serversocket.accept()
134+
logging.info(f"Received connection from {address}")
135+
return True
136+
except socket.timeout:
137+
continue
138+
return False
139+
140+
def test_hand_back_control_stops_reverse_interface_and_program_keeps_running(self):
141+
"""
142+
Use hand_back_control to return program flow to the robot.
143+
144+
We loaded a program that connects to a test socket and waits 10 seconds after
145+
external_control has finished. Hence, if we hand back control, the program should still
146+
be running and we should receive a connection on the socket server.
147+
"""
148+
program_name = "hand_back_control_test_prog.urp"
149+
150+
self._dashboard_interface.load_program(filename=program_name)
151+
152+
external_control_running = None
153+
154+
def program_state_cb(msg):
155+
nonlocal external_control_running
156+
external_control_running = msg.data
157+
158+
program_state_sub = self.node.create_subscription(
159+
Bool,
160+
"/io_and_status_controller/robot_program_running",
161+
program_state_cb,
162+
rclpy.qos.qos_profile_system_default,
163+
)
164+
165+
self._dashboard_interface.play()
166+
self._controller_manager_interface.wait_for_controller(
167+
"scaled_joint_trajectory_controller", "active"
168+
)
169+
self.assertTrue(
170+
self._controller_manager_interface.switch_controller(
171+
strictness=SwitchController.Request.BEST_EFFORT,
172+
deactivate_controllers=["passthrough_trajectory_controller"],
173+
activate_controllers=["scaled_joint_trajectory_controller"],
174+
).ok
175+
)
176+
177+
self.assertTrue(
178+
external_control_running, "Robot program should be running before hand_back_control"
179+
)
180+
181+
# Call hand_back_control
182+
logging.info("Calling hand_back_control")
183+
result = self._io_status_controller_interface.hand_back_control()
184+
self.assertTrue(result.success, "hand_back_control service call should succeed")
185+
186+
# Verify external control stops running
187+
end_time = time.time() + 10
188+
while external_control_running is not False and time.time() < end_time:
189+
rclpy.spin_once(self.node, timeout_sec=0.1)
190+
self.assertFalse(
191+
external_control_running, "External Control should stop after hand_back_control"
192+
)
193+
194+
self.assertTrue(
195+
self.wait_for_connection(),
196+
"Robot program should connect back to the socket server after hand_back_control",
197+
)
198+
program_state = self._dashboard_interface.program_state()
199+
self.assertEqual(
200+
program_state.state.state,
201+
ProgramState.PLAYING,
202+
"Robot program should still be running after hand_back_control",
203+
)
204+
self.assertEqual(
205+
program_state.program_name,
206+
program_name,
207+
"Robot should still be running the same program after hand_back_control",
208+
)
209+
210+
self.node.destroy_subscription(program_state_sub)
Binary file not shown.
Binary file not shown.

ur_robot_driver/test/test_common.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ class DashboardInterface(
249249
"load_installation": Load,
250250
"load_program": Load,
251251
"close_popup": Trigger,
252+
"close_safety_popup": Trigger,
252253
"get_loaded_program": GetLoadedProgram,
253254
"program_state": GetProgramState,
254255
"program_running": IsProgramRunning,
@@ -273,6 +274,9 @@ class DashboardInterface(
273274
},
274275
):
275276
def start_robot(self):
277+
self._check_call(self.close_popup())
278+
self._check_call(self.close_safety_popup())
279+
self._check_call(self.stop())
276280
self._check_call(self.power_off())
277281
self._check_call(self.power_on())
278282
self._check_call(self.brake_release())
@@ -333,6 +337,7 @@ class IoStatusInterface(
333337
services={
334338
"resend_robot_program": Trigger,
335339
"set_payload": SetPayload,
340+
"hand_back_control": Trigger,
336341
},
337342
):
338343
pass
@@ -468,7 +473,13 @@ def _declare_launch_arguments():
468473
return declared_arguments
469474

470475

471-
def _ursim_action(ursim_version="latest", ur_type="ur5e", container_name=None):
476+
def _ursim_action(
477+
ursim_version="latest",
478+
ur_type="ur5e",
479+
container_name=None,
480+
program_folder=None,
481+
urcap_folder=None,
482+
):
472483
cmd = [
473484
PathJoinSubstitution(
474485
[
@@ -485,6 +496,10 @@ def _ursim_action(ursim_version="latest", ur_type="ur5e", container_name=None):
485496
]
486497
if container_name is not None:
487498
cmd += ["-n", container_name]
499+
if program_folder is not None:
500+
cmd += ["-p", program_folder]
501+
if urcap_folder is not None:
502+
cmd += ["-u", urcap_folder]
488503
return ExecuteProcess(
489504
cmd=cmd,
490505
name="start_ursim",
@@ -553,6 +568,11 @@ def generate_driver_test_description(
553568
tf_prefix="",
554569
initial_joint_controller="scaled_joint_trajectory_controller",
555570
controller_spawner_timeout=TIMEOUT_WAIT_SERVICE_INITIAL,
571+
headless_mode=True,
572+
ursim_version="latest",
573+
ur_type="ur5e",
574+
ursim_program_folder=None,
575+
urcap_folder=None,
556576
):
557577
ur_type = LaunchConfiguration("ur_type")
558578

@@ -562,7 +582,7 @@ def generate_driver_test_description(
562582
"launch_rviz": "false",
563583
"controller_spawner_timeout": str(controller_spawner_timeout),
564584
"initial_joint_controller": initial_joint_controller,
565-
"headless_mode": "true",
585+
"headless_mode": "true" if headless_mode else "false",
566586
"launch_dashboard_client": "true",
567587
"start_joint_controller": "false",
568588
}
@@ -590,9 +610,16 @@ def generate_driver_test_description(
590610
OnProcessExit(target_action=wait_dashboard_server, on_exit=robot_driver)
591611
)
592612

613+
ursim_starter = _ursim_action(
614+
ursim_version=ursim_version,
615+
ur_type=ur_type,
616+
program_folder=ursim_program_folder,
617+
urcap_folder=urcap_folder,
618+
)
619+
593620
return LaunchDescription(
594621
_declare_launch_arguments()
595-
+ [ReadyToTest(), wait_dashboard_server, _ursim_action(), driver_starter]
622+
+ [ReadyToTest(), wait_dashboard_server, ursim_starter, driver_starter]
596623
)
597624

598625

0 commit comments

Comments
 (0)