diff --git a/.gitignore b/.gitignore
index a2fbf38..2cd034d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,13 +4,15 @@ doc/lwc-doc/site/
log/
/install/
/build/
-.vscode
-.idea
-venv
-.venv
-.claude
-.codex
-__pycache__
+.vscode/
+.idea/
+venv/
+.venv/
+.claude/
+.agents/
+.codex/
+__pycache__/
+.pytest_cache/
*.egg-info
**.FCBak
diff --git a/cobot-setting.yaml b/cobot-setting.yaml
index 8cf012f..36a2e2c 100644
--- a/cobot-setting.yaml
+++ b/cobot-setting.yaml
@@ -8,6 +8,13 @@ robot:
digital_twin:
+ tcp_gizmo:
+ enabled: true # TCP-гизма в Webots; только в режиме симуляции
+ publish_period: 0.032 # период управления [с]
+ command_timeout: 0.3 # максимальный возраст цели при отпускании мыши [с]
+ state_timeout: 0.5 # допустимый возраст состояния робота [с]
+ max_linear_speed: 0.50 # верхний предел скорости TCP [м/с]
+ max_angular_speed: 0.4 # скорость вращения TCP [рад/с]
webots:
world: pkg://iiwa_description/worlds/iiwa.wbt
# world: pkg://iiwa_description/worlds/simple_world.wbt
diff --git a/doc/lwc-doc/docs/getting-started/concepts/simulation.en.md b/doc/lwc-doc/docs/getting-started/concepts/simulation.en.md
index 04f4660..03043f0 100644
--- a/doc/lwc-doc/docs/getting-started/concepts/simulation.en.md
+++ b/doc/lwc-doc/docs/getting-started/concepts/simulation.en.md
@@ -1,8 +1,5 @@
# Simulation (Webots)
-!!! info "Work in progress"
- A detailed simulator guide is being prepared.
-
**Webots** is an open-source robot simulator. LWC uses it as a digital twin of the KUKA LBR IIWA 7, allowing control algorithms to be developed and debugged without a physical robot.
## Key features
@@ -11,8 +8,34 @@
- The simulation world is set in `cobot-setting.yaml` → `digital_twin.webots.world`.
- Start it with `cobot run --simulate`.
+## TCP gizmo
+
+This simulation-only mode lets you specify the robot's target pose by dragging a marker at its tool center point (TCP) in the Webots window.
+
+1. Enable `digital_twin.tcp_gizmo.enabled: true` in `cobot-setting.yaml` and launch with `cobot run --simulate`.
+2. Wait until other robot motions finish. For an initial test, use the regular named-pose command to move to `work`.
+3. Select the TCP marker in Webots. Drag a translation arrow or rotation handle with the left mouse button. The robot remains stationary while you edit the target.
+4. Release the button to submit the target. One complete trajectory is sent to the controller, rather than a stream of actions during dragging.
+5. Wait for completion before dragging again. If the target cannot be reached or the checked path collides, the marker returns to the actual TCP.
+
+Regular coordinate, joint, and control-file commands remain available. They have priority over new gizmo gestures. An already started gizmo trajectory finishes before a waiting regular command proceeds, even when that wait exceeds two seconds.
+
+### Tool attachment and speed
+
+The marker follows the active tool's `tcp` frame. With `patron`, it is at the chuck's working end; with no tool, it is at the flange. Apply tool changes through `cobot robot-setup`, rebuild, and restart the simulation. New tool models must define `tcp` at their working point.
+
+`max_linear_speed` (m/s) and `max_angular_speed` (rad/s) are upper bounds. Short moves include acceleration and braking, and joint limits may make motion slower. Joint interpolation reaches the requested TCP pose but does not guarantee a straight Cartesian path.
+
+Set `digital_twin.tcp_gizmo.enabled: false` and restart to disable this mode. The [configuration reference](../configuration.md) describes all parameters. `command_timeout` checks the age of the submitted target; mouse release is detected directly and requires no waiting-time setting.
+
+### When the marker returns or does not respond
+
+- Wait for regular motions or a committed gizmo trajectory to finish before starting another drag.
+- For a rejected target, read `TCP gizmo rejected target: ...` in the launch output. Try a smaller displacement from a reachable posture such as `work`.
+- With missing or stale robot feedback, the gizmo cannot submit movement. Check that simulation is running and `/joint_states` updates.
+
## Differences from the real robot
-- There are no real safety constraints, so motion can be faster.
+- Motion still obeys configured joint and TCP limits; simulation does not reproduce all physical robot protections.
- Physics is approximate, including inertia, friction, and elasticity.
- FRI is not used; communication goes through the Webots ROS 2 driver.
diff --git a/doc/lwc-doc/docs/getting-started/concepts/simulation.md b/doc/lwc-doc/docs/getting-started/concepts/simulation.md
index 6795ad7..70da487 100644
--- a/doc/lwc-doc/docs/getting-started/concepts/simulation.md
+++ b/doc/lwc-doc/docs/getting-started/concepts/simulation.md
@@ -1,8 +1,5 @@
# Симуляция (Webots)
-!!! info "Раздел в разработке"
- Подробное описание работы в симуляторе готовится.
-
**Webots** — симулятор роботов с открытым исходным кодом. В LWC используется как цифровой двойник робота KUKA LBR IIWA 7: позволяет разрабатывать и отлаживать алгоритмы управления без доступа к физическому роботу.
## Ключевые особенности
@@ -11,8 +8,34 @@
- Мир симуляции задаётся в `cobot-setting.yaml` → `digital_twin.webots.world`
- Для запуска: `cobot run --simulate`
+## Перемещение за TCP
+
+В этом режиме цифрового двойника целевая поза робота задаётся перетаскиванием маркера рабочей точки инструмента (TCP) прямо в окне Webots.
+
+1. Включите `digital_twin.tcp_gizmo.enabled: true` в `cobot-setting.yaml` и запустите `cobot run --simulate`.
+2. Дождитесь завершения других движений. Для первой проверки удобно перейти в именованную позицию `work` обычной командой.
+3. Выберите маркер TCP в Webots. Удерживая левую кнопку мыши, тяните за стрелку перемещения или ручку вращения. Во время редактирования цели робот стоит.
+4. Отпустите кнопку: контроллеру отправится одна полная траектория, а не поток action-целей при перетаскивании.
+5. Дождитесь завершения движения перед следующим переносом. При недостижимой цели или столкновении на проверяемом пути маркер возвращается к фактическому TCP.
+
+Обычные команды координат, joints и файлы управления остаются доступны и имеют приоритет перед новым перетаскиванием. Уже начатое движение гизмы завершается до выполнения ожидающей обычной команды, в том числе если ожидание длится больше двух секунд.
+
+### Инструмент и скорость
+
+Маркер следует за фреймом `tcp` активного инструмента: у `patron` это рабочий торец патрона, без инструмента — фланец. При смене инструмента примените выбор через `cobot robot-setup`, пересоберите проект и перезапустите симуляцию. В моделях новых инструментов задавайте `tcp` в рабочей точке.
+
+`max_linear_speed` (м/с) и `max_angular_speed` (рад/с) задают верхние пределы. На коротких перемещениях есть разгон и торможение; ограничения суставов также могут снизить скорость. Интерполяция в суставах достигает заданной позы TCP, но не гарантирует прямолинейный декартов путь.
+
+Для отключения задайте `digital_twin.tcp_gizmo.enabled: false` и перезапустите систему. Все параметры описаны в [справочнике конфигурации](../configuration.md). `command_timeout` проверяет возраст поступившей цели: отпускание мыши определяется напрямую, настройка времени ожидания не требуется.
+
+### Если маркер возвращается или не реагирует
+
+- Дождитесь завершения обычной команды или уже начатой траектории гизмы перед новым перетаскиванием.
+- При отказе смотрите строку `TCP gizmo rejected target: ...` в выводе запуска. Попробуйте небольшое смещение из достижимой позы, например `work`.
+- При отсутствии или устаревании обратной связи перемещение недоступно. Проверьте, что симуляция работает и `/joint_states` обновляется.
+
## Отличия от реального робота
-- Нет ограничений по безопасности (можно двигаться быстрее)
+- Действуют настроенные ограничения суставов и TCP; симуляция не воспроизводит все защиты физического робота
- Физика не идеальна — инерция, трение и упругость приближённые
- FRI-протокол не задействован — связь идёт через Webots-драйвер ROS 2
diff --git a/doc/lwc-doc/docs/getting-started/configuration.en.md b/doc/lwc-doc/docs/getting-started/configuration.en.md
index f92e684..9587251 100644
--- a/doc/lwc-doc/docs/getting-started/configuration.en.md
+++ b/doc/lwc-doc/docs/getting-started/configuration.en.md
@@ -48,6 +48,13 @@ Configures the Webots environment and RViz visualization.
```yaml
digital_twin:
+ tcp_gizmo:
+ enabled: true
+ publish_period: 0.032
+ command_timeout: 0.3
+ state_timeout: 0.5
+ max_linear_speed: 0.50
+ max_angular_speed: 0.4
webots:
world: pkg://iiwa_description/worlds/iiwa.wbt
transform: "-0.25 0 0.79"
@@ -67,6 +74,28 @@ digital_twin:
| `webots.cameras` | List of YAML configurations for connected cameras |
| `rviz.config` | Path to the RViz configuration |
+### TCP gizmo in Webots
+
+Select the TCP marker, translate or rotate it with the native Webots gizmo, and release the left mouse button. Holding the button only changes the preview. Releasing it submits one complete trajectory. Another drag is accepted after that trajectory finishes. An unreachable target or a colliding trajectory resets the marker to the current TCP.
+
+The gizmo controls the active tool's `tcp` link independently of `planning.pose_link`. For `patron`, TCP is at the chuck's working end, 55.2 mm along local Z from the `patron` link origin. Without a tool, it coincides with the `link_ee` flange. Regular Cartesian commands also use this point when `planning.pose_link: tcp`.
+
+The gizmo has the lowest motion priority. It is unavailable during regular motion. If a coordinate, joint, or file command arrives while a committed gizmo trajectory is running, that command waits for the trajectory to finish before proceeding.
+
+| `tcp_gizmo` parameter | Description |
+|---|---|
+| `enabled` | Set to `false` to disable the gizmo at the next launch; simulation only |
+| `publish_period` | State update period, seconds |
+| `command_timeout` | Maximum age of an incoming target, seconds; not a delay after dragging |
+| `state_timeout` | Maximum age of robot feedback, seconds |
+| `max_linear_speed` | TCP linear speed cap, m/s |
+| `max_angular_speed` | TCP angular speed cap, rad/s |
+
+Mouse release is detected directly, so no idle timer is needed. Joint limits, acceleration, and deceleration can reduce speed below the configured cap. The trajectory interpolates joint positions to reach the requested TCP pose; a straight Cartesian path is not guaranteed.
+
+See the [simulation guide](concepts/simulation.md) for the operating sequence.
+
+
---
@@ -86,6 +115,8 @@ tool:
Available tools are defined in `src/iiwa_config/config/tools.yaml`. To add a tool, describe it there and then set its name in `tool.active`.
+Apply tool selection through `cobot robot-setup`, which updates the tool description and SRDF. Rebuild the project and restart the simulation after changing tools.
+
---
## `planning` section — motion planning
diff --git a/doc/lwc-doc/docs/getting-started/configuration.md b/doc/lwc-doc/docs/getting-started/configuration.md
index c99e264..5a2acc4 100644
--- a/doc/lwc-doc/docs/getting-started/configuration.md
+++ b/doc/lwc-doc/docs/getting-started/configuration.md
@@ -50,6 +50,13 @@ robot:
```yaml
digital_twin:
+ tcp_gizmo:
+ enabled: true
+ publish_period: 0.032
+ command_timeout: 0.3
+ state_timeout: 0.5
+ max_linear_speed: 0.50
+ max_angular_speed: 0.4
webots:
world: pkg://iiwa_description/worlds/iiwa.wbt
transform: "-0.25 0 0.79"
@@ -69,6 +76,27 @@ digital_twin:
| `webots.cameras` | Список YAML-конфигов подключённых камер |
| `rviz.config` | Путь к конфигурации RViz |
+### Перемещение за TCP в Webots
+
+Порядок действий приведён в [руководстве по симуляции](concepts/simulation.md).
+
+Выберите маркер TCP, переместите или поверните его штатной гизмой Webots и отпустите левую кнопку мыши. Пока кнопка удерживается, меняется только маркер. После отпускания отправляется одна полная траектория. Следующее перетаскивание доступно после её завершения. Если цель недостижима или траектория проходит через столкновение, маркер возвращается к текущему TCP.
+
+Гизма использует звено `tcp` активного инструмента независимо от `planning.pose_link`. Для `patron` это рабочий торец патрона (55,2 мм от начала звена `patron` вдоль его локальной оси Z); без инструмента — фланец `link_ee`. Этот TCP также используется обычными декартовыми командами, когда `planning.pose_link: tcp`.
+
+У гизмы самый низкий приоритет: во время обычного движения она недоступна. Если команда координат, joints или файла приходит во время уже начатого движения гизмы, она ждёт завершения этой траектории.
+
+| Параметр `tcp_gizmo` | Описание |
+|---|---|
+| `enabled` | `false` полностью отключает гизму при следующем запуске; доступна только в симуляции |
+| `publish_period` | Период обновления состояния, с |
+| `command_timeout` | Максимальный возраст входящей цели, с; не задержка после перетаскивания |
+| `state_timeout` | Допустимый возраст обратной связи робота, с |
+| `max_linear_speed` | Верхний предел скорости TCP, м/с |
+| `max_angular_speed` | Верхний предел угловой скорости TCP, рад/с |
+
+Отпускание определяется по состоянию кнопки мыши, поэтому таймер ожидания не нужен. Скорость может быть ниже заданного предела из-за ограничений суставов, ускорения и торможения. Траектория интерполируется в суставах и достигает заданной позы TCP; прямолинейность пути TCP не гарантируется.
+
---
@@ -88,6 +116,8 @@ tool:
Список доступных инструментов находится в файле `src/iiwa_config/config/tools.yaml`. Для добавления нового инструмента необходимо описать его там, после чего указать имя в `tool.active`.
+Применяйте выбор через `cobot robot-setup`: мастер обновляет описание инструмента и SRDF. После смены инструмента пересоберите проект и перезапустите симуляцию.
+
---
## Блок `planning` — планирование движений
diff --git a/rosdep.yaml b/rosdep.yaml
index 54c02dc..1f58779 100644
--- a/rosdep.yaml
+++ b/rosdep.yaml
@@ -18,3 +18,22 @@ python3-fastmcp:
pip:
packages: [fastmcp]
+# TCP gizmo / MoveIt Servo (ROS 2 Jazzy).
+moveit_servo:
+ ubuntu: [ros-jazzy-moveit-servo]
+rclcpp_action:
+ ubuntu: [ros-jazzy-rclcpp-action]
+control_msgs:
+ ubuntu: [ros-jazzy-control-msgs]
+trajectory_msgs:
+ ubuntu: [ros-jazzy-trajectory-msgs]
+action_msgs:
+ ubuntu: [ros-jazzy-action-msgs]
+rcl_interfaces:
+ ubuntu: [ros-jazzy-rcl-interfaces]
+tf2_eigen:
+ ubuntu: [ros-jazzy-tf2-eigen]
+python3-numpy:
+ ubuntu: [python3-numpy]
+python3-scipy:
+ ubuntu: [python3-scipy]
diff --git a/src/iiwa_bringup/launch/iiwa.launch.py b/src/iiwa_bringup/launch/iiwa.launch.py
index c9abd26..e772700 100644
--- a/src/iiwa_bringup/launch/iiwa.launch.py
+++ b/src/iiwa_bringup/launch/iiwa.launch.py
@@ -90,6 +90,7 @@ def _runtime_setup(context, *args, **kwargs):
"controller_timer": str(settings.digital_twin.webots.controller_timer),
"controller": str(settings.controller.controller_path),
"initial_positions_file": str(settings.controller.moveit.initial_positions),
+ "tcp_gizmo_enabled": str(settings.digital_twin.tcp_gizmo.enabled).lower(),
}.items(),
))
diff --git a/src/iiwa_bringup/launch/supported/moveit_nodes.py b/src/iiwa_bringup/launch/supported/moveit_nodes.py
index ea78412..def2d18 100644
--- a/src/iiwa_bringup/launch/supported/moveit_nodes.py
+++ b/src/iiwa_bringup/launch/supported/moveit_nodes.py
@@ -1,5 +1,6 @@
from launch_ros.actions import Node
from moveit_configs_utils import MoveItConfigsBuilder
+from .tcp_gizmo_nodes import make_tcp_gizmo_nodes
def make_moveit_nodes(settings, robot_description: str, use_sim_time: bool):
@@ -46,8 +47,9 @@ def make_moveit_nodes(settings, robot_description: str, use_sim_time: bool):
"default_frame": settings.planning.default_frame,
"default_planner": settings.planning.default_planner,
"planning_attempts": settings.planning.planning_attempts,
+ "tcp_gizmo_enabled": use_sim_time and settings.digital_twin.tcp_gizmo.enabled,
},
],
)
- return moveit_configs, [move_group, move_to_pose_server]
+ return moveit_configs, [move_group, move_to_pose_server, *make_tcp_gizmo_nodes(settings, common_params, use_sim_time)]
diff --git a/src/iiwa_bringup/launch/supported/tcp_gizmo_nodes.py b/src/iiwa_bringup/launch/supported/tcp_gizmo_nodes.py
new file mode 100644
index 0000000..b29a018
--- /dev/null
+++ b/src/iiwa_bringup/launch/supported/tcp_gizmo_nodes.py
@@ -0,0 +1,67 @@
+"""TCP teleoperation exists only in a simulated, explicitly enabled launch."""
+from dataclasses import asdict
+import tempfile
+import xml.etree.ElementTree as ET
+
+import yaml
+from launch_ros.actions import Node
+from webots_ros2_driver.webots_controller import WebotsController
+
+
+def make_tcp_gizmo_nodes(settings, common_params, simulate):
+ cfg = settings.digital_twin.tcp_gizmo
+ if not simulate or not cfg.enabled:
+ return []
+
+ # The robot's Webots placement maps base_link into the Webots world.
+ robot = ET.Element('robot', name='tcp_gizmo')
+ ET.SubElement(robot, 'link', name='gizmo_base')
+ plugin = ET.SubElement(ET.SubElement(robot, 'webots'), 'plugin',
+ type='iiwa_utils.tcp_gizmo.WebotsTcpGizmo')
+ for key, value in {
+ 'translation': settings.digital_twin.webots.transform,
+ 'rotation': settings.digital_twin.webots.rotation,
+ 'frame': 'base_link',
+ 'state_timeout': cfg.state_timeout,
+ }.items():
+ ET.SubElement(plugin, key).text = str(value)
+ with tempfile.NamedTemporaryFile(mode='w', prefix='iiwa_tcp_gizmo_', suffix='.yaml', delete=False) as stream:
+ yaml.safe_dump({'/**': {'ros__parameters': {
+ 'robot_description': ET.tostring(robot, encoding='unicode'),
+ 'set_robot_state_publisher': False,
+ 'use_sim_time': True,
+ }}}, stream)
+ driver_params = stream.name
+
+ servo = {
+ 'move_group_name': settings.planning.planning_group,
+ 'publish_period': cfg.publish_period,
+ 'max_expected_latency': max(0.1, 3 * cfg.publish_period),
+ 'command_in_type': 'speed_units',
+ 'scale': {'linear': cfg.max_linear_speed, 'rotational': cfg.max_angular_speed},
+ 'incoming_command_timeout': cfg.command_timeout,
+ 'is_primary_planning_scene_monitor': False,
+ 'monitored_planning_scene_topic': '/monitored_planning_scene',
+ 'joint_topic': '/joint_states',
+ 'check_collisions': True,
+ 'check_octomap_collisions': False,
+ 'publish_joint_positions': True,
+ 'publish_joint_velocities': False,
+ 'use_smoothing': True,
+ 'joint_limit_margins': [0.05],
+ }
+ return [
+ Node(package='iiwa_planning', executable='tcp_gizmo', name='tcp_gizmo',
+ output='screen', parameters=[*common_params, {
+ **asdict(cfg), 'simulation': True,
+ # Tool xacros define tcp at the working end; without a tool
+ # it coincides with link_ee. General pose commands may use a
+ # different planning.pose_link, but the handle controls TCP.
+ 'pose_link': 'tcp',
+ 'planning_group': settings.planning.planning_group,
+ 'default_frame': 'base_link', 'moveit_servo': servo,
+ }]),
+ Node(package='iiwa_utils', executable='tcp_gizmo_spawner', output='screen'),
+ WebotsController(robot_name='tcp_gizmo',
+ parameters=[{'use_sim_time': True}, driver_params]),
+ ]
diff --git a/src/iiwa_bringup/launch/supported/webots_spawn.launch.py b/src/iiwa_bringup/launch/supported/webots_spawn.launch.py
index 018ab88..cd8b738 100644
--- a/src/iiwa_bringup/launch/supported/webots_spawn.launch.py
+++ b/src/iiwa_bringup/launch/supported/webots_spawn.launch.py
@@ -22,6 +22,7 @@ def _spawn_setup(context, *args, **kwargs):
world = LaunchConfiguration("world").perform(context)
controller = LaunchConfiguration("controller").perform(context)
initial_positions_file = LaunchConfiguration("initial_positions_file").perform(context)
+ gizmo_enabled = LaunchConfiguration("tcp_gizmo_enabled", default="false").perform(context) == "true"
robot_description = converter.load_robot_description(
model_path=description,
@@ -64,6 +65,10 @@ def _spawn_setup(context, *args, **kwargs):
driver = WebotsController(
robot_name=robot_name,
+ remappings=[
+ ('/iiwa_arm_controller/joint_trajectory', '/cobot/tcp_gizmo/controller_command'),
+ ('/iiwa_arm_controller/follow_joint_trajectory', '/cobot/tcp_gizmo/follow_joint_trajectory'),
+ ] if gizmo_enabled else [],
parameters=[
{"use_sim_time": True}, # dict → triggers --ros-args prefix
params_file, # file → --params-file (robot_description + controllers)
diff --git a/src/iiwa_bringup/package.xml b/src/iiwa_bringup/package.xml
index 01f4001..7e3b2ed 100644
--- a/src/iiwa_bringup/package.xml
+++ b/src/iiwa_bringup/package.xml
@@ -11,6 +11,7 @@
iiwa_utils
+ iiwa_planning
iiwa_config
iiwa_description
foxglove_bridge
diff --git a/src/iiwa_config/config/setting.yaml b/src/iiwa_config/config/setting.yaml
index cc6b634..9a7cda4 100644
--- a/src/iiwa_config/config/setting.yaml
+++ b/src/iiwa_config/config/setting.yaml
@@ -8,6 +8,13 @@ robot:
digital_twin:
+ tcp_gizmo:
+ enabled: true # TCP-гизма в Webots; только в режиме симуляции
+ publish_period: 0.032 # период управления [с]
+ command_timeout: 0.3 # максимальный возраст цели при отпускании мыши [с]
+ state_timeout: 0.5 # допустимый возраст состояния робота [с]
+ max_linear_speed: 0.50 # верхний предел скорости TCP [м/с]
+ max_angular_speed: 0.4 # скорость вращения TCP [рад/с]
webots:
world: pkg://iiwa_description/worlds/iiwa.wbt
# world: pkg://iiwa_description/worlds/simple_world.wbt
diff --git a/src/iiwa_config/config/tools.yaml b/src/iiwa_config/config/tools.yaml
index 8f3e014..c1d7a36 100644
--- a/src/iiwa_config/config/tools.yaml
+++ b/src/iiwa_config/config/tools.yaml
@@ -15,7 +15,7 @@ tools:
label: "Без захвата"
xacro: null
tip_link: "link_ee"
- tcp_link: "link_ee"
+ tcp_link: "tcp"
collisions: []
patron:
@@ -61,4 +61,3 @@ tools:
- [camera_hand, link2, Never]
- [camera_hand, link1, Never]
- [camera_hand, base_link, Never]
-
diff --git a/src/iiwa_description/urdf/tools/patron.xacro b/src/iiwa_description/urdf/tools/patron.xacro
index 4469d96..59448e4 100644
--- a/src/iiwa_description/urdf/tools/patron.xacro
+++ b/src/iiwa_description/urdf/tools/patron.xacro
@@ -107,7 +107,8 @@
-
+
+
@@ -174,4 +175,4 @@
-
\ No newline at end of file
+
diff --git a/src/iiwa_msgs/CMakeLists.txt b/src/iiwa_msgs/CMakeLists.txt
index 313fcef..190078a 100644
--- a/src/iiwa_msgs/CMakeLists.txt
+++ b/src/iiwa_msgs/CMakeLists.txt
@@ -7,6 +7,9 @@ find_package(geometry_msgs REQUIRED)
find_package(action_msgs REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
+ "msg/GizmoCommand.msg"
+ "msg/GizmoState.msg"
+ "srv/MotionPriority.srv"
"srv/MoveToPose.srv"
"srv/MoveToNamedPose.srv"
"action/MoveToPose.action"
diff --git a/src/iiwa_msgs/msg/GizmoCommand.msg b/src/iiwa_msgs/msg/GizmoCommand.msg
new file mode 100644
index 0000000..5ca1352
--- /dev/null
+++ b/src/iiwa_msgs/msg/GizmoCommand.msg
@@ -0,0 +1,3 @@
+# One committed TCP target on mouse release, with generation from GizmoState.
+geometry_msgs/PoseStamped target
+uint64 epoch
diff --git a/src/iiwa_msgs/msg/GizmoState.msg b/src/iiwa_msgs/msg/GizmoState.msg
new file mode 100644
index 0000000..c39434b
--- /dev/null
+++ b/src/iiwa_msgs/msg/GizmoState.msg
@@ -0,0 +1,6 @@
+# Actual TCP pose; enabled is false while any higher-priority motion owns control.
+geometry_msgs/PoseStamped actual
+bool enabled
+# Advances on rejection, controller completion, or higher-priority ownership.
+uint64 epoch
+string reason
diff --git a/src/iiwa_msgs/srv/MotionPriority.srv b/src/iiwa_msgs/srv/MotionPriority.srv
new file mode 100644
index 0000000..6c6d50e
--- /dev/null
+++ b/src/iiwa_msgs/srv/MotionPriority.srv
@@ -0,0 +1,7 @@
+# Acquire/renew or release a named high-priority lease. TTL uses wall time [s].
+string owner
+bool acquire
+float64 ttl
+---
+bool success
+string message
diff --git a/src/iiwa_planning/CMakeLists.txt b/src/iiwa_planning/CMakeLists.txt
index 8a4ad33..fb916f2 100644
--- a/src/iiwa_planning/CMakeLists.txt
+++ b/src/iiwa_planning/CMakeLists.txt
@@ -9,6 +9,8 @@ endif()
find_package(ament_cmake REQUIRED)
find_package(ament_cmake_python REQUIRED)
find_package(rclcpp REQUIRED)
+find_package(rclcpp_action REQUIRED)
+find_package(control_msgs REQUIRED)
find_package(rclpy REQUIRED)
find_package(moveit_ros_planning_interface REQUIRED)
find_package(moveit_core REQUIRED)
@@ -16,6 +18,16 @@ find_package(moveit_ros_planning REQUIRED)
find_package(moveit_msgs REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(iiwa_msgs REQUIRED)
+find_package(moveit_servo REQUIRED)
+find_package(trajectory_msgs REQUIRED)
+find_package(action_msgs REQUIRED)
+find_package(tf2_eigen REQUIRED)
+
+add_executable(tcp_gizmo src/tcp_gizmo.cpp)
+target_compile_features(tcp_gizmo PUBLIC cxx_std_17)
+target_include_directories(tcp_gizmo PRIVATE include)
+ament_target_dependencies(tcp_gizmo rclcpp rclcpp_action control_msgs iiwa_msgs geometry_msgs trajectory_msgs
+ action_msgs moveit_servo moveit_ros_planning moveit_core tf2_eigen)
add_executable(motion_planning_cpp src/motion_planning_cpp.cpp)
target_include_directories(motion_planning_cpp PUBLIC include)
@@ -33,6 +45,7 @@ ament_target_dependencies(motion_planning_cpp
ament_python_install_package(${PROJECT_NAME})
install(TARGETS
+ tcp_gizmo
motion_planning_cpp
DESTINATION lib/${PROJECT_NAME}
)
@@ -55,4 +68,14 @@ install(FILES
)
+if(BUILD_TESTING)
+ find_package(ament_cmake_gtest REQUIRED)
+ find_package(ament_cmake_pytest REQUIRED)
+ ament_add_gtest(test_tcp_gizmo_policy test/test_tcp_gizmo_policy.cpp)
+ target_compile_features(test_tcp_gizmo_policy PUBLIC cxx_std_17)
+ target_include_directories(test_tcp_gizmo_policy PRIVATE include)
+ ament_add_pytest_test(test_motion_priority test/test_motion_priority.py
+ APPEND_ENV PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR})
+endif()
+
ament_package()
diff --git a/src/iiwa_planning/iiwa_planning/motion_priority.py b/src/iiwa_planning/iiwa_planning/motion_priority.py
new file mode 100644
index 0000000..bbc45d1
--- /dev/null
+++ b/src/iiwa_planning/iiwa_planning/motion_priority.py
@@ -0,0 +1,146 @@
+"""Wall-clock motion reservations and serialization, independent of ROS simulation time."""
+import threading
+import uuid
+
+
+class LeaseError(RuntimeError):
+ pass
+
+
+class MotionLease:
+ """Renew one owner until context exit; transport errors fail closed."""
+ def __init__(self, exchange=None, *, on_lost=lambda: None, ttl=5.0,
+ renew_interval=1.0):
+ self.owner = uuid.uuid4().hex
+ self._exchange = exchange
+ self._on_lost = on_lost
+ self._ttl = ttl
+ self._interval = renew_interval
+ self._stop = threading.Event()
+ self._lost = threading.Event()
+ self._thread = None
+
+ def __enter__(self):
+ if self._exchange is not None:
+ self._exchange(self.owner, True, self._ttl)
+ self._thread = threading.Thread(target=self._renew, daemon=True)
+ self._thread.start()
+ return self
+
+ def _renew(self):
+ while not self._stop.wait(self._interval):
+ try:
+ self._exchange(self.owner, True, self._ttl)
+ except Exception:
+ self._lost.set()
+ try:
+ self._on_lost()
+ finally:
+ return
+
+ @property
+ def lost(self):
+ return self._lost.is_set()
+
+ def check(self):
+ if self.lost:
+ raise LeaseError('Motion priority reservation lost')
+
+ def __exit__(self, exc_type, exc, tb):
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=3.0)
+ try:
+ self._exchange(self.owner, False, self._ttl)
+ except Exception:
+ # Expiry bounds cleanup even if the service disappeared.
+ pass
+ if exc_type is None:
+ self.check()
+ return False
+
+
+class MotionSerialiser:
+ """One PlanningComponent owner; stop invalidates both planning and queued work."""
+ def __init__(self):
+ self.lock = threading.Lock()
+ self._state_lock = threading.Lock()
+ self._generation = 0
+
+ def generation(self):
+ with self._state_lock:
+ return self._generation
+
+ def check(self, generation):
+ if generation != self.generation():
+ raise LeaseError('Motion interrupted by stop')
+
+ def stop(self, callback):
+ with self._state_lock:
+ self._generation += 1
+ callback()
+
+
+def wait_response(future, timeout=2.0, *, available=lambda: True):
+ """Wait only from worker threads; an independent executor services the future."""
+ done = threading.Event()
+ future.add_done_callback(lambda _: done.set())
+ if timeout is None:
+ # Acquisition is acknowledged only after a committed gizmo trajectory
+ # finishes. Its duration can exceed the normal service RPC timeout.
+ while not done.wait(.2):
+ if not available():
+ future.cancel()
+ raise LeaseError('Motion priority service unavailable while waiting')
+ elif not done.wait(timeout):
+ future.cancel()
+ raise LeaseError('Motion priority service response timed out')
+ result = future.result()
+ if result is None:
+ raise LeaseError('Motion priority service returned no response')
+ return result
+
+
+class MotionPriorityClient:
+ """Dedicated executor prevents lease starvation under concurrent motion requests."""
+ def __init__(self, *, context=None):
+ from rclpy.node import Node
+ from rclpy.callback_groups import ReentrantCallbackGroup
+ from rclpy.executors import MultiThreadedExecutor
+ from iiwa_msgs.srv import MotionPriority
+ self._type = MotionPriority
+ self._node = Node('motion_priority_' + uuid.uuid4().hex[:12], context=context,
+ start_parameter_services=False, use_global_arguments=False)
+ self._client = self._node.create_client(
+ MotionPriority, '/cobot/tcp_gizmo/priority',
+ callback_group=ReentrantCallbackGroup())
+ self._executor = MultiThreadedExecutor(num_threads=2, context=context)
+ self._executor.add_node(self._node)
+ self._thread = threading.Thread(target=self._spin, daemon=True)
+ self._thread.start()
+
+ def _spin(self):
+ from rclpy.executors import ExternalShutdownException
+ try:
+ self._executor.spin()
+ except ExternalShutdownException:
+ pass
+
+ def create_stop_service(self, callback):
+ from std_srvs.srv import Trigger
+ return self._node.create_service(Trigger, "/cobot/stop", callback)
+
+ def exchange(self, owner, acquire, ttl):
+ if not self._client.wait_for_service(timeout_sec=0.5):
+ raise LeaseError('Motion priority service unavailable')
+ request = self._type.Request(owner=owner, acquire=acquire, ttl=ttl)
+ result = wait_response(
+ self._client.call_async(request), timeout=None if acquire else 2.0,
+ available=lambda: self._node.context.ok() and self._client.service_is_ready())
+ if not result.success:
+ raise LeaseError(result.message)
+
+ def close(self):
+ self._executor.shutdown(timeout_sec=2.0)
+ self._thread.join(timeout=2.0)
+ self._node.destroy_node()
diff --git a/src/iiwa_planning/include/iiwa_planning/tcp_gizmo_policy.hpp b/src/iiwa_planning/include/iiwa_planning/tcp_gizmo_policy.hpp
new file mode 100644
index 0000000..39e0b40
--- /dev/null
+++ b/src/iiwa_planning/include/iiwa_planning/tcp_gizmo_policy.hpp
@@ -0,0 +1,65 @@
+#pragma once
+#include
+#include
+#include