Запрос на слияние 'dev' (#2) из dev в master
This commit is contained in:
@@ -3,3 +3,10 @@ install/
|
||||
build/
|
||||
.vscode
|
||||
.idea
|
||||
venv
|
||||
.venv
|
||||
.claude
|
||||
__pycache__
|
||||
|
||||
*.egg-info
|
||||
**.FCBak
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [2026] [Daniel Robotics]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,13 +1,168 @@
|
||||
Подмена файла по пути обязательна: `/opt/ros/rolling/lib/webots_ros2_driver/ros2_supervisor.py`
|
||||
# Lightweight Cobot
|
||||
|
||||
> необходимо `warn` заменить на `warning` в логере
|
||||
> **Тестовая версия документации — будет дополнена**
|
||||
|
||||
ROS 2 пакеты для управления роботом **KUKA LBR IIWA 7 R800**: связь с реальным роботом через библиотеку [FRI](https://github.com/lbr-stack/fri) (Fast Robot Interface) и симуляция в среде [Webots](https://cyberbotics.com/).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">LBR IIWA 7 R800</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://raw.githubusercontent.com/lbr-stack/lbr_fri_ros2_stack/jazzy/lbr_fri_ros2_stack/doc/img/foxglove/iiwa7_r800.png" alt="LBR IIWA 7 R800" width="300">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
|
||||
| ОС | Дистрибутив ROS | Версия FRI |
|
||||
| :--- | :--- | :--- |
|
||||
| `Ubuntu 24.04` | `jazzy` | `1.15` |
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Для установки запустите скрипт одной командой:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitverse.ru/api/repos/daniel-robotics/lightweight-cobot/raw/branch/main/install.sh
|
||||
```
|
||||
|
||||
Скрипт установит ROS 2 Jazzy, Webots, соберёт рабочее пространство и установит CLI `cobot`.
|
||||
|
||||
---
|
||||
|
||||
## Команды `cobot`
|
||||
|
||||
После установки управляйте системой через CLI:
|
||||
|
||||
```
|
||||
cobot <команда>
|
||||
```
|
||||
|
||||
### Настройка
|
||||
|
||||
| Команда | Описание |
|
||||
| :--- | :--- |
|
||||
| `cobot setup` | Первоначальная настройка: документация, среда сборки, конфигурация робота |
|
||||
| `cobot local-setup` | Установка ROS 2 Jazzy локально и сборка проекта через colcon |
|
||||
| `cobot docker-setup` | Сборка или загрузка Docker-образов |
|
||||
| `cobot doc-setup` | Запуск или остановка сервера документации MkDocs |
|
||||
| `cobot robot-setup` | Интерактивная настройка файла `cobot-setting.yaml` |
|
||||
|
||||
### Запуск
|
||||
|
||||
| Команда | Описание |
|
||||
| :--- | :--- |
|
||||
| `cobot run` | Запуск контроллера робота или симулятора Webots (локально или через Docker) |
|
||||
|
||||
### Сборка
|
||||
|
||||
| Команда | Описание |
|
||||
| :--- | :--- |
|
||||
| `cobot rebuild` | Пересборка ROS 2 пакетов из `src/` с помощью colcon |
|
||||
| `cobot clean` | Удаление артефактов сборки (`build/` `install/` `log/`) |
|
||||
|
||||
### Управление
|
||||
|
||||
| Команда | Описание |
|
||||
| :--- | :--- |
|
||||
| `cobot update` | Получение последних изменений из удалённой ветки и переустановка `cobot` |
|
||||
| `cobot delete` | Удаление проекта, Docker-образов, контейнеров и опционально ROS 2 |
|
||||
|
||||
---
|
||||
|
||||
## Демонстрация
|
||||
|
||||
> GIF-анимации будут добавлены в следующих версиях
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="33%">Симуляция в Webots</th>
|
||||
<th align="center" width="33%">Управление по суставам</th>
|
||||
<th align="center" width="33%">Декартово управление</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><i>— скоро —</i></td>
|
||||
<td align="center"><i>— скоро —</i></td>
|
||||
<td align="center"><i>— скоро —</i></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Пакеты
|
||||
|
||||
| Пакет | Описание |
|
||||
| :--- | :--- |
|
||||
| `iiwa_bringup` | Launch-файлы: симуляция Webots, реальный робот через FRI, MoveIt и RViz |
|
||||
| `iiwa_config` | Конфигурационные файлы: MoveIt, контроллеры ros2_control, кинематика и общие параметры |
|
||||
| `iiwa_controller` | Hardware interface: управление суставами в реальном времени через FRI |
|
||||
| `iiwa_description` | URDF/XACRO описание робота и конфигурация мира Webots |
|
||||
| `iiwa_msgs` | ROS 2 интерфейсы: action-сообщения для движения по суставам и в декартовых координатах, сервисы именованных поз |
|
||||
| `iiwa_planning` | Планирование движения: C++ и Python узлы на базе MoveIt 2 (OMPL, Pilz, moveit_py) |
|
||||
| `iiwa_utils` | Утилиты системы: загрузка конфигурации, спавн объектов и камер в Webots, конвертация данных |
|
||||
| `iiwa_web` | Веб-интерфейс для мониторинга и дистанционного управления через браузер |
|
||||
|
||||
---
|
||||
|
||||
## Цитирование
|
||||
|
||||
Если вы используете этот проект в своей работе, пожалуйста, оставьте звёздочку ⭐ и укажите ссылку:
|
||||
|
||||
```bibtex
|
||||
@software{lightweight_cobot_2026,
|
||||
author = {Грабарь, Даниил},
|
||||
title = {Lightweight Cobot: ROS 2 stack for KUKA LBR IIWA 7},
|
||||
year = {2026},
|
||||
url = {https://gitverse.ru/daniel-robotics/lightweight-cobot}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Благодарности
|
||||
|
||||
Выражаем благодарность следующим организациям и грантам:
|
||||
|
||||
| Организация | Примечание |
|
||||
| :--- | :--- |
|
||||
| [Комсомольский-на-Амуре государственный университет](https://knastu.ru/) | Исследования проводились на базе КнАГУ |
|
||||
| [Российский научный фонд](https://rscf.ru/) | Работа выполнена при поддержке Российского научного фонда |
|
||||
| <!-- TODO --> | <!-- TODO --> |
|
||||
| <!-- TODO --> | <!-- TODO --> |
|
||||
|
||||
---
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Черновые команды — будут удалены в следующих версиях -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
## Черновые команды
|
||||
|
||||
> Вроде уже не обязательно
|
||||
Подмена файла по пути обязательна: `/opt/ros/rolling/lib/webots_ros2_driver/ros2_supervisor.py`
|
||||
необходимо `warn` заменить на `warning` в логере
|
||||
|
||||
```bash
|
||||
|
||||
sudo apt install -y ros-${ROS_DISTRO}-webots-ros2 \
|
||||
ros-${ROS_DISTRO}-ros2-control \
|
||||
ros-${ROS_DISTRO}-ros2-controllers \
|
||||
ros-${ROS_DISTRO}-moveit-* \
|
||||
ros-${ROS_DISTRO}-moveit \
|
||||
ros-${ROS_DISTRO}-moveit-py \
|
||||
ros-${ROS_DISTRO}-rmw-cyclonedds-cpp \
|
||||
ros-${ROS_DISTRO}-ament-cmake-clang-format \
|
||||
ros-${ROS_DISTRO}-rosbag2-storage-mcap \
|
||||
ros-${ROS_DISTRO}-librealsense2 \
|
||||
ros-${ROS_DISTRO}-realsense2* \
|
||||
libportaudio2 \
|
||||
|
||||
```
|
||||
|
||||
Установка moveit2 (внимательно проверяй)
|
||||
@@ -22,10 +177,133 @@ sudo apt install -y build-essential \
|
||||
python3-vcstool \
|
||||
wget
|
||||
|
||||
git clone https://github.com/moveit/moveit2.git
|
||||
vcs import --recursive < moveit2/moveit2.repos
|
||||
sudo apt remove ros-$ROS_DISTRO-moveit*
|
||||
rosdep install -r --from-paths ./src/ --ignore-src --rosdistro $ROS_DISTRO --os=ubuntu:noble -y
|
||||
# Not Using
|
||||
LC_ALL=C ros2 launch iiwa_moveit demo...
|
||||
|
||||
colcon build --mixin release
|
||||
sudo pip3 install transforms3d --break-system-packages
|
||||
```
|
||||
Отправка робота в точку:
|
||||
```bash
|
||||
ros2 action send_goal /iiwa/move_to_pose iiwa_msgs/action/MoveToPose \
|
||||
"{x: 0.5, y: 0.0, z: 0.5, a: 3.14, b: 0, c: 0, speed: 0.1, planner: 'ptp'}"
|
||||
|
||||
ros2 action send_goal --feedback /iiwa/move_to_joints iiwa_msgs/action/MoveToJoints \
|
||||
"{joints: [0.0, 0.0, 0.0, -1.57, 0.0, 1.57, 0.0], speed: 0.4}"
|
||||
|
||||
ros2 service call /iiwa/move_to_named iiwa_msgs/srv/MoveToNamedPose \
|
||||
"{name: 'home', speed: 0.5}"
|
||||
|
||||
ros2 service call /iiwa/move_to_named iiwa_msgs/srv/MoveToNamedPose \
|
||||
"{name: 'work', speed: 0.3}"
|
||||
|
||||
ros2 service call /iiwa/stop std_srvs/srv/Trigger "{}"
|
||||
```
|
||||
|
||||
Примеры использования `test_motion_sequence`:
|
||||
```bash
|
||||
# Просто выполнить последовательность без записи
|
||||
ros2 run iiwa_utils test_motion_sequence \
|
||||
--ros-args -p n_iterations:=3 \
|
||||
-p delay_between_iterations:=5.0
|
||||
|
||||
# Записать все доступные топики в bag
|
||||
ros2 run iiwa_utils test_motion_sequence \
|
||||
--ros-args -p n_iterations:=5 \
|
||||
-p delay_between_iterations:=5.0 \
|
||||
-p bag_path:=/tmp/iiwa_session
|
||||
|
||||
# Записать конкретные топики
|
||||
ros2 run iiwa_utils test_motion_sequence \
|
||||
--ros-args -p n_iterations:=5 \
|
||||
-p delay_between_iterations:=5.0 \
|
||||
-p bag_path:=/tmp/iiwa_session \
|
||||
-p topics:="['/joint_states', '/d455_top/color/image_raw', '/tf']"
|
||||
|
||||
# Использовать свой конфиг поз
|
||||
ros2 run iiwa_utils test_motion_sequence \
|
||||
--ros-args -p config_path:=/path/to/my_config.json \
|
||||
-p n_iterations:=1 \
|
||||
-p delay_between_iterations:=3.0 \
|
||||
-p bag_path:=/tmp/iiwa_session
|
||||
|
||||
```
|
||||
|
||||
Спавн объекта:
|
||||
```bash
|
||||
{
|
||||
"data": "Solid { name \"test_box2\" translation 0 1 0.5 children [ Shape { appearance PBRAppearance { baseColor 0.901961 0.380392 0 } geometry Box { size 0.1 0.1 0.1 } } ] boundingObject Box { size 0.1 0.1 0.1 } physics Physics { } }"
|
||||
}
|
||||
```
|
||||
|
||||
Спавн `.proto`:
|
||||
|
||||
|
||||
Можно заготовить готовые `.proto` файлы, и потом случайно спавнить объект по такому принципу + создать Node который будет вызываться и спавнить этот объекты. Может быть шаблон куда потом подставятся данные через `.format()`. По такому же принципу спавн человека. Остается понять, только задать область спавна относительно робта
|
||||
|
||||
Установка зависимостей для сборки пакетов:
|
||||
|
||||
```bash
|
||||
rosdep install --from-paths src --ignore-src -r -y --os=ubuntu:jammy
|
||||
```
|
||||
|
||||
Запись данных в rosbag:
|
||||
|
||||
```bash
|
||||
$ sudo apt-get install ros-$ROS_DISTRO-rosbag2-storage-mcap
|
||||
|
||||
# Record all topics
|
||||
ros2 bag record -a --storage mcap -o my_session
|
||||
|
||||
# Record specific topics
|
||||
ros2 bag record \
|
||||
/camera/image_raw \
|
||||
/lidar/points \
|
||||
/imu/data \
|
||||
--storage mcap \
|
||||
-o robot_drive_session
|
||||
|
||||
|
||||
# LZ4 = faster write, moderate compression (good for real-time recording)
|
||||
ros2 bag record -a --storage mcap \
|
||||
--compression-mode file \
|
||||
--compression-format lz4 \
|
||||
-o compressed_session
|
||||
|
||||
# Zstandard = slower write, better compression (good for post-processing)
|
||||
ros2 bag record -a --storage mcap \
|
||||
--compression-mode file \
|
||||
--compression-format zstd \
|
||||
-o compressed_zstd_session
|
||||
|
||||
# convert existing rosbag2 to mcap format
|
||||
ros2 bag convert \
|
||||
-i robot_drive_old/ \
|
||||
-o robot_drive_mcap/ \
|
||||
--output-options '{"storage_id": "mcap"}'
|
||||
```
|
||||
|
||||
|
||||
<!-- Generate Doc -->
|
||||
```bash
|
||||
chmod +x doc/build_docs.sh
|
||||
|
||||
./build_docs.sh #Собирает статику в mkdocs/site/
|
||||
./build_docs.sh build # То же самое явно
|
||||
./build_docs.sh serve # Live-preview с авто-перезагрузкой
|
||||
```
|
||||
|
||||
<!-- iiwa controller - docker -->
|
||||
<!-- Lightweight cobot -->
|
||||
```bash
|
||||
docker build -t evilfisru/lwc:... -f docker/jazzy/.../Dockerfile .
|
||||
|
||||
docker run -it --rm --network host evilfisru/lwa:jazzy-lwa7-noble
|
||||
```
|
||||
|
||||
```bash
|
||||
# release - src/build/log удаляются (по умолчанию)
|
||||
`docker build -t my-image .`
|
||||
|
||||
# dev - src остаётся для отладки
|
||||
`docker build --build-arg BUILD_TYPE=dev -t my-image .`
|
||||
```
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Lightweight Cobot
|
||||
|
||||
> **Draft documentation — work in progress**
|
||||
|
||||
ROS 2 packages for controlling the **KUKA LBR IIWA 7 R800**: communication with the real robot via [FRI](https://github.com/lbr-stack/fri) (Fast Robot Interface) and simulation in [Webots](https://cyberbotics.com/).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">LBR IIWA 7 R800</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://raw.githubusercontent.com/lbr-stack/lbr_fri_ros2_stack/jazzy/lbr_fri_ros2_stack/doc/img/foxglove/iiwa7_r800.png" alt="LBR IIWA 7 R800" width="300">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
| OS | ROS Distribution | FRI Version |
|
||||
| :--- | :--- | :--- |
|
||||
| `Ubuntu 24.04` | `jazzy` | `1.15` |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Install everything with a single command:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://gitverse.ru/api/repos/daniel-robotics/lightweight-cobot/raw/branch/main/install.sh)
|
||||
```
|
||||
|
||||
The script installs ROS 2 Jazzy, Webots, builds the workspace, and installs the `cobot` CLI.
|
||||
|
||||
---
|
||||
|
||||
## `cobot` Commands
|
||||
|
||||
After installation, manage the system via CLI:
|
||||
|
||||
```
|
||||
cobot <command>
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
| Command | Description |
|
||||
| :--- | :--- |
|
||||
| `cobot setup` | First-time setup: documentation server, build environment, robot config |
|
||||
| `cobot local-setup` | Install ROS 2 Jazzy natively and build the project with colcon |
|
||||
| `cobot docker-setup` | Build or pull Docker images |
|
||||
| `cobot doc-setup` | Deploy or stop the MkDocs documentation server |
|
||||
| `cobot robot-setup` | Configure `cobot-setting.yaml` interactively |
|
||||
|
||||
### Run
|
||||
|
||||
| Command | Description |
|
||||
| :--- | :--- |
|
||||
| `cobot run` | Launch the robot controller or Webots simulator (local or Docker) |
|
||||
|
||||
### Build
|
||||
|
||||
| Command | Description |
|
||||
| :--- | :--- |
|
||||
| `cobot rebuild` | Rebuild ROS 2 packages in `src/` with colcon |
|
||||
| `cobot clean` | Remove colcon build artifacts (`build/` `install/` `log/`) |
|
||||
|
||||
### Management
|
||||
|
||||
| Command | Description |
|
||||
| :--- | :--- |
|
||||
| `cobot update` | Pull latest changes from the remote git branch and reinstall `cobot` |
|
||||
| `cobot delete` | Remove the project, Docker images, containers, and optionally ROS 2 |
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
|
||||
> GIF animations will be added in upcoming releases
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="33%">Webots Simulation</th>
|
||||
<th align="center" width="33%">Joint-Space Control</th>
|
||||
<th align="center" width="33%">Cartesian Control</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><i>— coming soon —</i></td>
|
||||
<td align="center"><i>— coming soon —</i></td>
|
||||
<td align="center"><i>— coming soon —</i></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
| :--- | :--- |
|
||||
| `iiwa_bringup` | Launch files: Webots simulation, real robot via FRI, MoveIt motion planning and RViz visualization |
|
||||
| `iiwa_config` | Configuration files: MoveIt, ros2_control controllers, kinematics and general system settings |
|
||||
| `iiwa_controller` | Hardware interface: real-time joint control via FRI within the ros2_control ecosystem |
|
||||
| `iiwa_description` | URDF/XACRO robot description and Webots world configuration |
|
||||
| `iiwa_msgs` | ROS 2 interfaces: action messages for joint-space and Cartesian motion, services for named poses |
|
||||
| `iiwa_planning` | Motion planning: C++ and Python nodes built on MoveIt 2 (OMPL, Pilz, moveit_py) |
|
||||
| `iiwa_utils` | System utilities: configuration loading, object and camera spawning in Webots, data conversion |
|
||||
| `iiwa_web` | Web interface for monitoring and remote control of the cobot via browser |
|
||||
|
||||
---
|
||||
|
||||
## Citation
|
||||
|
||||
If you use this project in your work, please leave a star ⭐ and cite it:
|
||||
|
||||
```bibtex
|
||||
@software{lightweight_cobot_2026,
|
||||
author = {Hrabar, Daniil},
|
||||
title = {Lightweight Cobot: ROS 2 stack for KUKA LBR IIWA 7},
|
||||
year = {2026},
|
||||
url = {https://gitverse.ru/daniel-robotics/lightweight-cobot}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
We gratefully acknowledge the support of the following organizations and grants:
|
||||
|
||||
| Organization | Notes |
|
||||
| :--- | :--- |
|
||||
| [Komsomolsk-on-Amur State University (KnAGU)](https://knastu.ru/) | Research was conducted at KnAGU |
|
||||
| [Russian Science Foundation (RSF)](https://rscf.ru/) | Work supported by the Russian Science Foundation |
|
||||
| <!-- TODO --> | <!-- TODO --> |
|
||||
| <!-- TODO --> | <!-- TODO --> |
|
||||
@@ -0,0 +1,73 @@
|
||||
robot:
|
||||
name: "iiwa7"
|
||||
ip: "192.170.10.2"
|
||||
port: 30200
|
||||
command_mode: "position" # torque, position
|
||||
fri_cycle_ms: 10 # период FRI-цикла: 5 мс (200 Гц) или 10 мс (100 Гц)
|
||||
joint_position_tau: 0.04 # EMA фильтр позиций [с]: сглаживает команды перед отправкой в FRI
|
||||
joint_velocity_tau: 0.01 # EMA фильтр скорости [с]: убирает выбросы конечных разностей
|
||||
active_controller: "jtc" # "jtc" = MoveIt/JointTrajectoryController, "forward" = ForwardCommandController
|
||||
description: pkg://iiwa_description/urdf/iiwa7.urdf.xacro
|
||||
|
||||
|
||||
digital_twin:
|
||||
webots:
|
||||
world: pkg://iiwa_description/worlds/iiwa.wbt
|
||||
# world: pkg://iiwa_description/worlds/simple_world.wbt
|
||||
transform: "-0.25 0 0.79"
|
||||
rotation: "0 0 1 0"
|
||||
controller_timer: "50"
|
||||
|
||||
cameras:
|
||||
- pkg://iiwa_config/config/cameras/d455_top.yaml
|
||||
|
||||
rviz:
|
||||
config: pkg://iiwa_config/config/rviz/rviz_moveit.rviz
|
||||
|
||||
controller:
|
||||
controller_path: pkg://iiwa_config/config/moveit/iiwa_controller.yaml
|
||||
|
||||
moveit:
|
||||
srdf: pkg://iiwa_config/config/moveit/iiwa7.srdf
|
||||
kinematics: pkg://iiwa_config/config/moveit/kinematics.yaml
|
||||
joint_limits: pkg://iiwa_config/config/moveit/joint_limits.yaml
|
||||
pilz_limits: pkg://iiwa_config/config/moveit/pilz_cartesian_limits.yaml
|
||||
initial_positions: pkg://iiwa_config/config/moveit/initial_positions.yaml
|
||||
moveit_controllers: pkg://iiwa_config/config/moveit/moveit_controllers.yaml
|
||||
moveit_cpp: pkg://iiwa_config/config/moveit/moveit_cpp.yaml
|
||||
|
||||
|
||||
planning:
|
||||
pose_link: "tcp" # TCP-линк для декартовых целей
|
||||
planning_group: "iiwa_arm" # Группа планирования из SRDF
|
||||
default_frame: "base_link" # Система отсчёта по умолчанию
|
||||
default_planner: "ompl" # Планировщик по умолчанию
|
||||
planning_attempts: 3 # Число попыток планирования
|
||||
|
||||
foxglove:
|
||||
enabled: true # Запускать ли foxglove_bridge вместе с роботом
|
||||
port: 8765 # WebSocket-порт, к которому подключается Foxglove Studio (по умолчанию 8765)
|
||||
debug: false # Включить подробное логирование bridge-процесса
|
||||
address: 0.0.0.0 # Адрес, на котором слушает сервер. 0.0.0.0 — все интерфейсы, 127.0.0.1 — только локально
|
||||
tls: false # Включить TLS-шифрование соединения (нужны certfile + keyfile)
|
||||
certfile: "" # Путь к SSL-сертификату (нужен только при tls: true)Путь к SSL-сертификату (нужен только при tls: true)
|
||||
keyfile: "" # Путь к SSL-сертификату (нужен только при tls: true)
|
||||
topic_whitelist: ['.*'] # Regex-список топиков, которые bridge публикует клиенту
|
||||
param_whitelist: ['.*'] # Regex-список ROS-параметров, видимых клиенту
|
||||
service_whitelist: ['.*'] # Regex-список сервисов, доступных клиенту
|
||||
client_topic_whitelist: ['.*'] # Regex-список топиков, в которые клиент может публиковать (clientPublish)
|
||||
min_qos_depth: 1 # Минимальная глубина QoS-очереди при подписке bridge на топик
|
||||
max_qos_depth: 10 # Максимальная глубина QoS-очереди
|
||||
num_threads: 0 # Число потоков исполнения. 0 = автоматически по числу CPU
|
||||
send_buffer_limit: 10000000 # Максимальный размер буфера отправки в байтах (защита от OOM при медленном клиенте)
|
||||
use_sim_time: false # Использовать симуляционное время /clock вместо системного
|
||||
capabilities: # Список возможностей, открытых клиенту
|
||||
- clientPublish
|
||||
- parameters
|
||||
- parametersSubscribe
|
||||
- services
|
||||
- connectionGraph
|
||||
- assets
|
||||
include_hidden: false # Показывать клиенту скрытые топики и сервисы (начинаются с _)
|
||||
asset_uri_allowlist: ['^package://(?:[-\w%]+/)*[-\w%.]+\.(?:dae|fbx|glb|gltf|jpeg|jpg|mtl|obj|png|stl|tif|tiff|urdf|webp|xacro)$'] # Regex-список URI вида package://..., из которых bridge разрешает отдавать файлы-ассеты (URDF, mesh и т.п.)
|
||||
ignore_unresponsive_param_nodes: true # Не падать, если нода не отвечает на запросы параметров (защита от зависания при старте)
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
# Import each command module so we can register its subparser.
|
||||
# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер.
|
||||
from cobot.commands import clean as cmd_clean
|
||||
from cobot.commands import delete as cmd_delete
|
||||
from cobot.commands import docker_setup as cmd_docker_setup
|
||||
from cobot.commands import doc_setup as cmd_doc_setup
|
||||
from cobot.commands import local_setup as cmd_local_setup
|
||||
from cobot.commands import rebuild as cmd_rebuild
|
||||
from cobot.commands import robot_setup as cmd_robot_setup
|
||||
from cobot.commands import run as cmd_run
|
||||
from cobot.commands import setup as cmd_setup
|
||||
from cobot.commands import update as cmd_update
|
||||
|
||||
# Command groups shown in --help output.
|
||||
# Add new commands here when introducing other categories.
|
||||
# Группы команд, отображаемые в --help.
|
||||
# Добавляйте новые команды сюда при создании новых категорий.
|
||||
_GROUPS = [
|
||||
("Setup", [
|
||||
("setup", "first-time setup: docs, build environment, robot config"),
|
||||
("local-setup", "install ROS2 Jazzy natively and build the project with colcon"),
|
||||
("docker-setup", "build or pull Docker images for KUKA iiwa7"),
|
||||
("doc-setup", "deploy or stop the MkDocs documentation server"),
|
||||
("robot-setup", "configure cobot-setting.yaml interactively"),
|
||||
]),
|
||||
("Run", [
|
||||
("run", "launch the robot controller or Webots simulator (local or Docker)"),
|
||||
]),
|
||||
("Build", [
|
||||
("rebuild", "rebuild ROS2 packages in src/ with colcon"),
|
||||
("clean", "remove colcon build artifacts (build/ install/ log/)"),
|
||||
]),
|
||||
("Management", [
|
||||
("update", "pull latest changes from the remote git branch and reinstall cobot"),
|
||||
("delete", "remove the project, Docker images, containers, and optionally ROS2"),
|
||||
]),
|
||||
]
|
||||
|
||||
_DESCRIPTION = "Lightweight Cobot"
|
||||
|
||||
|
||||
# Custom --help action that prints commands grouped by category instead of a flat list.
|
||||
# Кастомный обработчик --help, который выводит команды по категориям, а не одним списком.
|
||||
class _GroupedHelpAction(argparse.Action):
|
||||
"""Custom argparse action that replaces the default --help output with a grouped
|
||||
command listing organized by category (Setup, Run, Management).
|
||||
Кастомный обработчик argparse, заменяющий стандартный вывод --help на сгруппированный
|
||||
список команд по категориям (Setup, Run, Management).
|
||||
"""
|
||||
|
||||
def __init__(self, option_strings, dest, default=None, required=False, help=None):
|
||||
super().__init__(
|
||||
option_strings=option_strings,
|
||||
dest=dest,
|
||||
nargs=0,
|
||||
default=default,
|
||||
required=required,
|
||||
help=help,
|
||||
)
|
||||
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
print(f"usage: cobot [-h] <command> ...\n")
|
||||
print(f"{_DESCRIPTION}\n")
|
||||
for group_title, commands in _GROUPS:
|
||||
print(f"{group_title} commands:")
|
||||
for cmd, help_text in commands:
|
||||
print(f" {cmd:<22} {help_text}")
|
||||
print()
|
||||
print("options:")
|
||||
print(" -h, --help show this help message and exit")
|
||||
parser.exit()
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the cobot CLI. Parses arguments and dispatches to the correct command.
|
||||
Точка входа CLI cobot. Разбирает аргументы и вызывает нужную команду.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cobot",
|
||||
description=_DESCRIPTION,
|
||||
add_help=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-h", "--help",
|
||||
action=_GroupedHelpAction,
|
||||
default=argparse.SUPPRESS,
|
||||
help="show this help message and exit",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
||||
subparsers.required = True
|
||||
|
||||
_register_commands(subparsers)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
def _register_commands(subparsers):
|
||||
"""Register all command subparsers. Each command module calls register() which adds its
|
||||
own subparser and sets args.func to its run() function.
|
||||
Регистрирует все подпарсеры команд. Каждый модуль вызывает register(), добавляет свой
|
||||
подпарсер и устанавливает args.func на свою функцию run().
|
||||
"""
|
||||
# Each module registers its own subparser and sets args.func to its run() function.
|
||||
# Каждый модуль регистрирует свой подпарсер и устанавливает args.func на свою функцию run().
|
||||
cmd_setup.register(subparsers)
|
||||
cmd_local_setup.register(subparsers)
|
||||
cmd_docker_setup.register(subparsers)
|
||||
cmd_doc_setup.register(subparsers)
|
||||
cmd_robot_setup.register(subparsers)
|
||||
cmd_run.register(subparsers)
|
||||
cmd_rebuild.register(subparsers)
|
||||
cmd_clean.register(subparsers)
|
||||
cmd_update.register(subparsers)
|
||||
cmd_delete.register(subparsers)
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, MultiPickScreen
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
_DIR_OPTIONS = ["build/", "install/", "log/"]
|
||||
_DIR_MAP = {
|
||||
"build/": _PROJECT_DIR / "build",
|
||||
"install/": _PROJECT_DIR / "install",
|
||||
"log/": _PROJECT_DIR / "log",
|
||||
}
|
||||
|
||||
|
||||
def _task_clean(screen: LogScreen, dirs: List[str]) -> None:
|
||||
"""Delete the selected top-level directories.
|
||||
Удаляет выбранные директории верхнего уровня.
|
||||
"""
|
||||
try:
|
||||
screen.write("[bold]Cleaning build artifacts[/bold]\n")
|
||||
total = len(dirs)
|
||||
for i, label in enumerate(dirs):
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.set_progress(i / total * 100, f"Removing {label}...")
|
||||
path = _DIR_MAP[label]
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
screen.write(f"[green][ok][/green] Removed {label}")
|
||||
else:
|
||||
screen.write(f"[dim]Not found: {label}[/dim]")
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("\n[green]Done.[/green]")
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
class _CleanApp(App[None]):
|
||||
"""Clean wizard: lets the user pick which directories to delete, then removes them.
|
||||
Мастер очистки: позволяет выбрать директории для удаления, затем удаляет их.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, all_dirs: bool):
|
||||
super().__init__()
|
||||
# True = skip the question and delete everything right away.
|
||||
# True = пропустить вопрос и сразу удалить всё.
|
||||
self._all_dirs = all_dirs
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._all_dirs:
|
||||
self._start(_DIR_OPTIONS)
|
||||
else:
|
||||
self._ask_dirs()
|
||||
|
||||
def _ask_dirs(self) -> None:
|
||||
self.push_screen(
|
||||
MultiPickScreen(
|
||||
"clean",
|
||||
"Which directories to delete?",
|
||||
_DIR_OPTIONS,
|
||||
note="Space — toggle · Enter — confirm",
|
||||
),
|
||||
self._got_dirs,
|
||||
)
|
||||
|
||||
def _got_dirs(self, dirs: Optional[List[str]]) -> None:
|
||||
if not dirs:
|
||||
self.exit()
|
||||
return
|
||||
self._start(dirs)
|
||||
|
||||
def _start(self, dirs: List[str]) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
"Cleaning",
|
||||
lambda s: _task_clean(s, dirs),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"clean",
|
||||
help="Remove colcon build artifacts (build/ install/ log/)",
|
||||
)
|
||||
p.add_argument(
|
||||
"target",
|
||||
nargs="?",
|
||||
metavar="all",
|
||||
default=None,
|
||||
help="'all' to skip the prompt and delete all three directories at once",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the clean command.
|
||||
Точка входа для команды clean.
|
||||
"""
|
||||
_CleanApp(all_dirs=(getattr(args, "target", None) == "all")).run()
|
||||
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
# Stop and remove all Docker containers whose name contains "lwc".
|
||||
# Останавливаем и удаляем все Docker-контейнеры, чьё имя содержит "lwc".
|
||||
def _stop_docker_containers(write) -> None:
|
||||
"""Stop and force-remove all Docker containers whose name contains "lwc".
|
||||
Останавливает и принудительно удаляет все Docker-контейнеры с "lwc" в имени.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Stopping Docker containers...")
|
||||
result = subprocess.run(
|
||||
["docker", "ps", "-a", "--filter", "name=lwc", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
containers = [c for c in result.stdout.strip().splitlines() if c]
|
||||
if not containers:
|
||||
write("[dim]No project containers found.[/dim]")
|
||||
return
|
||||
for name in containers:
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
|
||||
write(f"[green][ok][/green] Removed container: {name}")
|
||||
|
||||
|
||||
# Remove all Docker images whose repository or tag contains "lwc".
|
||||
# Удаляем все Docker-образы, репозиторий или тег которых содержит "lwc".
|
||||
def _remove_docker_images(write) -> None:
|
||||
"""Force-remove all local Docker images whose name or tag contains "lwc".
|
||||
Принудительно удаляет все локальные Docker-образы с "lwc" в имени или теге.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Removing Docker images...")
|
||||
result = subprocess.run(
|
||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
project_images = [
|
||||
img for img in result.stdout.strip().splitlines()
|
||||
if "lwc" in img.lower()
|
||||
]
|
||||
if not project_images:
|
||||
write("[dim]No project images found.[/dim]")
|
||||
return
|
||||
for img in project_images:
|
||||
subprocess.run(["docker", "rmi", "-f", img], capture_output=True)
|
||||
write(f"[green][ok][/green] Removed image: {img}")
|
||||
|
||||
|
||||
# Remove the Docker volume that stores the Webots asset cache.
|
||||
# Удаляем Docker volume с кэшем ассетов Webots.
|
||||
def _remove_webots_volume(write) -> None:
|
||||
"""Remove the lwc-webots-cache Docker volume if it exists. Skips silently if absent.
|
||||
Удаляет Docker volume lwc-webots-cache если он существует. Молча пропускает если отсутствует.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "volume", "inspect", "lwc-webots-cache"],
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
write("[dim]Webots cache volume not found, skipping.[/dim]")
|
||||
return
|
||||
subprocess.run(["docker", "volume", "rm", "lwc-webots-cache"], capture_output=True)
|
||||
write("[green][ok][/green] Removed Docker volume: lwc-webots-cache")
|
||||
|
||||
|
||||
# Remove ROS2 Jazzy packages via apt and clean up the source line from shell configs.
|
||||
# Uses the official removal commands to also unregister the ROS2 apt repository.
|
||||
# Удаляем пакеты ROS2 Jazzy через apt и очищаем строку source из конфигов оболочки.
|
||||
# Используем официальные команды удаления, которые также снимают регистрацию apt-репозитория ROS2.
|
||||
def _remove_ros2(write) -> None:
|
||||
"""Remove all ros-jazzy-* packages, the ros2-apt-source package, and the ROS2 source
|
||||
line from .bashrc / .zshrc. Does nothing if /opt/ros/jazzy is not present.
|
||||
Удаляет все пакеты ros-jazzy-*, пакет ros2-apt-source и строку source ROS2 из
|
||||
.bashrc / .zshrc. Ничего не делает если /opt/ros/jazzy отсутствует.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Removing ROS2 Jazzy packages...")
|
||||
if not Path("/opt/ros/jazzy").exists():
|
||||
write("[dim]ROS2 Jazzy not found, skipping.[/dim]")
|
||||
else:
|
||||
# Remove all ros-jazzy-* packages matched by the apt regex pattern ~n<name>.
|
||||
# Удаляем все пакеты ros-jazzy-* по regex-паттерну apt ~n<имя>.
|
||||
subprocess.run(
|
||||
["sudo", "apt", "remove", "-y", "~nros-jazzy-*"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
|
||||
write("[green][ok][/green] ROS2 Jazzy packages removed")
|
||||
|
||||
# Remove the ROS2 apt source package that added the repository.
|
||||
# Удаляем пакет apt-источника ROS2, который добавил репозиторий.
|
||||
subprocess.run(
|
||||
["sudo", "apt", "remove", "-y", "ros2-apt-source"],
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(["sudo", "apt", "update", "-qq"], capture_output=True)
|
||||
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
|
||||
write("[green][ok][/green] ROS2 apt repository removed")
|
||||
|
||||
# Clean up the source line that local-setup added to the shell config.
|
||||
# Очищаем строку source, добавленную local-setup в конфиг оболочки.
|
||||
source_line = "source /opt/ros/jazzy/setup.bash"
|
||||
for rc_name in [".bashrc", ".zshrc"]:
|
||||
rc = Path.home() / rc_name
|
||||
if not rc.exists():
|
||||
continue
|
||||
content = rc.read_text()
|
||||
if source_line not in content:
|
||||
continue
|
||||
# Remove the whole block that was added by local-setup, not just the single line.
|
||||
# Удаляем весь блок добавленный local-setup, а не только одну строку.
|
||||
new_content = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n")
|
||||
new_content = new_content.replace(source_line, "")
|
||||
rc.write_text(new_content)
|
||||
write(f"[green][ok][/green] Cleaned up ~/{rc_name}")
|
||||
|
||||
|
||||
# Remove Webots from the system via apt.
|
||||
# Удаляем Webots из системы через apt.
|
||||
def _remove_webots(write) -> None:
|
||||
"""Remove the webots package via apt, run autoremove, and clean up WEBOTS_HOME
|
||||
from .bashrc / .zshrc. Skips if webots is not found on PATH.
|
||||
Удаляет пакет webots через apt, запускает autoremove и очищает WEBOTS_HOME из
|
||||
.bashrc / .zshrc. Пропускает если webots не найден в PATH.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Removing Webots...")
|
||||
if not shutil.which("webots"):
|
||||
write("[dim]Webots not found, skipping.[/dim]")
|
||||
return
|
||||
subprocess.run(["sudo", "apt", "remove", "-y", "webots"], capture_output=True)
|
||||
subprocess.run(["sudo", "apt", "autoremove", "-y"], capture_output=True)
|
||||
write("[green][ok][/green] Webots removed")
|
||||
|
||||
# Remove the WEBOTS_HOME block that install_webots.sh added to shell configs.
|
||||
# Удаляем блок WEBOTS_HOME, добавленный install_webots.sh в конфиги оболочки.
|
||||
for rc_name in [".bashrc", ".zshrc"]:
|
||||
rc = Path.home() / rc_name
|
||||
if not rc.exists():
|
||||
continue
|
||||
content = rc.read_text()
|
||||
if "WEBOTS_HOME" not in content:
|
||||
continue
|
||||
new_content = content.replace("\n# Webots\nexport WEBOTS_HOME=/usr/local/webots\n", "\n")
|
||||
new_content = new_content.replace("export WEBOTS_HOME=/usr/local/webots\n", "")
|
||||
new_content = new_content.replace("# Webots\n", "")
|
||||
if new_content != content:
|
||||
rc.write_text(new_content)
|
||||
write(f"[green][ok][/green] Cleaned WEBOTS_HOME from ~/{rc_name}")
|
||||
|
||||
|
||||
# Uninstall the cobot CLI from the uv tool store.
|
||||
# Удаляем cobot CLI из хранилища инструментов uv.
|
||||
def _uninstall_cobot(write) -> None:
|
||||
"""Uninstall the lightweight-cobot package from the uv tool store.
|
||||
Удаляет пакет lightweight-cobot из хранилища инструментов uv.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Uninstalling cobot CLI...")
|
||||
result = subprocess.run(
|
||||
["uv", "tool", "uninstall", "lightweight-cobot"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
write("[green][ok][/green] cobot uninstalled")
|
||||
else:
|
||||
write(f"[yellow]Warning:[/yellow] {result.stderr.strip() or 'could not uninstall cobot'}")
|
||||
|
||||
|
||||
# Delete the entire project directory from disk.
|
||||
# Удаляем всю директорию проекта с диска.
|
||||
def _remove_project_dir(write) -> None:
|
||||
"""Recursively delete the entire project directory (_PROJECT_DIR) from disk.
|
||||
Рекурсивно удаляет всю директорию проекта (_PROJECT_DIR) с диска.
|
||||
"""
|
||||
write(f"[cyan][*][/cyan] Removing project directory...")
|
||||
try:
|
||||
shutil.rmtree(_PROJECT_DIR)
|
||||
write(f"[green][ok][/green] Removed {_PROJECT_DIR}")
|
||||
except Exception as exc:
|
||||
write(f"[red]Failed:[/red] {exc}")
|
||||
raise
|
||||
|
||||
|
||||
# Run all deletion steps in order.
|
||||
# Progress ranges are split evenly across the active steps so the bar always reaches 100%.
|
||||
# Выполняем все шаги удаления по порядку.
|
||||
# Диапазоны прогресса делятся равномерно между активными шагами, чтобы бар всегда доходил до 100%.
|
||||
def _task_delete(screen: LogScreen, remove_ros: bool, remove_webots: bool) -> None:
|
||||
"""Worker function that runs inside LogScreen. Runs all deletion steps in order:
|
||||
containers -> images -> ROS2 (optional) -> Webots (optional) -> cobot CLI -> project dir.
|
||||
Рабочая функция внутри LogScreen. Выполняет все шаги удаления по порядку:
|
||||
контейнеры -> образы -> ROS2 (опционально) -> Webots (опционально) -> cobot CLI -> директория.
|
||||
"""
|
||||
try:
|
||||
screen.set_progress(0, "Stopping containers...")
|
||||
_stop_docker_containers(screen.write)
|
||||
_remove_webots_volume(screen.write)
|
||||
|
||||
screen.set_progress(20, "Removing Docker images...")
|
||||
_remove_docker_images(screen.write)
|
||||
|
||||
pct = 40
|
||||
if remove_ros:
|
||||
screen.set_progress(pct, "Removing ROS2 Jazzy...")
|
||||
_remove_ros2(screen.write)
|
||||
pct = 65
|
||||
|
||||
if remove_webots:
|
||||
screen.set_progress(pct, "Removing Webots...")
|
||||
_remove_webots(screen.write)
|
||||
pct = 75
|
||||
|
||||
screen.set_progress(pct, "Uninstalling cobot CLI...")
|
||||
_uninstall_cobot(screen.write)
|
||||
|
||||
screen.set_progress(88, "Removing project directory...")
|
||||
_remove_project_dir(screen.write)
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("\n[green]Project fully removed.[/green]")
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# Multi-step confirmation wizard before anything is deleted.
|
||||
# Shows extra questions only when the relevant software is actually installed.
|
||||
# Многошаговый мастер подтверждения перед удалением.
|
||||
# Дополнительные вопросы показываются только если соответствующее ПО действительно установлено.
|
||||
class _DeleteApp(App[None]):
|
||||
"""Deletion wizard that asks for confirmation, then optionally asks about ROS2 and Webots,
|
||||
then launches LogScreen running _task_delete.
|
||||
Мастер удаления: просит подтверждение, затем опционально спрашивает про ROS2 и Webots,
|
||||
затем запускает LogScreen с _task_delete.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Confirm deletion",
|
||||
"This will permanently delete the project, Docker images and containers. Are you sure?",
|
||||
["No, cancel", "Yes, delete everything"],
|
||||
"No, cancel",
|
||||
),
|
||||
self._on_confirm,
|
||||
)
|
||||
|
||||
def _on_confirm(self, choice: Optional[str]) -> None:
|
||||
if choice is None or choice.startswith("No"):
|
||||
self.exit()
|
||||
return
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"ROS2 Jazzy",
|
||||
"Also remove ROS2 Jazzy from the system?",
|
||||
["No, keep ROS2", "Yes, remove ROS2 Jazzy"],
|
||||
"No, keep ROS2",
|
||||
),
|
||||
self._on_ros_choice,
|
||||
)
|
||||
|
||||
def _on_ros_choice(self, choice: Optional[str]) -> None:
|
||||
remove_ros = choice is not None and choice.startswith("Yes")
|
||||
# Only ask about Webots if it is actually installed on this machine.
|
||||
# Спрашиваем про Webots только если он действительно установлен на этой машине.
|
||||
if shutil.which("webots"):
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Webots",
|
||||
"Also remove Webots from the system?",
|
||||
["No, keep Webots", "Yes, remove Webots"],
|
||||
"No, keep Webots",
|
||||
),
|
||||
lambda c: self._on_webots_choice(c, remove_ros),
|
||||
)
|
||||
else:
|
||||
self._start_deletion(remove_ros, remove_webots=False)
|
||||
|
||||
def _on_webots_choice(self, choice: Optional[str], remove_ros: bool) -> None:
|
||||
remove_webots = choice is not None and choice.startswith("Yes")
|
||||
self._start_deletion(remove_ros, remove_webots)
|
||||
|
||||
def _start_deletion(self, remove_ros: bool, remove_webots: bool) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
"Deleting project",
|
||||
lambda s: _task_delete(s, remove_ros, remove_webots),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"delete",
|
||||
help="Remove the project, Docker images, containers, and optionally ROS2",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
_DeleteApp().run()
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
# The documentation source lives inside the project. We mount it into the container so
|
||||
# MkDocs can pick up live edits without rebuilding the image.
|
||||
# Исходники документации находятся внутри проекта. Монтируем директорию в контейнер, чтобы
|
||||
# MkDocs мог подхватывать изменения вживую без пересборки образа.
|
||||
_DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc"
|
||||
_IMAGE_NAME = "lwc-docs"
|
||||
_CONTAINER_NAME = "lwc-docs"
|
||||
_DEFAULT_PORT = "8000"
|
||||
|
||||
Write = Callable[[str], None]
|
||||
|
||||
|
||||
# Thin wrapper around docker so we do not repeat ["docker", ...] everywhere.
|
||||
# Тонкая обёртка вокруг docker, чтобы не повторять ["docker", ...] везде.
|
||||
def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
|
||||
"""Run a docker subcommand. Pass capture=True to capture stdout/stderr instead of printing.
|
||||
Запускает подкоманду docker. capture=True перехватывает stdout/stderr вместо вывода на экран.
|
||||
"""
|
||||
return subprocess.run(["docker", *args], capture_output=capture, text=True)
|
||||
|
||||
|
||||
# Check whether the docs container is currently running.
|
||||
# Проверяем, запущен ли сейчас контейнер с документацией.
|
||||
def _is_running() -> bool:
|
||||
"""Return True if the lwc-docs container is currently running.
|
||||
Возвращает True если контейнер lwc-docs в данный момент запущен.
|
||||
"""
|
||||
r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
|
||||
return _CONTAINER_NAME in r.stdout
|
||||
|
||||
|
||||
# Check whether the docs Docker image has already been built.
|
||||
# Проверяем, был ли уже собран Docker-образ для документации.
|
||||
def _image_exists() -> bool:
|
||||
"""Return True if the lwc-docs Docker image exists locally.
|
||||
Возвращает True если Docker-образ lwc-docs существует локально.
|
||||
"""
|
||||
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip())
|
||||
|
||||
|
||||
# Build the MkDocs Docker image. Only needs to run once.
|
||||
# Progress comes from parsing "Step X/Y" lines in the docker build output.
|
||||
# Собираем Docker-образ MkDocs. Нужно сделать только один раз.
|
||||
# Прогресс получаем, парся строки "Step X/Y" из вывода docker build.
|
||||
def _build_docs_image(
|
||||
write: Write,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
register_proc: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Build the lwc-docs Docker image from the doc/lwc-doc directory. Returns True on success.
|
||||
Собирает Docker-образ lwc-docs из директории doc/lwc-doc. Возвращает True при успехе.
|
||||
"""
|
||||
write("[cyan][*][/cyan] Building documentation image (runs once)...")
|
||||
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines that we can parse for progress.
|
||||
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y", которые можно парсить для прогресса.
|
||||
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
|
||||
proc = subprocess.Popen(
|
||||
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
write(s)
|
||||
if on_progress:
|
||||
m = re.match(r"Step (\d+)/(\d+) :", line)
|
||||
if m:
|
||||
step, total = int(m.group(1)), int(m.group(2))
|
||||
on_progress(step / total * 100)
|
||||
proc.wait()
|
||||
if proc.returncode in (-9, -15):
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
write("[green][ok][/green] Documentation image ready")
|
||||
return True
|
||||
write("[red]Image build failed.[/red]")
|
||||
return False
|
||||
|
||||
|
||||
# Start the docs server. Builds the image first if it does not exist yet.
|
||||
# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует.
|
||||
def _task_up(screen: LogScreen, port: str) -> None:
|
||||
"""Worker function for the "up" action. Builds the image if missing, then starts the container.
|
||||
Рабочая функция для действия "up". Собирает образ если отсутствует, затем запускает контейнер.
|
||||
"""
|
||||
try:
|
||||
if _is_running():
|
||||
screen.write(f"[green]Docs already running at:[/green] http://localhost:{port}")
|
||||
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
return
|
||||
|
||||
if not _DOC_DIR.exists():
|
||||
screen.write(f"[red]Doc directory not found:[/red] {_DOC_DIR}")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
if not _image_exists():
|
||||
screen.set_progress(0, "Building documentation image...")
|
||||
ok = _build_docs_image(
|
||||
screen.write,
|
||||
on_progress=lambda p: screen.set_progress(p * 0.85, "Building documentation image..."),
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
else:
|
||||
screen.write("[dim]Documentation image already built, skipping.[/dim]")
|
||||
|
||||
if screen.is_stopped():
|
||||
return
|
||||
|
||||
screen.set_progress(88, "Starting MkDocs server...")
|
||||
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
|
||||
result = _docker(
|
||||
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
||||
"-p", f"{port}:8000",
|
||||
# Mount the docs directory so edits appear live without restarting the container.
|
||||
# Монтируем директорию с документацией, чтобы изменения появлялись сразу без перезапуска.
|
||||
"-v", f"{_DOC_DIR}:/docs",
|
||||
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
||||
capture=True,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if result.returncode != 0:
|
||||
screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Server running")
|
||||
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
|
||||
screen.write(" Edit files in [bold]doc/lwc-doc/docs/[/bold] — reloads automatically.")
|
||||
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# Stop the running docs container.
|
||||
# Останавливаем работающий контейнер с документацией.
|
||||
def _task_down(screen: LogScreen) -> None:
|
||||
"""Worker function for the "down" action. Stops the lwc-docs container if it is running.
|
||||
Рабочая функция для действия "down". Останавливает контейнер lwc-docs если он запущен.
|
||||
"""
|
||||
try:
|
||||
if not _is_running():
|
||||
screen.write("[yellow]Docs container is not running.[/yellow]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
return
|
||||
screen.set_progress(30, "Stopping container...")
|
||||
screen.write("[cyan][*][/cyan] Stopping documentation server...")
|
||||
_docker("stop", _CONTAINER_NAME)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("[green][ok][/green] Container stopped.")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# Stop the container, remove the old image, rebuild it, and start a new container.
|
||||
# Останавливаем контейнер, удаляем старый образ, пересобираем и запускаем новый контейнер.
|
||||
def _task_rebuild(screen: LogScreen, port: str) -> None:
|
||||
"""Worker function for the "rebuild" action. Stops the container, removes the old image,
|
||||
rebuilds it, and starts a fresh container on the given port.
|
||||
Рабочая функция для действия "rebuild". Останавливает контейнер, удаляет старый образ,
|
||||
пересобирает его и запускает новый контейнер на указанном порту.
|
||||
"""
|
||||
try:
|
||||
if _is_running():
|
||||
screen.set_progress(5, "Stopping container...")
|
||||
screen.write("[cyan][*][/cyan] Stopping existing container...")
|
||||
_docker("stop", _CONTAINER_NAME)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.write("[green][ok][/green] Stopped.")
|
||||
|
||||
if _image_exists():
|
||||
screen.set_progress(15, "Removing old image...")
|
||||
screen.write("[cyan][*][/cyan] Removing old image...")
|
||||
_docker("rmi", "-f", _IMAGE_NAME)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
screen.write("[green][ok][/green] Image removed.")
|
||||
|
||||
screen.set_progress(20, "Building documentation image...")
|
||||
ok = _build_docs_image(
|
||||
screen.write,
|
||||
on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."),
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(90, "Starting MkDocs server...")
|
||||
screen.write("\n[cyan][*][/cyan] Starting MkDocs server...")
|
||||
result = _docker(
|
||||
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
||||
"-p", f"{port}:8000",
|
||||
"-v", f"{_DOC_DIR}:/docs",
|
||||
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
||||
capture=True,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if result.returncode != 0:
|
||||
screen.write(f"[red]Failed to start container.[/red]\n{result.stderr}")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Server running")
|
||||
screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}")
|
||||
screen.write(" Stop with: [bold]cobot doc-setup down[/bold]")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# One app handles all three actions (up/down/rebuild) by branching in on_mount.
|
||||
# Одно приложение обрабатывает все три действия (up/down/rebuild), разветвляясь в on_mount.
|
||||
class _DocApp(App[None]):
|
||||
"""Documentation server app. Handles "up", "down", and "rebuild" actions by branching
|
||||
in on_mount to the appropriate LogScreen task.
|
||||
Приложение сервера документации. Обрабатывает действия "up", "down" и "rebuild",
|
||||
разветвляясь в on_mount к соответствующей задаче LogScreen.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, action: str):
|
||||
super().__init__()
|
||||
self._action = action
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._action == "down":
|
||||
self.push_screen(
|
||||
LogScreen("Documentation server", _task_down, show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
elif self._action == "rebuild":
|
||||
self.push_screen(
|
||||
InputScreen("Step 1 of 1", "Port to serve documentation on:", _DEFAULT_PORT),
|
||||
self._got_port_rebuild,
|
||||
)
|
||||
else:
|
||||
self.push_screen(
|
||||
InputScreen("Step 1 of 1", "Port to serve documentation on:", _DEFAULT_PORT),
|
||||
self._got_port_up,
|
||||
)
|
||||
|
||||
def _got_port_up(self, port: Optional[str]) -> None:
|
||||
if port is None:
|
||||
self.exit()
|
||||
return
|
||||
# Use the default port if the user cleared the input or typed something that is not a number.
|
||||
# Используем порт по умолчанию если пользователь очистил ввод или написал не число.
|
||||
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
|
||||
self.push_screen(
|
||||
LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
def _got_port_rebuild(self, port: Optional[str]) -> None:
|
||||
if port is None:
|
||||
self.exit()
|
||||
return
|
||||
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
|
||||
self.push_screen(
|
||||
LogScreen("Documentation server — rebuild", lambda s: _task_rebuild(s, p), show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser("doc-setup", help="Deploy or stop the documentation server")
|
||||
p.add_argument(
|
||||
"action",
|
||||
nargs="?",
|
||||
choices=["up", "down", "rebuild"],
|
||||
default="up",
|
||||
help="up — start (default), down — stop, rebuild — rebuild image and restart",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if not shutil.which("docker"):
|
||||
from rich.console import Console
|
||||
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
action = getattr(args, "action", "up")
|
||||
_DocApp(action).run()
|
||||
@@ -0,0 +1,412 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
_DOCKER_DIR = _PROJECT_DIR / "docker"
|
||||
|
||||
# Default Docker Hub repository and local image prefix used when building locally.
|
||||
# Репозиторий Docker Hub по умолчанию и локальный префикс образов при локальной сборке.
|
||||
_DEFAULT_HUB_REPO = "evilfisru/lwc"
|
||||
_DEFAULT_PREFIX = "lwc-local"
|
||||
|
||||
# The images must be built in this order because each one is based on the previous.
|
||||
# Образы должны собираться в этом порядке, потому что каждый основан на предыдущем.
|
||||
_CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"]
|
||||
_WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"]
|
||||
|
||||
# Maps each image to the image it is built FROM. None means it starts from scratch (base Ubuntu).
|
||||
# Сопоставляет каждый образ с тем, на основе которого он собирается. None - начинает с нуля (базовый Ubuntu).
|
||||
_IMAGE_PARENT: dict[str, str | None] = {
|
||||
"ros-core": None,
|
||||
"ros-base": "ros-core",
|
||||
"ros-iiwa7": "ros-base",
|
||||
"ros-iiwa7-webots": "ros-base",
|
||||
}
|
||||
|
||||
# These images need the full project source as Docker build context because they copy source files.
|
||||
# Эти образы требуют полный исходный код проекта как контекст сборки, потому что копируют файлы.
|
||||
_NEEDS_PROJECT_CTX = {"ros-iiwa7", "ros-iiwa7-webots"}
|
||||
|
||||
Write = Callable[[str], None]
|
||||
|
||||
|
||||
# All the choices the user makes in the wizard are stored here before we start the actual build.
|
||||
# Все выборы пользователя в мастере хранятся здесь перед началом фактической сборки.
|
||||
@dataclass
|
||||
class _Config:
|
||||
ros_version: str
|
||||
variant: str
|
||||
source: str
|
||||
build_type: str
|
||||
image_prefix: str
|
||||
hub_repo: str
|
||||
|
||||
|
||||
# Build one Docker image and stream its output to the log.
|
||||
# Progress is tracked by parsing "Step X/Y" lines that Docker prints during the build.
|
||||
# Собирает один Docker-образ и транслирует его вывод в лог.
|
||||
# Прогресс отслеживается по строкам "Step X/Y", которые Docker печатает во время сборки.
|
||||
def _build_image(
|
||||
name: str,
|
||||
tag: str,
|
||||
dockerfile: Path,
|
||||
ctx: Path,
|
||||
write: Write,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
parent_tag: Optional[str] = None,
|
||||
build_type: str = "release",
|
||||
register_proc: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Build a single Docker image from a Dockerfile and stream its output line by line.
|
||||
Returns True on success, False if the build failed or was cancelled.
|
||||
Собирает один Docker-образ из Dockerfile и транслирует вывод построчно.
|
||||
Возвращает True при успехе, False если сборка завершилась ошибкой или была отменена.
|
||||
"""
|
||||
write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...")
|
||||
# DOCKER_BUILDKIT=0 gives us "Step X/Y" lines in the output which we parse for progress.
|
||||
# DOCKER_BUILDKIT=0 даёт нам строки "Step X/Y" в выводе, которые мы парсим для прогресса.
|
||||
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
|
||||
cmd = [
|
||||
"docker", "build", "-t", tag, "-f", str(dockerfile),
|
||||
"--build-arg", f"BUILD_TYPE={build_type}",
|
||||
]
|
||||
if parent_tag:
|
||||
cmd += ["--build-arg", f"IMAGE={parent_tag}"]
|
||||
cmd.append(str(ctx))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
write(s)
|
||||
if on_progress:
|
||||
m = re.match(r"Step (\d+)/(\d+) :", line)
|
||||
if m:
|
||||
step, total = int(m.group(1)), int(m.group(2))
|
||||
on_progress(step / total * 100)
|
||||
proc.wait()
|
||||
if proc.returncode in (-9, -15):
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
write(f"[green][ok][/green] {name}")
|
||||
return True
|
||||
write(f"[red]Build failed:[/red] {tag}")
|
||||
return False
|
||||
|
||||
|
||||
# Pull a Docker image from Hub and track progress by counting downloaded layers.
|
||||
# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеством скачанных слоёв.
|
||||
def _pull_image(
|
||||
name: str,
|
||||
tag: str,
|
||||
write: Write,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
register_proc: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""Pull a Docker image from Docker Hub and report layer-by-layer progress.
|
||||
Returns True on success, False if the pull failed or was cancelled.
|
||||
Скачивает Docker-образ с Docker Hub и сообщает о прогрессе по слоям.
|
||||
Возвращает True при успехе, False если скачивание завершилось ошибкой или было отменено.
|
||||
"""
|
||||
write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...")
|
||||
if on_progress:
|
||||
on_progress(5)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["docker", "pull", tag],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
layers_total = 0
|
||||
layers_done = 0
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
write(s)
|
||||
# Count layers as they appear and mark them done when Docker confirms they are pulled.
|
||||
# Считаем слои по мере их появления и отмечаем завершёнными когда Docker подтверждает скачивание.
|
||||
if "Pulling fs layer" in line or "Waiting" in line:
|
||||
layers_total += 1
|
||||
elif "Pull complete" in line or "Already exists" in line:
|
||||
layers_done += 1
|
||||
if on_progress and layers_total > 0:
|
||||
on_progress(5 + layers_done / layers_total * 90)
|
||||
proc.wait()
|
||||
if proc.returncode in (-9, -15):
|
||||
return False
|
||||
if proc.returncode == 0:
|
||||
write(f"[green][ok][/green] {name}")
|
||||
if on_progress:
|
||||
on_progress(100)
|
||||
return True
|
||||
write(f"[red]Pull failed:[/red] {tag}")
|
||||
return False
|
||||
|
||||
|
||||
# The actual work - either build or pull all images depending on what the user chose.
|
||||
# Each image gets its own slice of the progress bar so the overall bar advances smoothly.
|
||||
# Основная работа - собираем или скачиваем все образы в зависимости от выбора пользователя.
|
||||
# Каждый образ получает свой кусок прогресс-бара, чтобы общий бар двигался равномерно.
|
||||
def _task_execute(screen: LogScreen, cfg: _Config) -> None:
|
||||
"""Worker function that runs inside LogScreen. Builds or pulls all images in the chain
|
||||
defined by the user's choices and updates the progress bar after each image.
|
||||
Рабочая функция, выполняемая внутри LogScreen. Собирает или скачивает все образы из цепочки
|
||||
согласно выбору пользователя и обновляет прогресс-бар после каждого образа.
|
||||
"""
|
||||
try:
|
||||
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
|
||||
n = len(chain)
|
||||
|
||||
if cfg.source == "build":
|
||||
screen.write(
|
||||
f"[bold]Building {n} image(s) — "
|
||||
f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n"
|
||||
)
|
||||
for i, name in enumerate(chain):
|
||||
if screen.is_stopped():
|
||||
return
|
||||
lo = i / n * 100
|
||||
hi = (i + 1) / n * 100
|
||||
screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...")
|
||||
|
||||
tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}"
|
||||
dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile"
|
||||
if not dockerfile.exists():
|
||||
screen.write(f"[red]Dockerfile not found:[/red] {dockerfile}")
|
||||
if not screen.is_stopped():
|
||||
screen.finish(False)
|
||||
return
|
||||
ctx = _PROJECT_DIR if name in _NEEDS_PROJECT_CTX else dockerfile.parent
|
||||
parent_name = _IMAGE_PARENT.get(name)
|
||||
parent_tag = (
|
||||
f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}"
|
||||
if parent_name else None
|
||||
)
|
||||
ok = _build_image(
|
||||
name, tag, dockerfile, ctx, screen.write,
|
||||
on_progress=lambda p, lo=lo, hi=hi: screen.set_progress(
|
||||
lo + p * (hi - lo) / 100, f"Image {i + 1}/{n}: building {name}..."
|
||||
),
|
||||
parent_tag=parent_tag,
|
||||
build_type=cfg.build_type,
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(hi)
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "All images built")
|
||||
screen.write(
|
||||
f"\n[green]Done.[/green] "
|
||||
f"Images tagged [bold]{cfg.image_prefix}:<name>-{cfg.ros_version}[/bold]."
|
||||
)
|
||||
|
||||
else:
|
||||
short = "webots" if cfg.variant == "webots" else "iiwa"
|
||||
suffix = "-dev" if cfg.build_type == "dev" else ""
|
||||
full_ref = f"{cfg.hub_repo}:{short}-{cfg.ros_version}{suffix}"
|
||||
screen.write(
|
||||
f"[bold]Pulling from {cfg.hub_repo} — "
|
||||
f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n"
|
||||
)
|
||||
screen.set_progress(0, f"Pulling {full_ref}...")
|
||||
ok = _pull_image(
|
||||
short, full_ref, screen.write,
|
||||
on_progress=lambda p: screen.set_progress(p, f"Pulling {full_ref}..."),
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if not ok:
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(100, "Pull complete")
|
||||
screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].")
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# Scan the docker/ directory for subdirectories named after ROS versions (e.g. jazzy).
|
||||
# If nothing is found we fall back to "jazzy" so the wizard still works.
|
||||
# Сканируем директорию docker/ на наличие поддиректорий с именами версий ROS (например jazzy).
|
||||
# Если ничего не найдено, используем "jazzy" по умолчанию, чтобы мастер всё равно работал.
|
||||
def _discover_versions() -> List[str]:
|
||||
"""Return a sorted list of ROS versions found in docker/. Jazzy is placed first.
|
||||
Falls back to ["jazzy"] if the directory does not exist or is empty.
|
||||
Возвращает отсортированный список версий ROS найденных в docker/. Jazzy идёт первым.
|
||||
Возвращает ["jazzy"] если директория не существует или пуста.
|
||||
"""
|
||||
if not _DOCKER_DIR.exists():
|
||||
return ["jazzy"]
|
||||
dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir())
|
||||
# Put jazzy first so it is the pre-selected default in the wizard.
|
||||
# Ставим jazzy первым, чтобы он был предвыбранным по умолчанию в мастере.
|
||||
if "jazzy" in dirs:
|
||||
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
|
||||
return dirs or ["jazzy"]
|
||||
|
||||
|
||||
# Multi-step wizard that collects all build options before starting the actual image build.
|
||||
# Многошаговый мастер, который собирает все параметры сборки перед запуском фактической сборки образа.
|
||||
class _Wizard(App[None]):
|
||||
"""Five-step wizard: ROS version -> source (pull/build) -> variant -> build type -> repo/prefix.
|
||||
Collects all options, then hands off to LogScreen which runs _task_execute.
|
||||
Пятишаговый мастер: версия ROS -> источник (pull/build) -> вариант -> тип сборки -> репо/префикс.
|
||||
Собирает все параметры, затем передаёт управление LogScreen который запускает _task_execute.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, versions: List[str], default_version: str = "jazzy"):
|
||||
super().__init__()
|
||||
self.versions = versions
|
||||
self.default_version = default_version
|
||||
self._state: dict = {}
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._ask_version()
|
||||
|
||||
def _ask_version(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen("Step 1 of 5", "Select ROS version:", self.versions, self.default_version),
|
||||
self._got_version,
|
||||
)
|
||||
|
||||
def _got_version(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["ros_version"] = v
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Step 2 of 5", "Source:",
|
||||
["Pull from Docker Hub", "Build locally"],
|
||||
"Pull from Docker Hub",
|
||||
),
|
||||
self._got_source,
|
||||
)
|
||||
|
||||
def _got_source(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["source"] = "build" if v == "Build locally" else "pull"
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Step 3 of 5",
|
||||
"What to install:",
|
||||
[
|
||||
"Controller only — ros-core, ros-base, ros-iiwa7",
|
||||
"Controller with Webots — ros-core, ros-base, ros-iiwa7-webots",
|
||||
],
|
||||
"Controller only — ros-core, ros-base, ros-iiwa7",
|
||||
),
|
||||
self._got_variant,
|
||||
)
|
||||
|
||||
def _got_variant(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["variant"] = "webots" if v.startswith("Controller with Webots") else "controller"
|
||||
self.push_screen(
|
||||
PickScreen("Step 4 of 5", "Build type:", ["release", "dev"], "release"),
|
||||
self._got_build_type,
|
||||
)
|
||||
|
||||
def _got_build_type(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["build_type"] = v or "release"
|
||||
# Pull needs a Hub repo name, build needs a local image prefix.
|
||||
# Для pull нужно имя репозитория на Hub, для build - локальный префикс образов.
|
||||
if self._state["source"] == "pull":
|
||||
self.push_screen(
|
||||
InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO),
|
||||
self._got_hub_repo,
|
||||
)
|
||||
else:
|
||||
self.push_screen(
|
||||
InputScreen("Step 5 of 5", "Image prefix:", _DEFAULT_PREFIX),
|
||||
self._got_prefix,
|
||||
)
|
||||
|
||||
def _got_hub_repo(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["hub_repo"] = v
|
||||
self._finish()
|
||||
|
||||
def _got_prefix(self, v: Optional[str]) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._state["image_prefix"] = v
|
||||
self._finish()
|
||||
|
||||
def _finish(self) -> None:
|
||||
# Assemble the config and hand it off to the log screen that does the actual work.
|
||||
# Собираем конфиг и передаём его экрану лога, который выполняет фактическую работу.
|
||||
s = self._state
|
||||
cfg = _Config(
|
||||
ros_version=s["ros_version"],
|
||||
variant=s["variant"],
|
||||
source=s["source"],
|
||||
build_type=s["build_type"],
|
||||
image_prefix=s.get("image_prefix", _DEFAULT_PREFIX),
|
||||
hub_repo=s.get("hub_repo", _DEFAULT_HUB_REPO),
|
||||
)
|
||||
title = (
|
||||
f"Building Docker images — ROS {cfg.ros_version}"
|
||||
if cfg.source == "build"
|
||||
else f"Pulling Docker image — ROS {cfg.ros_version}"
|
||||
)
|
||||
self.push_screen(
|
||||
LogScreen(title, lambda screen: _task_execute(screen, cfg), show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser("docker-setup", help="Build or pull Docker images for KUKA iiwa7")
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if not shutil.which("docker"):
|
||||
from rich.console import Console
|
||||
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
versions = _discover_versions()
|
||||
default = "jazzy" if "jazzy" in versions else versions[0]
|
||||
_Wizard(versions=versions, default_version=default).run()
|
||||
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
|
||||
# Root directory of the project, used as the working directory for colcon builds.
|
||||
# Корневая директория проекта, используется как рабочая директория для сборки colcon.
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
# ROS2 distribution name targeted by this installer.
|
||||
# Название дистрибутива ROS2, который устанавливает этот скрипт.
|
||||
_DISTRO = "jazzy"
|
||||
|
||||
# Webots simulator version targeted by this installer.
|
||||
# Версия симулятора Webots, устанавливаемая этим скриптом.
|
||||
_WEBOTS_VERSION = "2025a"
|
||||
|
||||
# Directory that contains the shell scripts used by this command.
|
||||
# Директория с shell-скриптами, используемыми этой командой.
|
||||
_SCRIPTS_DIR = _PROJECT_DIR / "scripts"
|
||||
|
||||
# Type alias for the callable used to write a line to the TUI log screen.
|
||||
# Псевдоним типа для функции записи строки в лог TUI.
|
||||
Write = Callable[[str], None]
|
||||
|
||||
|
||||
# OS and tool detection helpers
|
||||
# Вспомогательные функции для определения ОС и наличия инструментов
|
||||
def _detect_ubuntu_2404() -> bool:
|
||||
"""Return True if the current OS is Ubuntu 24.04 (Noble).
|
||||
|
||||
Reads /etc/os-release and checks the ID and VERSION_ID fields.
|
||||
Возвращает True, если текущая ОС - Ubuntu 24.04 (Noble).
|
||||
Читает /etc/os-release и проверяет поля ID и VERSION_ID.
|
||||
"""
|
||||
path = Path("/etc/os-release")
|
||||
if not path.exists():
|
||||
return False
|
||||
info: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
info[k.strip()] = v.strip().strip('"')
|
||||
return info.get("ID") == "ubuntu" and info.get("VERSION_ID") == "24.04"
|
||||
|
||||
|
||||
def _detect_ros2() -> bool:
|
||||
"""Return True if ROS2 Jazzy is already installed under /opt/ros/jazzy.
|
||||
|
||||
Возвращает True, если ROS2 Jazzy уже установлен в /opt/ros/jazzy.
|
||||
"""
|
||||
return Path(f"/opt/ros/{_DISTRO}").is_dir()
|
||||
|
||||
|
||||
def webots_installed() -> bool:
|
||||
"""Return True if the Webots binary is available on PATH.
|
||||
|
||||
Возвращает True, если бинарный файл Webots доступен в PATH.
|
||||
"""
|
||||
return shutil.which("webots") is not None
|
||||
|
||||
|
||||
def _ros2_env() -> dict:
|
||||
"""Build an environment dict with ROS2 variables sourced from setup.bash.
|
||||
|
||||
Sources /opt/ros/jazzy/setup.bash in a subprocess, captures all exported
|
||||
variables and merges them into a copy of os.environ. Falls back to plain
|
||||
os.environ if the setup file does not exist yet.
|
||||
|
||||
Формирует словарь окружения с переменными ROS2, полученными из setup.bash.
|
||||
Запускает /opt/ros/jazzy/setup.bash в подпроцессе, перехватывает все
|
||||
экспортированные переменные и объединяет их с копией os.environ.
|
||||
Возвращает чистый os.environ если файл setup.bash ещё не существует.
|
||||
"""
|
||||
setup = Path(f"/opt/ros/{_DISTRO}/setup.bash")
|
||||
if not setup.exists():
|
||||
return os.environ.copy()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", f"source {setup} && env"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
env = os.environ.copy()
|
||||
for line in result.stdout.splitlines():
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
env[k] = v
|
||||
|
||||
# CMake's find_package(Python3) ignores PATH and uses its own search logic,
|
||||
# so we must pin it explicitly to the system Python where catkin_pkg is installed.
|
||||
# PATH reordering alone is not enough.
|
||||
# CMake игнорирует PATH при поиске Python через find_package(Python3),
|
||||
# поэтому явно указываем системный Python, где установлен catkin_pkg.
|
||||
# Одного изменения PATH недостаточно.
|
||||
env["Python3_EXECUTABLE"] = "/usr/bin/python3"
|
||||
env["PYTHON_EXECUTABLE"] = "/usr/bin/python3"
|
||||
|
||||
# Also keep PATH clean so other tools (rosdep, colcon itself) use system Python.
|
||||
# Заодно чистим PATH чтобы другие инструменты тоже использовали системный Python.
|
||||
_SYSTEM_PATHS = ["/usr/bin", "/usr/local/bin"]
|
||||
existing = env.get("PATH", "").split(":")
|
||||
env["PATH"] = ":".join(
|
||||
_SYSTEM_PATHS + [p for p in existing if p not in _SYSTEM_PATHS]
|
||||
)
|
||||
return env
|
||||
|
||||
|
||||
# Subprocess runner helpers
|
||||
# Вспомогательные функции для запуска подпроцессов
|
||||
def _run_logged(
|
||||
cmd: List[str],
|
||||
write: Write,
|
||||
env: dict | None = None,
|
||||
cwd=None,
|
||||
register_proc: Callable | None = None,
|
||||
) -> None:
|
||||
"""Run a command and stream every non-empty output line to the TUI log.
|
||||
|
||||
Raises RuntimeError if the process exits with a non-zero code (SIGKILL is
|
||||
treated as a normal cancellation and does not raise).
|
||||
|
||||
Запускает команду и передаёт каждую непустую строку вывода в лог TUI.
|
||||
Выбрасывает RuntimeError если процесс завершился с ненулевым кодом
|
||||
(SIGKILL считается нормальной отменой и не вызывает исключение).
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env or os.environ,
|
||||
cwd=cwd,
|
||||
)
|
||||
if register_proc:
|
||||
register_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
write(s)
|
||||
proc.wait()
|
||||
if proc.returncode not in (0, -9):
|
||||
raise RuntimeError(f"Command failed: {cmd[0]}")
|
||||
|
||||
|
||||
# Tasks - long-running functions executed inside a LogScreen background thread
|
||||
# Задачи - долгие функции, выполняемые в фоновом потоке внутри LogScreen
|
||||
def _run_script(script: Path, screen: LogScreen) -> None:
|
||||
"""Run a shell script, stream its output to the TUI log, and parse
|
||||
PROGRESS:<pct>:<label> markers to update the progress bar.
|
||||
Raises RuntimeError if the script exits with a non-zero code.
|
||||
Запускает shell-скрипт, транслирует вывод в лог TUI и разбирает маркеры
|
||||
PROGRESS:<pct>:<метка> для обновления прогресс-бара.
|
||||
Выбрасывает RuntimeError если скрипт завершился с ненулевым кодом.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
["bash", str(script)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s.startswith("PROGRESS:"):
|
||||
# Format emitted by scripts: PROGRESS:<pct>:<label>
|
||||
# Формат, выводимый скриптами: PROGRESS:<pct>:<метка>
|
||||
parts = s.split(":", 2)
|
||||
try:
|
||||
screen.set_progress(float(parts[1]), parts[2] if len(parts) > 2 else "")
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif s:
|
||||
screen.write(s)
|
||||
proc.wait()
|
||||
if proc.returncode not in (0, -9):
|
||||
raise RuntimeError(f"Script failed (exit {proc.returncode}): {script.name}")
|
||||
|
||||
|
||||
def _task_install(screen: LogScreen, pkg: str) -> None:
|
||||
"""Run the ROS2 Jazzy installation shell script for the chosen variant (desktop / ros-base).
|
||||
The script emits PROGRESS: markers so the bar advances during installation.
|
||||
Запускает shell-скрипт установки ROS2 Jazzy для выбранного варианта (desktop / ros-base).
|
||||
Скрипт выводит маркеры PROGRESS:, чтобы прогресс-бар обновлялся во время установки.
|
||||
"""
|
||||
try:
|
||||
# "desktop" -> setup_ros2_desktop.sh, "ros-base" -> setup_ros2_ros_base.sh
|
||||
script = _SCRIPTS_DIR / f"setup_ros2_{pkg.replace('-', '_')}.sh"
|
||||
if not script.exists():
|
||||
screen.write(f"[red]Script not found:[/red] {script}")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(0, "Starting installation...")
|
||||
_run_script(script, screen)
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write(f"\n[green]ROS2 {_DISTRO} ({pkg}) installed successfully.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
def _task_build(screen: LogScreen) -> None:
|
||||
"""Build the project workspace using rosdep and colcon.
|
||||
|
||||
Step 1 - runs "rosdep install --from-paths src" to pull in all package
|
||||
dependencies declared in the src/ directory.
|
||||
Step 2 - runs "colcon build --symlink-install" to compile every package.
|
||||
|
||||
Both commands receive a copy of os.environ extended with the sourced ROS2
|
||||
setup so that ament CMake macros and ROS2 packages are visible even if the
|
||||
user has not yet sourced setup.bash in this terminal session.
|
||||
|
||||
Собирает рабочее пространство проекта с помощью rosdep и colcon.
|
||||
Шаг 1 - запускает "rosdep install --from-paths src" для установки всех
|
||||
зависимостей пакетов, объявленных в директории src/.
|
||||
Шаг 2 - запускает "colcon build --symlink-install" для компиляции каждого пакета.
|
||||
|
||||
Обе команды получают копию os.environ с подключённым окружением ROS2, так что
|
||||
макросы ament CMake и пакеты ROS2 видны даже если пользователь ещё не выполнил
|
||||
source setup.bash в этой сессии терминала.
|
||||
"""
|
||||
try:
|
||||
env = _ros2_env()
|
||||
|
||||
if not shutil.which("colcon") and not Path(f"/opt/ros/{_DISTRO}/bin/colcon").exists():
|
||||
screen.write("[red]colcon not found.[/red]")
|
||||
screen.write(f"Source ROS2 first: [bold]source /opt/ros/{_DISTRO}/setup.bash[/bold]")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(0, "Installing dependencies...")
|
||||
screen.write("[bold]Step 1 / 2 - rosdep install[/bold]\n")
|
||||
_run_logged(
|
||||
["rosdep", "install", "--from-paths", "src", "-i", "-r", "-y"],
|
||||
screen.write,
|
||||
env=env,
|
||||
cwd=_PROJECT_DIR,
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
if screen.is_stopped():
|
||||
return
|
||||
|
||||
screen.set_progress(30, "Building...")
|
||||
list_result = subprocess.run(
|
||||
["colcon", "list", "--base-paths", "src"], capture_output=True, text=True,
|
||||
cwd=_PROJECT_DIR, env=env,
|
||||
)
|
||||
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
|
||||
screen.write(f"\n[bold]Step 2 / 2 - colcon build ({total} packages)[/bold]\n")
|
||||
built = 0
|
||||
|
||||
def _track(line: str) -> None:
|
||||
"""Update the progress bar each time colcon finishes a package.
|
||||
Обновляет прогресс-бар каждый раз, когда colcon завершает пакет.
|
||||
"""
|
||||
nonlocal built
|
||||
screen.write(line)
|
||||
if "Finished <<<" in line or "Failed <<<" in line:
|
||||
built += 1
|
||||
screen.set_progress(
|
||||
30 + built / total * 70,
|
||||
f"{built} / {total} packages done",
|
||||
)
|
||||
|
||||
_run_logged(
|
||||
["colcon", "build", "--base-paths", "src"],
|
||||
_track,
|
||||
env=env,
|
||||
cwd=_PROJECT_DIR,
|
||||
register_proc=screen.set_proc,
|
||||
)
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Build complete")
|
||||
screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
def _task_install_webots(screen: LogScreen) -> None:
|
||||
"""Run the Webots installation shell script, streaming output and progress to the TUI.
|
||||
|
||||
Запускает shell-скрипт установки Webots, транслируя вывод и прогресс в TUI.
|
||||
"""
|
||||
try:
|
||||
script = _SCRIPTS_DIR / "install_webots.sh"
|
||||
if not script.exists():
|
||||
screen.write(f"[red]Script not found:[/red] {script}")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(0, "Starting Webots installation...")
|
||||
_run_script(script, screen)
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write(f"\n[green]Webots {_WEBOTS_VERSION} installed successfully.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
# TUI application - orchestrates screens and user choices
|
||||
# TUI приложение - управляет экранами и выборами пользователя
|
||||
class _LocalSetupApp(App[Optional[str]]):
|
||||
"""Main TUI application for the local-setup command.
|
||||
|
||||
Guides the user through: install ROS2 choice, OS check, version choice,
|
||||
installation log, build log, and optional Webots installation.
|
||||
Returns "docker" if the user opts for Docker setup, None otherwise.
|
||||
|
||||
Главное TUI приложение для команды local-setup.
|
||||
Проводит пользователя через: выбор установки ROS2, проверку ОС, выбор версии,
|
||||
лог установки, лог сборки и опциональную установку Webots.
|
||||
Возвращает "docker" если пользователь выбирает Docker, иначе None.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"local-setup",
|
||||
"Install ROS2 Jazzy?",
|
||||
["Yes, install", "No, exit"],
|
||||
"Yes, install",
|
||||
),
|
||||
self._on_install_choice,
|
||||
)
|
||||
|
||||
def _on_install_choice(self, choice: Optional[str]) -> None:
|
||||
"""Handle the initial yes/no choice to install ROS2.
|
||||
Обрабатывает начальный выбор да/нет для установки ROS2.
|
||||
"""
|
||||
if not choice or choice.startswith("No"):
|
||||
self.exit(None)
|
||||
return
|
||||
if not _detect_ubuntu_2404():
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Unsupported OS",
|
||||
"Ubuntu 24.04 not detected. Set up the environment via Docker instead?",
|
||||
["Yes, run docker-setup", "No, exit"],
|
||||
"Yes, run docker-setup",
|
||||
),
|
||||
self._on_docker_choice,
|
||||
)
|
||||
else:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"ROS2 version",
|
||||
"Which ROS2 Jazzy variant do you want to install?",
|
||||
["Desktop (full install, includes GUI tools)", "Base (minimal, no GUI)"],
|
||||
"Desktop (full install, includes GUI tools)",
|
||||
),
|
||||
self._on_version_choice,
|
||||
)
|
||||
|
||||
def _on_docker_choice(self, choice: Optional[str]) -> None:
|
||||
"""Exit the app signalling whether docker-setup should be launched.
|
||||
Завершает приложение, сигнализируя нужно ли запустить docker-setup.
|
||||
"""
|
||||
self.exit("docker" if choice and choice.startswith("Yes") else None)
|
||||
|
||||
def _on_version_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the installation log screen for the chosen ROS2 variant.
|
||||
Запускает экран лога установки для выбранного варианта ROS2.
|
||||
"""
|
||||
if not choice:
|
||||
self.exit(None)
|
||||
return
|
||||
pkg = "desktop" if choice.startswith("Desktop") else "ros-base"
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing ROS2 Jazzy ({pkg})",
|
||||
lambda s: _task_install(s, pkg),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self._after_install(),
|
||||
)
|
||||
|
||||
def _after_install(self) -> None:
|
||||
"""After installation, ask whether to build the project workspace now.
|
||||
После установки спрашивает, нужно ли собрать рабочее пространство прямо сейчас.
|
||||
"""
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Build",
|
||||
"Build the project workspace now?\n(runs rosdep install + colcon build)",
|
||||
["Yes, build now", "No, skip"],
|
||||
"Yes, build now",
|
||||
),
|
||||
self._on_build_choice,
|
||||
)
|
||||
|
||||
def _on_build_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the build log screen or skip directly to the Webots prompt.
|
||||
Запускает экран сборки или пропускает к вопросу про Webots.
|
||||
"""
|
||||
if not choice or choice.startswith("No"):
|
||||
self._after_build()
|
||||
return
|
||||
self.push_screen(
|
||||
LogScreen("Building project", _task_build, show_progress=True),
|
||||
lambda _: self._after_build(),
|
||||
)
|
||||
|
||||
def _after_build(self) -> None:
|
||||
"""After the build, offer to install Webots if it is not already present.
|
||||
После сборки предлагает установить Webots если он ещё не установлен.
|
||||
"""
|
||||
if webots_installed():
|
||||
self.exit(None)
|
||||
return
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"Webots",
|
||||
f"Install Webots {_WEBOTS_VERSION} simulator?",
|
||||
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, skip"],
|
||||
"No, skip",
|
||||
),
|
||||
self._on_webots_choice,
|
||||
)
|
||||
|
||||
def _on_webots_choice(self, choice: Optional[str]) -> None:
|
||||
"""Start the Webots installer or exit depending on the user choice.
|
||||
Запускает установщик Webots или завершает работу в зависимости от выбора.
|
||||
"""
|
||||
if choice and choice.startswith("Yes"):
|
||||
subprocess.run(["sudo", "-v"], check=False)
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing Webots {_WEBOTS_VERSION}",
|
||||
_task_install_webots,
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(None),
|
||||
)
|
||||
else:
|
||||
self.exit(None)
|
||||
|
||||
|
||||
class WebotsInstallApp(App[bool]):
|
||||
"""Standalone TUI app for installing Webots, used by the run command.
|
||||
|
||||
Launched by run.py when the user starts a local simulation but Webots
|
||||
is not installed yet.
|
||||
|
||||
Отдельное TUI приложение для установки Webots, используемое командой run.
|
||||
Запускается из run.py когда пользователь запускает локальную симуляцию,
|
||||
но Webots ещё не установлен.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
f"Installing Webots {_WEBOTS_VERSION}",
|
||||
_task_install_webots,
|
||||
show_progress=True,
|
||||
),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
# Entry point - registered as the "local-setup" subcommand
|
||||
# Точка входа - зарегистрирована как подкоманда "local-setup"
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
"""Register the local-setup subcommand with the CLI argument parser.
|
||||
|
||||
Регистрирует подкоманду local-setup в парсере аргументов командной строки.
|
||||
"""
|
||||
p = subparsers.add_parser(
|
||||
"local-setup",
|
||||
help="Install ROS2 Jazzy natively and build the project with colcon",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the local-setup command.
|
||||
|
||||
Pre-caches the sudo token while the terminal is in normal mode so that
|
||||
subsequent sudo calls inside the Textual TUI do not hang waiting for
|
||||
a password prompt that the user cannot see.
|
||||
|
||||
Точка входа для команды local-setup.
|
||||
Предварительно кеширует sudo-токен пока терминал в обычном режиме, чтобы
|
||||
последующие вызовы sudo внутри Textual TUI не зависали ожидая запрос пароля,
|
||||
который пользователь не может увидеть.
|
||||
"""
|
||||
subprocess.run(["sudo", "-v"], check=False)
|
||||
result = _LocalSetupApp().run()
|
||||
if result == "docker":
|
||||
_docker_setup(args)
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
|
||||
from cobot.commands.local_setup import _ros2_env
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def _task_rebuild(screen: LogScreen, packages: List[str], symlink: bool) -> None:
|
||||
"""Run colcon build for the selected packages (or all if packages is empty).
|
||||
Streams output to the log and tracks per-package progress.
|
||||
|
||||
Запускает colcon build для выбранных пакетов (или всех если packages пуст).
|
||||
Транслирует вывод в лог и отслеживает прогресс по каждому пакету.
|
||||
"""
|
||||
try:
|
||||
env = _ros2_env()
|
||||
|
||||
# Count packages so we can show X / total progress.
|
||||
# Считаем пакеты чтобы показывать X / всего в прогрессе.
|
||||
list_cmd = ["colcon", "list", "--base-paths", "src"]
|
||||
if packages:
|
||||
list_cmd += ["--packages-select"] + packages
|
||||
list_result = subprocess.run(
|
||||
list_cmd, capture_output=True, text=True,
|
||||
cwd=_PROJECT_DIR, env=env,
|
||||
)
|
||||
total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1)
|
||||
|
||||
pkg_label = " ".join(packages) if packages else "all packages"
|
||||
symlink_label = " --symlink-install" if symlink else ""
|
||||
screen.write(f"[bold]colcon build{symlink_label} — {pkg_label}[/bold]\n")
|
||||
screen.set_progress(0, f"0 / {total} packages done")
|
||||
built = 0
|
||||
|
||||
cmd = ["colcon", "build", "--base-paths", "src"]
|
||||
if symlink:
|
||||
cmd.append("--symlink-install")
|
||||
if packages:
|
||||
cmd += ["--packages-select"] + packages
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR, env=env,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
if "Finished <<<" in line or "Failed <<<" in line:
|
||||
built += 1
|
||||
screen.set_progress(
|
||||
built / total * 100,
|
||||
f"{built} / {total} packages done",
|
||||
)
|
||||
proc.wait()
|
||||
|
||||
if screen.is_stopped():
|
||||
return
|
||||
|
||||
if proc.returncode not in (0, -9):
|
||||
screen.write("\n[red]Build failed.[/red]")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("\n[green]Build complete.[/green]")
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
class _RebuildApp(App[None]):
|
||||
"""Rebuild wizard: optionally asks for packages and symlink flag, then runs colcon.
|
||||
Мастер пересборки: опционально спрашивает пакеты и флаг symlink, затем запускает colcon.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, packages: Optional[List[str]], symlink: Optional[bool]):
|
||||
super().__init__()
|
||||
# None means "ask the user interactively".
|
||||
# None означает "спросить пользователя интерактивно".
|
||||
self._packages = packages
|
||||
self._symlink = symlink
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self._packages is None:
|
||||
self._ask_packages()
|
||||
elif self._symlink is None:
|
||||
self._ask_symlink()
|
||||
else:
|
||||
self._start()
|
||||
|
||||
def _ask_packages(self) -> None:
|
||||
self.push_screen(
|
||||
InputScreen(
|
||||
"rebuild",
|
||||
"Packages to rebuild (space-separated, leave empty for all):",
|
||||
"",
|
||||
note="Example: iiwa_controller iiwa_bringup",
|
||||
),
|
||||
self._got_packages,
|
||||
)
|
||||
|
||||
def _got_packages(self, value: Optional[str]) -> None:
|
||||
if value is None:
|
||||
self.exit()
|
||||
return
|
||||
self._packages = value.split() if value.strip() else []
|
||||
if self._symlink is None:
|
||||
self._ask_symlink()
|
||||
else:
|
||||
self._start()
|
||||
|
||||
def _ask_symlink(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
"rebuild",
|
||||
"Use --symlink-install?",
|
||||
["Yes", "No"],
|
||||
"Yes",
|
||||
),
|
||||
self._got_symlink,
|
||||
)
|
||||
|
||||
def _got_symlink(self, value: Optional[str]) -> None:
|
||||
if value is None:
|
||||
self.exit()
|
||||
return
|
||||
self._symlink = value == "Yes"
|
||||
self._start()
|
||||
|
||||
def _start(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen(
|
||||
"Rebuilding packages",
|
||||
lambda s: _task_rebuild(s, self._packages, self._symlink),
|
||||
show_progress=True,
|
||||
),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"rebuild",
|
||||
help="Rebuild ROS2 packages in src/ with colcon",
|
||||
)
|
||||
p.add_argument(
|
||||
"packages",
|
||||
nargs="*",
|
||||
metavar="PACKAGE",
|
||||
help="Packages to rebuild (omit to be asked, or leave empty for all)",
|
||||
)
|
||||
p.set_defaults(symlink=None)
|
||||
p.add_argument(
|
||||
"--symlink-install",
|
||||
dest="symlink",
|
||||
action="store_true",
|
||||
help="Pass --symlink-install to colcon build",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-symlink-install",
|
||||
dest="symlink",
|
||||
action="store_false",
|
||||
help="Do not pass --symlink-install to colcon build",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Entry point for the rebuild command.
|
||||
Точка входа для команды rebuild.
|
||||
"""
|
||||
# Convert empty list to None so the TUI asks interactively.
|
||||
# Преобразуем пустой список в None, чтобы TUI спросил интерактивно.
|
||||
packages = args.packages if args.packages else None
|
||||
_RebuildApp(packages, args.symlink).run()
|
||||
@@ -0,0 +1,326 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Static
|
||||
|
||||
from cobot.tui import SCREEN_CSS, InputScreen, PickScreen
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
||||
|
||||
# Use ruamel.yaml instead of PyYAML so comments and formatting in the config file are preserved.
|
||||
# Используем ruamel.yaml вместо PyYAML, чтобы комментарии и форматирование в конфиге сохранялись.
|
||||
_yaml = YAML()
|
||||
_yaml.preserve_quotes = True
|
||||
|
||||
|
||||
# One question inside a configuration block.
|
||||
# A field can either show a pick list (options) or a free-text input (no options).
|
||||
# Один вопрос внутри блока конфигурации.
|
||||
# Поле может показывать список вариантов (options) или поле для ввода текста (без options).
|
||||
@dataclass
|
||||
class _Field:
|
||||
key: str # dot-separated path within the block, e.g. "webots.world"
|
||||
question: str
|
||||
default: Any
|
||||
note: str = ""
|
||||
options: Optional[List[str]] = None # if set - PickScreen, else - InputScreen
|
||||
|
||||
def label(self) -> str:
|
||||
return self.key.split(".")[-1]
|
||||
|
||||
|
||||
# A group of related fields shown together under one "Configure X?" question.
|
||||
# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?".
|
||||
@dataclass
|
||||
class _Block:
|
||||
yaml_key: str # top-level key in cobot-setting.yaml
|
||||
title: str # shown in "Configure <title>?" prompt
|
||||
fields: List[_Field]
|
||||
|
||||
|
||||
# All configuration blocks. Each block maps to a top-level key in cobot-setting.yaml.
|
||||
# Все блоки конфигурации. Каждый блок соответствует ключу верхнего уровня в cobot-setting.yaml.
|
||||
_BLOCKS: List[_Block] = [
|
||||
_Block(
|
||||
yaml_key="foxglove",
|
||||
title="Foxglove bridge",
|
||||
fields=[
|
||||
_Field("enabled", "Enable Foxglove bridge?", "true",
|
||||
note="Start foxglove_bridge alongside the robot node",
|
||||
options=["true", "false"]),
|
||||
_Field("port", "WebSocket port:", "8765",
|
||||
note="Port Foxglove Studio connects to (default 8765)"),
|
||||
_Field("address", "Listen address:", "0.0.0.0",
|
||||
note="0.0.0.0 = all interfaces, 127.0.0.1 = localhost only",
|
||||
options=["0.0.0.0", "127.0.0.1"]),
|
||||
_Field("use_sim_time", "Use simulation time (/clock)?", "false",
|
||||
note="Subscribe to /clock instead of using wall time",
|
||||
options=["false", "true"]),
|
||||
_Field("debug", "Enable verbose bridge logging?", "false",
|
||||
options=["false", "true"]),
|
||||
_Field("num_threads", "Executor threads (0 = auto):", "0"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="planning",
|
||||
title="MoveIt planning",
|
||||
fields=[
|
||||
_Field("pose_link", "TCP link name:", "tcp",
|
||||
note="Link used as the end-effector for Cartesian goals (defined in URDF/SRDF)"),
|
||||
_Field("planning_group", "Planning group:", "iiwa_arm",
|
||||
note="MoveIt planning group as defined in the SRDF"),
|
||||
_Field("default_frame", "Default reference frame:", "base_link"),
|
||||
_Field("default_planner", "Default planner:", "ompl",
|
||||
options=["ompl", "pilz_industrial_motion_planner", "chomp"]),
|
||||
_Field("planning_attempts", "Planning attempts:", "3"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="digital_twin",
|
||||
title="Digital twin (Webots / RViz)",
|
||||
fields=[
|
||||
_Field("webots.transform", "Robot transform in Webots scene (x y z, metres):", "-0.25 0 0.79"),
|
||||
_Field("webots.rotation", "Robot rotation in Webots scene (ax ay az angle):", "0 0 1 0"),
|
||||
_Field("webots.controller_timer", "Webots controller step timer (ms):", "50"),
|
||||
],
|
||||
),
|
||||
_Block(
|
||||
yaml_key="robot",
|
||||
title="Robot connection",
|
||||
fields=[
|
||||
_Field("name", "Robot model name:", "iiwa7"),
|
||||
_Field("ip", "Robot IP address:", "192.170.10.2",
|
||||
note="IP of the KUKA controller on the FRI network interface"),
|
||||
_Field("port", "FRI port:", "30200"),
|
||||
_Field("command_mode", "Command mode:", "position",
|
||||
note="position = joint position control, torque = joint torque control",
|
||||
options=["position", "torque"]),
|
||||
_Field("fri_cycle_ms", "FRI cycle time (ms):", "10",
|
||||
note="5 ms = 200 Hz, 10 ms = 100 Hz",
|
||||
options=["10", "5"]),
|
||||
_Field("active_controller", "Active ROS controller:", "jtc",
|
||||
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
|
||||
options=["jtc", "forward"]),
|
||||
_Field("joint_position_tau", "Position EMA filter tau (s):", "0.04",
|
||||
note="Smooths position commands before sending to FRI"),
|
||||
_Field("joint_velocity_tau", "Velocity EMA filter tau (s):", "0.01",
|
||||
note="Removes spikes from finite-difference velocity estimation"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Try to keep the original YAML type (bool, int, float) when saving a value back.
|
||||
# Trying to preserve type prevents "true" from becoming a plain string in the YAML file.
|
||||
# Пытаемся сохранить исходный тип YAML (bool, int, float) при записи значения обратно.
|
||||
# Сохранение типа предотвращает превращение "true" в обычную строку в YAML-файле.
|
||||
def _coerce(value: str, original: Any) -> Any:
|
||||
"""Convert a string value to match the type of the original YAML value (bool, int, float, str).
|
||||
Преобразует строковое значение к типу исходного значения YAML (bool, int, float, str).
|
||||
"""
|
||||
if isinstance(original, bool):
|
||||
return value.lower() == "true"
|
||||
if isinstance(original, int):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if isinstance(original, float):
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
# Read a value from a nested YAML mapping using a dot-separated key like "webots.transform".
|
||||
# Читаем значение из вложенного YAML-словаря по ключу с точками, например "webots.transform".
|
||||
def _get_nested(mapping: Any, path: str) -> Any:
|
||||
"""Return the value at a dot-separated path inside a nested YAML mapping, or None if missing.
|
||||
Возвращает значение по пути с точками внутри вложенного YAML-словаря, или None если отсутствует.
|
||||
"""
|
||||
keys = path.split(".")
|
||||
cur = mapping
|
||||
for k in keys:
|
||||
if cur is None or k not in cur:
|
||||
return None
|
||||
cur = cur[k]
|
||||
return cur
|
||||
|
||||
|
||||
# Write a value into a nested YAML mapping using a dot-separated key.
|
||||
# Записываем значение в вложенный YAML-словарь по ключу с точками.
|
||||
def _set_nested(mapping: Any, path: str, value: Any) -> None:
|
||||
"""Set the value at a dot-separated path inside a nested YAML mapping, coercing type to match.
|
||||
Устанавливает значение по пути с точками во вложенном YAML-словаре, приводя тип к исходному.
|
||||
"""
|
||||
keys = path.split(".")
|
||||
cur = mapping
|
||||
for k in keys[:-1]:
|
||||
cur = cur[k]
|
||||
original = cur[keys[-1]]
|
||||
cur[keys[-1]] = _coerce(value, original)
|
||||
|
||||
|
||||
# Shown after all blocks have been configured to confirm the file was saved.
|
||||
# Показывается после настройки всех блоков для подтверждения сохранения файла.
|
||||
class _SavedScreen(Screen[None]):
|
||||
"""Confirmation screen shown after all configuration blocks are saved. Press Enter to close.
|
||||
Экран подтверждения, показываемый после сохранения всех блоков конфигурации. Enter для закрытия.
|
||||
"""
|
||||
BINDINGS = [Binding("enter,escape", "close", "Close")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("Done", id="step")
|
||||
yield Static(f"Configuration saved to {_CONFIG_PATH.name}", id="question")
|
||||
yield Static("Press Enter to close.", id="note")
|
||||
yield Footer()
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
# The main configuration wizard. Goes through each block in order.
|
||||
# For each block it first asks "Configure X?" then steps through all its fields.
|
||||
# Главный мастер конфигурации. Проходит по каждому блоку по порядку.
|
||||
# Для каждого блока сначала спрашивает "Настроить X?" а затем проходит по всем его полям.
|
||||
class _Wizard(App[None]):
|
||||
"""Configuration wizard that iterates over all _BLOCKS. For each block it asks
|
||||
"Configure X?" and if confirmed steps through every field with PickScreen or InputScreen.
|
||||
Saves to cobot-setting.yaml when all blocks are done and shows _SavedScreen.
|
||||
Мастер конфигурации, проходящий по всем _BLOCKS. Для каждого блока спрашивает
|
||||
"Настроить X?" и при подтверждении проходит по всем полям через PickScreen или InputScreen.
|
||||
Сохраняет в cobot-setting.yaml по завершении и показывает _SavedScreen.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, data: Any):
|
||||
super().__init__()
|
||||
self._data = data
|
||||
self._blocks = list(_BLOCKS)
|
||||
self._block_idx = 0
|
||||
self._field_idx = 0
|
||||
self._current_block: Optional[_Block] = None
|
||||
self._pending_fields: List[_Field] = []
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._next_block()
|
||||
|
||||
def _next_block(self) -> None:
|
||||
if self._block_idx >= len(self._blocks):
|
||||
# All blocks done - save and show the confirmation screen.
|
||||
# Все блоки пройдены - сохраняем и показываем экран подтверждения.
|
||||
_save_config(self._data)
|
||||
self.push_screen(_SavedScreen(), lambda _: self.exit())
|
||||
return
|
||||
block = self._blocks[self._block_idx]
|
||||
total = len(self._blocks)
|
||||
step = f"Block {self._block_idx + 1} of {total}"
|
||||
self.push_screen(
|
||||
PickScreen(
|
||||
step,
|
||||
f"Configure {block.title}?",
|
||||
["Yes", "No"],
|
||||
"Yes",
|
||||
),
|
||||
lambda v: self._got_block_choice(v, block),
|
||||
)
|
||||
|
||||
def _got_block_choice(self, v: Optional[str], block: _Block) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
self._block_idx += 1
|
||||
if v == "Yes":
|
||||
self._current_block = block
|
||||
self._pending_fields = list(block.fields)
|
||||
self._field_idx = 0
|
||||
self._next_field()
|
||||
else:
|
||||
# Skip all fields in this block and jump to the next block.
|
||||
# Пропускаем все поля этого блока и переходим к следующему.
|
||||
self._next_block()
|
||||
|
||||
def _next_field(self) -> None:
|
||||
if not self._pending_fields:
|
||||
self._next_block()
|
||||
return
|
||||
|
||||
f = self._pending_fields[0]
|
||||
block = self._current_block
|
||||
total_blocks = len(self._blocks)
|
||||
block_num = self._block_idx # already incremented
|
||||
self._field_idx += 1
|
||||
field_num = self._field_idx
|
||||
total_fields = len(block.fields)
|
||||
|
||||
step = f"Block {block_num} of {total_blocks} - Field {field_num} of {total_fields}"
|
||||
|
||||
# Resolve current value from loaded YAML as the pre-filled default
|
||||
yaml_val = _get_nested(self._data[block.yaml_key], f.key)
|
||||
current = str(yaml_val) if yaml_val is not None else f.default
|
||||
|
||||
if f.options:
|
||||
# Make the current value the default selection
|
||||
default_opt = current if current in f.options else f.options[0]
|
||||
screen = PickScreen(step, f.question, f.options, default_opt, note=f.note)
|
||||
else:
|
||||
screen = InputScreen(step, f.question, current, note=f.note)
|
||||
|
||||
self.push_screen(screen, lambda v, _f=f: self._got_field(v, _f))
|
||||
|
||||
def _got_field(self, v: Optional[str], f: _Field) -> None:
|
||||
if v is None:
|
||||
self.exit()
|
||||
return
|
||||
block = self._current_block
|
||||
_set_nested(self._data[block.yaml_key], f.key, v)
|
||||
# Remove the field we just handled and move on to the next one.
|
||||
# Удаляем только что обработанное поле и переходим к следующему.
|
||||
self._pending_fields.pop(0)
|
||||
self._next_field()
|
||||
|
||||
|
||||
# Load the config file preserving all comments and key order.
|
||||
# Загружаем конфиг-файл, сохраняя все комментарии и порядок ключей.
|
||||
def _load_config() -> Any:
|
||||
"""Load cobot-setting.yaml with ruamel.yaml, preserving comments and key order.
|
||||
Загружает cobot-setting.yaml с помощью ruamel.yaml, сохраняя комментарии и порядок ключей.
|
||||
"""
|
||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
|
||||
return _yaml.load(fh)
|
||||
|
||||
|
||||
# Write the modified config back to disk preserving comments and formatting.
|
||||
# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование.
|
||||
def _save_config(data: Any) -> None:
|
||||
"""Write the modified YAML data back to cobot-setting.yaml, preserving comments.
|
||||
Записывает изменённые данные YAML обратно в cobot-setting.yaml, сохраняя комментарии.
|
||||
"""
|
||||
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
|
||||
_yaml.dump(data, fh)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser("robot-setup", help="Configure cobot-setting.yaml interactively")
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
if not _CONFIG_PATH.exists():
|
||||
from rich.console import Console
|
||||
Console().print(f"[red]Config not found:[/red] {_CONFIG_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
data = _load_config()
|
||||
_Wizard(data).run()
|
||||
@@ -0,0 +1,560 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen
|
||||
from cobot.commands.local_setup import webots_installed, WebotsInstallApp, _WEBOTS_VERSION
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
||||
_INSTALL_DIR = _PROJECT_DIR / "install"
|
||||
_JAZZY_DIR = Path("/opt/ros/jazzy")
|
||||
# Default Webots installation path for the official .deb package.
|
||||
# Путь установки Webots по умолчанию для официального .deb-пакета.
|
||||
_WEBOTS_DEFAULT_HOME = Path("/usr/local/webots")
|
||||
|
||||
# Path where the config file is mounted inside the Docker container.
|
||||
# Путь по которому конфиг-файл монтируется внутри Docker-контейнера.
|
||||
_CONFIG_IN_CONTAINER = "/ros2_ws/cobot-setting.yaml"
|
||||
|
||||
# Container names used for docker run and docker kill.
|
||||
# Имена контейнеров, используемые для docker run и docker kill.
|
||||
_CONTAINER_CONTROLLER = "lwc-controller"
|
||||
_CONTAINER_WEBOTS = "lwc-webots"
|
||||
|
||||
# Named Docker volume that stores the Webots asset cache between container runs.
|
||||
# Without it Webots re-downloads all 3D assets from the internet on every launch.
|
||||
# Именованный Docker volume для хранения кэша ассетов Webots между запусками контейнера.
|
||||
# Без него Webots заново скачивает все 3D-ассеты из интернета при каждом запуске.
|
||||
_WEBOTS_CACHE_VOLUME = "lwc-webots-cache"
|
||||
|
||||
# Candidates checked in order - for the controller the webots image is a valid fallback
|
||||
# because it already contains all controller packages too.
|
||||
# Кандидаты проверяются по порядку - для контроллера образ webots является допустимым запасным,
|
||||
# так как он уже содержит все пакеты контроллера.
|
||||
_CONTROLLER_IMAGES = [
|
||||
"lwc-local:ros-iiwa7-jazzy",
|
||||
"evilfisru/lwc:iiwa-jazzy",
|
||||
"evilfisru/lwc:iiwa-jazzy-dev",
|
||||
"lwc-local:ros-iiwa7-webots-jazzy",
|
||||
"evilfisru/lwc:webots-jazzy",
|
||||
"evilfisru/lwc:webots-jazzy-dev",
|
||||
]
|
||||
_WEBOTS_IMAGES = [
|
||||
"lwc-local:ros-iiwa7-webots-jazzy",
|
||||
"evilfisru/lwc:webots-jazzy",
|
||||
"evilfisru/lwc:webots-jazzy-dev",
|
||||
]
|
||||
|
||||
|
||||
# A minimal app that asks one question and exits immediately with the chosen value.
|
||||
# We need a full App because Textual screens cannot run outside one.
|
||||
# Минимальное приложение, которое задаёт один вопрос и сразу выходит с выбранным значением.
|
||||
# Нам нужен полноценный App, потому что экраны Textual не могут работать вне него.
|
||||
class _Ask(App[Optional[str]]):
|
||||
"""Minimal one-question Textual app. Pushes a PickScreen and exits with the chosen value.
|
||||
Минимальное однвопросное Textual-приложение. Открывает PickScreen и завершается с выбранным значением.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, step: str, question: str, options: List[str], default: str):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._options = options
|
||||
self._default = default
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(self._step, self._question, self._options, self._default),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
||||
"""Show a single-choice PickScreen and return the selected value, or None on Escape.
|
||||
Показывает PickScreen с одним выбором и возвращает выбранное значение или None при Escape.
|
||||
"""
|
||||
# Returns None when the user pressed Escape to cancel.
|
||||
# Возвращает None когда пользователь нажал Escape для отмены.
|
||||
return _Ask(step, question, options, default).run()
|
||||
|
||||
|
||||
def _detect_webots_home() -> str:
|
||||
"""Return the WEBOTS_HOME path for the locally installed Linux Webots.
|
||||
|
||||
Validates that the candidate directory contains the Linux 'webots' binary
|
||||
|
||||
Возвращает путь WEBOTS_HOME для локально установленного Linux Webots.
|
||||
"""
|
||||
def _is_linux_webots(home: str) -> bool:
|
||||
# Accept only directories that have the Linux 'webots' binary directly inside.
|
||||
# Принимаем только директории с Linux-бинарником 'webots' напрямую внутри.
|
||||
return (Path(home) / "webots").is_file()
|
||||
|
||||
if "WEBOTS_HOME" in os.environ:
|
||||
home = os.environ["WEBOTS_HOME"]
|
||||
if _is_linux_webots(home):
|
||||
return home
|
||||
return ""
|
||||
if _WEBOTS_DEFAULT_HOME.is_dir() and _is_linux_webots(str(_WEBOTS_DEFAULT_HOME)):
|
||||
return str(_WEBOTS_DEFAULT_HOME)
|
||||
webots_bin = shutil.which("webots")
|
||||
if webots_bin:
|
||||
home = str(Path(webots_bin).resolve().parent)
|
||||
if _is_linux_webots(home):
|
||||
return home
|
||||
return ""
|
||||
|
||||
|
||||
# Detect the GPU type so we can pass the right flags to docker run for Webots rendering.
|
||||
# Определяем тип GPU, чтобы передать нужные флаги в docker run для рендеринга Webots.
|
||||
def _detect_gpu() -> str:
|
||||
"""Return "nvidia", "mesa", or "software" based on what GPU drivers are available.
|
||||
Возвращает "nvidia", "mesa" или "software" в зависимости от доступных драйверов GPU.
|
||||
"""
|
||||
if shutil.which("nvidia-smi"):
|
||||
if subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0:
|
||||
return "nvidia"
|
||||
if Path("/dev/dri").exists():
|
||||
return "mesa"
|
||||
return "software"
|
||||
|
||||
|
||||
# List all Docker images currently available on this machine.
|
||||
# Получаем список всех Docker-образов доступных на этой машине.
|
||||
def _docker_images() -> set:
|
||||
"""Return the set of "repository:tag" strings for all locally available Docker images.
|
||||
Возвращает множество строк "репозиторий:тег" для всех локально доступных Docker-образов.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return set(r.stdout.strip().splitlines())
|
||||
|
||||
|
||||
# Return the first image from the candidates list that is already present locally.
|
||||
# Возвращаем первый образ из списка кандидатов, который уже присутствует локально.
|
||||
def _find_image(candidates: List[str]) -> Optional[str]:
|
||||
"""Return the first candidate image that exists locally, or None if none are available.
|
||||
Возвращает первый образ-кандидат, присутствующий локально, или None если ни один не найден.
|
||||
"""
|
||||
available = _docker_images()
|
||||
for img in candidates:
|
||||
if img in available:
|
||||
return img
|
||||
return None
|
||||
|
||||
|
||||
# Build the ROS2 project locally with colcon. Used when launching in local mode
|
||||
# and the install/ directory does not exist yet.
|
||||
# Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме,
|
||||
# если директория install/ ещё не существует.
|
||||
def _task_build(screen: LogScreen) -> None:
|
||||
"""Worker function that runs inside LogScreen. Counts packages, then runs colcon build
|
||||
with release mixin and updates progress as each package finishes.
|
||||
Рабочая функция внутри LogScreen. Подсчитывает пакеты, запускает colcon build с mixin release
|
||||
и обновляет прогресс по мере завершения каждого пакета.
|
||||
"""
|
||||
try:
|
||||
screen.write("[bold]Building project with colcon[/bold]\n")
|
||||
|
||||
# Count packages first so we can show X/total progress.
|
||||
# Сначала считаем пакеты, чтобы показывать X/всего в прогрессе.
|
||||
list_proc = subprocess.run(
|
||||
["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"],
|
||||
capture_output=True, text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
total = max(len([l for l in list_proc.stdout.splitlines() if l.strip()]), 1)
|
||||
screen.set_progress(0, f"0 / {total} packages done")
|
||||
built = 0
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c",
|
||||
f"source {_JAZZY_DIR}/setup.bash && colcon build --mixin release"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
# colcon prints "Finished <<<" or "Failed <<<" when each package is done.
|
||||
# colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов.
|
||||
if "Finished <<<" in line or "Failed <<<" in line:
|
||||
built += 1
|
||||
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
|
||||
proc.wait()
|
||||
|
||||
if proc.returncode != 0:
|
||||
screen.write("\n[red]Build failed.[/red]")
|
||||
screen.finish(False)
|
||||
return
|
||||
|
||||
screen.set_progress(100, "Build complete")
|
||||
screen.write("\n[green]Build successful.[/green]")
|
||||
screen.finish(True)
|
||||
except Exception as exc:
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
class _BuildApp(App[bool]):
|
||||
"""Minimal app that opens a LogScreen running _task_build and exits with the build result.
|
||||
Минимальное приложение, открывающее LogScreen с _task_build и завершающееся с результатом сборки.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen("Building project before launch", _task_build, show_progress=True),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
# Start the ROS2 launch file directly on this machine without Docker.
|
||||
# Uses start_new_session so we can kill the whole process group with one signal.
|
||||
# Запускаем launch-файл ROS2 напрямую на этой машине без Docker.
|
||||
# Используем start_new_session, чтобы можно было убить всю группу процессов одним сигналом.
|
||||
def _task_run_local(screen: RunScreen, mode: str) -> None:
|
||||
"""Worker function that runs inside RunScreen. Launches iiwa.launch.py locally by sourcing
|
||||
ROS2 and install/setup.bash, then streams output until the process exits or is stopped.
|
||||
Рабочая функция внутри RunScreen. Запускает iiwa.launch.py локально через source ROS2 и
|
||||
install/setup.bash, затем транслирует вывод до завершения процесса или его остановки.
|
||||
"""
|
||||
config = str(_CONFIG_PATH)
|
||||
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}"
|
||||
if mode == "webots":
|
||||
ros_cmd += " simulate:=1"
|
||||
|
||||
webots_home = _detect_webots_home() if mode == "webots" else ""
|
||||
webots_export = f"export WEBOTS_HOME={webots_home} && " if webots_home else ""
|
||||
|
||||
full_cmd = (
|
||||
f"{webots_export}"
|
||||
f"source {_JAZZY_DIR}/setup.bash && "
|
||||
f"source {_INSTALL_DIR}/setup.bash && "
|
||||
f"{ros_cmd}"
|
||||
)
|
||||
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
screen.write(f"[bold]Launching {label} (local)[/bold]")
|
||||
screen.write(f"[dim]{ros_cmd}[/dim]")
|
||||
if webots_home:
|
||||
screen.write(f"[dim]WEBOTS_HOME: {webots_home}[/dim]")
|
||||
screen.write("")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c", full_cmd],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, cwd=_PROJECT_DIR,
|
||||
start_new_session=True,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
# Kill the entire session so all ROS2 nodes are terminated together.
|
||||
# ros2 launch puts each node in its own process group (setpgrp), so killpg on the
|
||||
# bash pgid only reaches bash/launch itself. All nodes share the session started
|
||||
# with start_new_session=True, so pkill -s reaches every one of them.
|
||||
# Убиваем всю сессию, чтобы все ROS2-узлы завершились вместе.
|
||||
# ros2 launch помещает каждый узел в отдельную группу процессов (setpgrp), поэтому
|
||||
# killpg по pgid bash достигает только bash/launch. Все узлы разделяют сессию,
|
||||
# созданную через start_new_session=True, поэтому pkill -s достигает каждого из них.
|
||||
sid = os.getsid(proc.pid)
|
||||
screen.set_kill_fn(lambda: subprocess.run(
|
||||
["pkill", "-TERM", "-s", str(sid)], capture_output=True
|
||||
))
|
||||
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
|
||||
proc.wait()
|
||||
screen.finish(stopped=screen._stopped)
|
||||
|
||||
|
||||
# Start the ROS2 launch file inside a Docker container.
|
||||
# For Webots mode we also forward X11 and GPU access so the simulator window can appear on screen.
|
||||
# Запускаем launch-файл ROS2 внутри Docker-контейнера.
|
||||
# Для режима Webots также пробрасываем X11 и доступ к GPU, чтобы окно симулятора появилось на экране.
|
||||
def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None:
|
||||
"""Worker function that runs inside RunScreen. Builds the docker run command with the
|
||||
appropriate GPU/X11 flags for Webots, then streams container output until stopped or exited.
|
||||
Рабочая функция внутри RunScreen. Формирует команду docker run с нужными флагами GPU/X11
|
||||
для Webots, затем транслирует вывод контейнера до остановки или завершения.
|
||||
"""
|
||||
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER
|
||||
|
||||
ros_cmd = (
|
||||
"source /ros2_ws/install/setup.bash && "
|
||||
f"ros2 launch iiwa_bringup iiwa.launch.py setting:={_CONFIG_IN_CONTAINER}"
|
||||
)
|
||||
if mode == "webots":
|
||||
ros_cmd += " simulate:=1"
|
||||
|
||||
# Remove any stale container with the same name left from a previous run.
|
||||
# Удаляем устаревший контейнер с таким же именем, оставшийся от предыдущего запуска.
|
||||
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
||||
|
||||
cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"--name", container,
|
||||
"--network", "host",
|
||||
"--hostname", socket.gethostname(),
|
||||
"-e", "USER=root",
|
||||
]
|
||||
|
||||
if mode == "webots":
|
||||
# Allow the container to open windows on the host display.
|
||||
# Разрешаем контейнеру открывать окна на дисплее хоста.
|
||||
subprocess.run(["xhost", "+local:docker"], capture_output=True)
|
||||
cmd += [
|
||||
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
|
||||
"-e", "QT_X11_NO_MITSHM=1",
|
||||
"-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw",
|
||||
# Persist the Webots asset cache so it is not re-downloaded on every launch.
|
||||
# Сохраняем кэш ассетов Webots, чтобы он не скачивался заново при каждом запуске.
|
||||
"-v", f"{_WEBOTS_CACHE_VOLUME}:/root/.cache/Cyberbotics/Webots",
|
||||
]
|
||||
if gpu == "nvidia":
|
||||
cmd += [
|
||||
"--gpus", "all",
|
||||
"-e", "NVIDIA_VISIBLE_DEVICES=all",
|
||||
"-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
|
||||
]
|
||||
elif gpu == "mesa":
|
||||
# Pass through the DRI device for Intel/AMD hardware acceleration.
|
||||
# Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD.
|
||||
cmd += ["--device", "/dev/dri"]
|
||||
else:
|
||||
# No GPU found - fall back to software rendering via llvmpipe.
|
||||
# GPU не найден - используем программный рендеринг через llvmpipe.
|
||||
cmd += [
|
||||
"-e", "LIBGL_ALWAYS_SOFTWARE=1",
|
||||
"-e", "GALLIUM_DRIVER=llvmpipe",
|
||||
]
|
||||
|
||||
# Mount the config file so the container uses our local cobot-setting.yaml.
|
||||
# Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml.
|
||||
if _CONFIG_PATH.exists():
|
||||
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"]
|
||||
|
||||
cmd += [image, "bash", "-c", ros_cmd]
|
||||
|
||||
_GPU_LABELS = {
|
||||
"nvidia": "NVIDIA GPU",
|
||||
"mesa": "Intel/AMD DRI (Mesa)",
|
||||
"software": "Software rendering (llvmpipe)",
|
||||
}
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
screen.write(f"[bold]Launching {label} in Docker[/bold]")
|
||||
screen.write(f"[dim]Image: {image}[/dim]")
|
||||
if mode == "webots":
|
||||
screen.write(f"[dim]GPU: {_GPU_LABELS.get(gpu, gpu)}[/dim]")
|
||||
screen.write("")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
screen.set_proc(proc)
|
||||
# Use docker kill instead of proc.terminate() so the container is stopped immediately.
|
||||
# Terminating only the docker CLI process leaves the container itself running.
|
||||
# Используем docker kill вместо proc.terminate(), чтобы контейнер остановился немедленно.
|
||||
# Завершение только процесса docker CLI оставляет сам контейнер работающим.
|
||||
screen.set_kill_fn(lambda: subprocess.run(["docker", "kill", container], capture_output=True))
|
||||
|
||||
for line in proc.stdout:
|
||||
s = line.rstrip()
|
||||
if s:
|
||||
screen.write(s)
|
||||
|
||||
proc.wait()
|
||||
screen.finish(stopped=screen._stopped)
|
||||
|
||||
|
||||
# Wraps a RunScreen in an App so it can be launched with .run().
|
||||
# Оборачивает RunScreen в App, чтобы его можно было запустить через .run().
|
||||
class _RunApp(App[None]):
|
||||
"""Minimal app that wraps a RunScreen so it can be started with .run().
|
||||
Минимальное приложение, оборачивающее RunScreen чтобы его можно было запустить через .run().
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, title: str, task: Callable):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._run_fn = task
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit())
|
||||
|
||||
|
||||
# Guide the user through launching locally - asks what to run, checks prerequisites,
|
||||
# installs Webots and builds the project if needed, then launches.
|
||||
# Ведёт пользователя через локальный запуск - спрашивает что запустить, проверяет
|
||||
# предварительные условия, устанавливает Webots и собирает проект при необходимости, затем запускает.
|
||||
def _local_flow(args: argparse.Namespace) -> None:
|
||||
"""Interactive flow for local (non-Docker) launch. Checks Webots, ROS2, and build state,
|
||||
offers to install/build missing pieces, then starts RunScreen.
|
||||
Интерактивный сценарий для локального (не Docker) запуска. Проверяет Webots, ROS2 и состояние
|
||||
сборки, предлагает установить/собрать недостающее, затем запускает RunScreen.
|
||||
"""
|
||||
mode_v = _ask(
|
||||
"Run local",
|
||||
"What do you want to launch?",
|
||||
["Controller", "Webots simulator"],
|
||||
"Controller",
|
||||
)
|
||||
if mode_v is None:
|
||||
return
|
||||
mode = "webots" if mode_v == "Webots simulator" else "controller"
|
||||
|
||||
# Check Webots installed (local mode only)
|
||||
if mode == "webots" and not webots_installed():
|
||||
v = _ask(
|
||||
"Webots not found",
|
||||
f"Webots {_WEBOTS_VERSION} is not installed. Install it now?",
|
||||
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, cancel"],
|
||||
f"Yes, install Webots {_WEBOTS_VERSION}",
|
||||
)
|
||||
if v is None or v.startswith("No"):
|
||||
return
|
||||
ok = WebotsInstallApp().run()
|
||||
if not ok:
|
||||
return
|
||||
|
||||
# Check ROS2 Jazzy
|
||||
if not _JAZZY_DIR.is_dir():
|
||||
v = _ask(
|
||||
"ROS2 not found",
|
||||
"ROS2 Jazzy is not installed. Run local-setup now?",
|
||||
["Yes, run local-setup", "No, cancel"],
|
||||
"Yes, run local-setup",
|
||||
)
|
||||
if v and v.startswith("Yes"):
|
||||
from cobot.commands.local_setup import run as _local_setup
|
||||
_local_setup(args)
|
||||
return
|
||||
|
||||
# Check project built
|
||||
if not (_INSTALL_DIR / "setup.bash").exists():
|
||||
v = _ask(
|
||||
"Project not built",
|
||||
"The project has not been built yet. Build it now?",
|
||||
["Yes, build now", "No, cancel"],
|
||||
"Yes, build now",
|
||||
)
|
||||
if v is None or v.startswith("No"):
|
||||
return
|
||||
ok = _BuildApp().run()
|
||||
if not ok:
|
||||
return
|
||||
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
_RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run()
|
||||
|
||||
|
||||
# Guide the user through launching in Docker - asks what to run, finds a suitable image,
|
||||
# detects the GPU for Webots, and launches.
|
||||
# Ведёт пользователя через запуск в Docker - спрашивает что запустить, ищет подходящий образ,
|
||||
# определяет GPU для Webots и запускает.
|
||||
def _docker_flow(args: argparse.Namespace) -> None:
|
||||
"""Interactive flow for Docker launch. Finds the best available image, detects the GPU
|
||||
for Webots mode, then starts RunScreen with the docker run task.
|
||||
Интерактивный сценарий для запуска в Docker. Находит лучший доступный образ, определяет GPU
|
||||
для режима Webots, затем запускает RunScreen с задачей docker run.
|
||||
"""
|
||||
if not shutil.which("docker"):
|
||||
from rich.console import Console
|
||||
Console().print("[red]Error:[/red] Docker is not installed or not on PATH.")
|
||||
return
|
||||
|
||||
mode_v = _ask(
|
||||
"Run in Docker",
|
||||
"What do you want to launch?",
|
||||
["Controller", "Webots simulator"],
|
||||
"Controller",
|
||||
)
|
||||
if mode_v is None:
|
||||
return
|
||||
mode = "webots" if mode_v == "Webots simulator" else "controller"
|
||||
|
||||
candidates = _WEBOTS_IMAGES if mode == "webots" else _CONTROLLER_IMAGES
|
||||
image = _find_image(candidates)
|
||||
|
||||
if image is None:
|
||||
# No image available - offer to run docker-setup to get one.
|
||||
# Образ не найден - предлагаем запустить docker-setup чтобы его получить.
|
||||
what = "Webots" if mode == "webots" else "controller or Webots"
|
||||
v = _ask(
|
||||
"No image found",
|
||||
f"No Docker image found for {what}. Run docker-setup now?",
|
||||
["Yes, run docker-setup", "No, cancel"],
|
||||
"Yes, run docker-setup",
|
||||
)
|
||||
if v and v.startswith("Yes"):
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
_docker_setup(args)
|
||||
return
|
||||
|
||||
# Only detect GPU for Webots - the controller does not need a display.
|
||||
# GPU определяем только для Webots - контроллеру дисплей не нужен.
|
||||
gpu = _detect_gpu() if mode == "webots" else "software"
|
||||
|
||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||
_RunApp(
|
||||
f"Running {label} — Docker",
|
||||
lambda s: _task_run_docker(s, image, mode, gpu),
|
||||
).run()
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser(
|
||||
"run",
|
||||
help="Launch the robot controller or Webots simulator",
|
||||
)
|
||||
p.add_argument(
|
||||
"mode",
|
||||
nargs="?",
|
||||
choices=["local", "docker"],
|
||||
default=None,
|
||||
help="local — native ROS2, docker — Docker container (default: ask)",
|
||||
)
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
mode = getattr(args, "mode", None)
|
||||
|
||||
if mode == "local":
|
||||
_local_flow(args)
|
||||
elif mode == "docker":
|
||||
_docker_flow(args)
|
||||
else:
|
||||
# No mode given - ask the user how they want to run.
|
||||
# Режим не указан - спрашиваем пользователя как он хочет запустить.
|
||||
v = _ask(
|
||||
"Run",
|
||||
"How do you want to run the project?",
|
||||
["Local (native ROS2)", "Docker"],
|
||||
"Local (native ROS2)",
|
||||
)
|
||||
if v is None:
|
||||
return
|
||||
if v.startswith("Local"):
|
||||
_local_flow(args)
|
||||
else:
|
||||
_docker_flow(args)
|
||||
@@ -0,0 +1,97 @@
|
||||
import argparse
|
||||
from typing import List, Optional
|
||||
|
||||
from textual.app import App
|
||||
|
||||
# Import each sub-command's run() so we can call them in sequence.
|
||||
# Импортируем run() каждой подкоманды, чтобы вызывать их по порядку.
|
||||
from cobot.commands.doc_setup import run as _doc_setup
|
||||
from cobot.commands.docker_setup import run as _docker_setup
|
||||
from cobot.commands.local_setup import run as _local_setup
|
||||
from cobot.commands.robot_setup import run as _robot_setup
|
||||
from cobot.tui import SCREEN_CSS, PickScreen
|
||||
|
||||
|
||||
# A minimal Textual app that asks a single question and exits with the chosen value.
|
||||
# We need this because Textual screens cannot run outside of an App context.
|
||||
# Минимальное Textual-приложение, которое задаёт один вопрос и выходит с выбранным значением.
|
||||
# Нам это нужно, потому что экраны Textual не могут работать вне контекста приложения.
|
||||
class _Ask(App[Optional[str]]):
|
||||
"""Minimal one-question Textual app. Pushes a PickScreen and exits with the chosen value.
|
||||
Минимальное однвопросное Textual-приложение. Открывает PickScreen и завершается с выбранным значением.
|
||||
"""
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def __init__(self, step: str, question: str, options: List[str], default: str):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._options = options
|
||||
self._default = default
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
PickScreen(self._step, self._question, self._options, self._default),
|
||||
self.exit,
|
||||
)
|
||||
|
||||
|
||||
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
||||
"""Show a PickScreen and return the selected value, or None if the user pressed Escape.
|
||||
Показывает PickScreen и возвращает выбранное значение или None если пользователь нажал Escape.
|
||||
"""
|
||||
# Returns None if the user pressed Escape to cancel the whole wizard.
|
||||
# Возвращает None если пользователь нажал Escape для отмены всего мастера.
|
||||
return _Ask(step, question, options, default).run()
|
||||
|
||||
|
||||
def register(subparsers):
|
||||
"""Register the "setup" subparser.
|
||||
Регистрирует подпарсер "setup".
|
||||
"""
|
||||
p = subparsers.add_parser("setup", help="First-time project setup")
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
"""Run the three-step first-time setup wizard: doc server -> build env -> robot config.
|
||||
Запускает трёхшаговый мастер первоначальной настройки: сервер документации -> среда сборки -> конфиг.
|
||||
"""
|
||||
# Step 1 - documentation server.
|
||||
# Шаг 1 - сервер документации.
|
||||
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
|
||||
if v is None:
|
||||
return
|
||||
if v == "Yes":
|
||||
_doc_setup(args)
|
||||
|
||||
# Step 2 - build environment: local ROS2 or Docker.
|
||||
# Шаг 2 - среда сборки: локальный ROS2 или Docker.
|
||||
v = _ask(
|
||||
"Step 2 of 3",
|
||||
"How do you want to set up the build environment?",
|
||||
[
|
||||
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
|
||||
"docker-setup — build a Docker image with ROS2 Jazzy pre-installed",
|
||||
],
|
||||
"local-setup — install ROS2 Jazzy on this machine and build with colcon",
|
||||
)
|
||||
if v is None:
|
||||
return
|
||||
if v.startswith("local"):
|
||||
_local_setup(args)
|
||||
else:
|
||||
_docker_setup(args)
|
||||
|
||||
# Step 3 - robot parameters in cobot-setting.yaml.
|
||||
# Шаг 3 - параметры робота в cobot-setting.yaml.
|
||||
v = _ask(
|
||||
"Step 3 of 3",
|
||||
"Configure robot parameters (cobot-setting.yaml)?",
|
||||
["Yes", "No"],
|
||||
"Yes",
|
||||
)
|
||||
if v is None:
|
||||
return
|
||||
if v == "Yes":
|
||||
_robot_setup(args)
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App
|
||||
|
||||
from cobot.tui import SCREEN_CSS, LogScreen
|
||||
|
||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
# Pull the latest commits from the remote and reinstall the cobot CLI in one go.
|
||||
# Progress bar: fetch (0-30%), pull (30-80%), reinstall (80-100%).
|
||||
# Скачиваем последние коммиты с удалённого репозитория и переустанавливаем cobot CLI за один раз.
|
||||
# Прогресс-бар: fetch (0-30%), pull (30-80%), переустановка (80-100%).
|
||||
def _task_update(screen: LogScreen) -> None:
|
||||
"""Worker function that runs inside LogScreen. Fetches the current branch, shows incoming
|
||||
commits, pulls changes, then reinstalls the cobot CLI via uv tool install --editable.
|
||||
Рабочая функция, выполняемая внутри LogScreen. Получает текущую ветку, показывает входящие
|
||||
коммиты, вытягивает изменения, затем переустанавливает cobot CLI через uv tool install --editable.
|
||||
"""
|
||||
try:
|
||||
# Find out which branch we are on so we can fetch and pull the right one.
|
||||
# Определяем на какой ветке мы находимся, чтобы делать fetch и pull нужной ветки.
|
||||
branch = subprocess.check_output(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=_PROJECT_DIR, text=True,
|
||||
).strip()
|
||||
screen.write(f"[cyan][*][/cyan] Branch: [bold]{branch}[/bold]")
|
||||
|
||||
# Fetch (0 → 30 %)
|
||||
screen.set_progress(0, "Fetching from remote...")
|
||||
screen.write("[cyan][*][/cyan] Fetching from remote...")
|
||||
fetch_proc = subprocess.Popen(
|
||||
["git", "fetch", "origin"],
|
||||
cwd=_PROJECT_DIR, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
screen.set_proc(fetch_proc)
|
||||
fetch_out, fetch_err = fetch_proc.communicate()
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if fetch_proc.returncode not in (0, -9):
|
||||
screen.write(f"[red]Fetch failed:[/red] {fetch_err.strip()}")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(30)
|
||||
|
||||
# Count how many commits the remote is ahead of us.
|
||||
# Считаем сколько коммитов нас опережает удалённый репозиторий.
|
||||
behind = subprocess.check_output(
|
||||
["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
|
||||
cwd=_PROJECT_DIR, text=True,
|
||||
).strip()
|
||||
|
||||
if behind == "0":
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Already up to date")
|
||||
screen.write("[green][ok][/green] Already up to date.")
|
||||
screen.finish(True)
|
||||
return
|
||||
|
||||
# Show which commits are coming in so the user knows what changed.
|
||||
# Показываем какие коммиты приходят, чтобы пользователь знал что изменилось.
|
||||
screen.write(f"\n[bold]{behind} new commit(s):[/bold]")
|
||||
log_lines = subprocess.check_output(
|
||||
["git", "log", f"HEAD..origin/{branch}", "--oneline"],
|
||||
cwd=_PROJECT_DIR, text=True,
|
||||
).strip().splitlines()
|
||||
for line in log_lines:
|
||||
screen.write(f" [dim]{line}[/dim]")
|
||||
|
||||
# Pull (30 → 80 %)
|
||||
screen.set_progress(30, "Pulling changes...")
|
||||
screen.write("\n[cyan][*][/cyan] Pulling changes...")
|
||||
pull_proc = subprocess.Popen(
|
||||
["git", "pull", "origin", branch],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=_PROJECT_DIR,
|
||||
)
|
||||
screen.set_proc(pull_proc)
|
||||
pull_out, pull_err = pull_proc.communicate()
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if pull_proc.returncode not in (0, -9):
|
||||
for line in (pull_out + pull_err).splitlines():
|
||||
if line.strip():
|
||||
screen.write(line)
|
||||
screen.write("[red]Pull failed.[/red]")
|
||||
screen.finish(False)
|
||||
return
|
||||
screen.set_progress(80)
|
||||
|
||||
# Reinstall (80 → 100 %)
|
||||
# Reinstall so the cobot binary picks up any new dependencies from pyproject.toml.
|
||||
# Переустанавливаем, чтобы бинарник cobot подхватил новые зависимости из pyproject.toml.
|
||||
screen.set_progress(80, "Reinstalling cobot CLI...")
|
||||
screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
|
||||
reinstall_proc = subprocess.Popen(
|
||||
["uv", "tool", "install", "--editable", str(_PROJECT_DIR)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
screen.set_proc(reinstall_proc)
|
||||
reinstall_out, reinstall_err = reinstall_proc.communicate()
|
||||
if screen.is_stopped():
|
||||
return
|
||||
if reinstall_proc.returncode in (0, -9):
|
||||
screen.write("[green][ok][/green] cobot reinstalled")
|
||||
else:
|
||||
screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall_err.strip()}")
|
||||
|
||||
if not screen.is_stopped():
|
||||
screen.set_progress(100, "Done")
|
||||
screen.write("\n[green]Project updated successfully.[/green]")
|
||||
screen.finish(True)
|
||||
|
||||
except Exception as exc:
|
||||
if not screen.is_stopped():
|
||||
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||
screen.finish(False)
|
||||
|
||||
|
||||
class _UpdateApp(App[None]):
|
||||
"""Minimal Textual app that opens a LogScreen running _task_update and exits when it closes.
|
||||
Минимальное Textual-приложение, открывающее LogScreen с _task_update и завершающееся при закрытии.
|
||||
"""
|
||||
|
||||
CSS = SCREEN_CSS
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(
|
||||
LogScreen("Updating project", _task_update, show_progress=True),
|
||||
lambda _: self.exit(),
|
||||
)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||
p = subparsers.add_parser("update", help="Pull latest changes from the remote git branch")
|
||||
p.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
_UpdateApp().run()
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
from textual import on
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, SelectionList, Static
|
||||
from textual.widgets.selection_list import Selection
|
||||
|
||||
# Shared CSS applied to every screen in the app.
|
||||
# Общий CSS, применяемый ко всем экранам приложения.
|
||||
SCREEN_CSS = """
|
||||
Screen {
|
||||
padding: 2 4;
|
||||
}
|
||||
#step {
|
||||
color: $text-muted;
|
||||
text-style: dim;
|
||||
}
|
||||
#question {
|
||||
text-style: bold;
|
||||
color: $accent;
|
||||
margin-top: 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
#note {
|
||||
color: $text-muted;
|
||||
text-style: dim;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
RadioSet {
|
||||
height: auto;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
Input {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
LogScreen #progress {
|
||||
margin-top: 1;
|
||||
height: 1;
|
||||
}
|
||||
LogScreen #step-label {
|
||||
color: $text-muted;
|
||||
text-style: dim;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
LogScreen #log {
|
||||
height: 1fr;
|
||||
border: none;
|
||||
padding: 0 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
LogScreen #loading {
|
||||
height: 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
LogScreen #hint {
|
||||
margin-top: 1;
|
||||
color: $text;
|
||||
}
|
||||
RunScreen #log {
|
||||
height: 1fr;
|
||||
border: none;
|
||||
padding: 0 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
RunScreen #loading {
|
||||
height: 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
RunScreen #hint {
|
||||
margin-top: 1;
|
||||
color: $text-muted;
|
||||
text-style: dim;
|
||||
}
|
||||
MultiPickScreen SelectionList {
|
||||
height: auto;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# A screen that shows a question and a list of radio button options.
|
||||
# The user picks one and presses Enter - the chosen string is returned as the result.
|
||||
# Экран с вопросом и списком вариантов в виде радио-кнопок.
|
||||
# Пользователь выбирает один и нажимает Enter - выбранная строка возвращается как результат.
|
||||
class PickScreen(Screen[Optional[str]]):
|
||||
"""Single-choice radio button screen. Returns the selected option string, or None on Escape.
|
||||
Экран выбора одного варианта с радио-кнопками. Возвращает выбранную строку или None при Escape.
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "submit", "Confirm", priority=True),
|
||||
Binding("escape", "abort", "Cancel"),
|
||||
]
|
||||
|
||||
def __init__(self, step: str, question: str, options: List[str], default: str, note: str = ""):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._options = options
|
||||
self._default = default
|
||||
self._note = note
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._step, id="step")
|
||||
yield Static(self._question, id="question")
|
||||
if self._note:
|
||||
yield Static(self._note, id="note")
|
||||
with RadioSet(id="choices"):
|
||||
for opt in self._options:
|
||||
# Pre-select the default option so the user can just press Enter to accept it.
|
||||
# Заранее выделяем вариант по умолчанию, чтобы пользователь мог просто нажать Enter.
|
||||
yield RadioButton(opt, value=(opt == self._default))
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(RadioSet).focus()
|
||||
|
||||
def action_submit(self) -> None:
|
||||
radio_set = self.query_one("#choices", RadioSet)
|
||||
buttons = list(radio_set.query(RadioButton))
|
||||
idx = getattr(radio_set, "_selected", None)
|
||||
if idx is not None and 0 <= idx < len(buttons):
|
||||
self.dismiss(str(buttons[idx].label))
|
||||
else:
|
||||
btn = radio_set.pressed_button
|
||||
self.dismiss(str(btn.label) if btn else self._default)
|
||||
|
||||
def action_abort(self) -> None:
|
||||
# Exit the whole app, not just this screen, so the calling code knows the user cancelled.
|
||||
# Выходим из всего приложения, а не только из этого экрана, чтобы вызывающий код знал об отмене.
|
||||
self.app.exit(None)
|
||||
|
||||
|
||||
# A screen that shows a question with a free-text input field.
|
||||
# The user types a value, presses Enter, and the text is returned as the result.
|
||||
# Экран с вопросом и полем для ввода произвольного текста.
|
||||
# Пользователь вводит значение, нажимает Enter, и текст возвращается как результат.
|
||||
class InputScreen(Screen[Optional[str]]):
|
||||
"""Free-text input screen. Returns the trimmed value on Enter, or None on Escape.
|
||||
Экран свободного ввода текста. Возвращает обрезанное значение при Enter или None при Escape.
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "submit", "Confirm", priority=True),
|
||||
Binding("escape", "abort", "Cancel"),
|
||||
]
|
||||
|
||||
def __init__(self, step: str, question: str, default: str, note: str = ""):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._default = default
|
||||
self._note = note
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._step, id="step")
|
||||
yield Static(self._question, id="question")
|
||||
if self._note:
|
||||
yield Static(self._note, id="note")
|
||||
yield Input(id="value", value=self._default)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(Input).focus()
|
||||
|
||||
@on(Input.Submitted)
|
||||
def _submitted(self, event: Input.Submitted) -> None:
|
||||
self.dismiss(event.value.strip() or self._default)
|
||||
|
||||
def action_submit(self) -> None:
|
||||
val = self.query_one(Input).value.strip()
|
||||
self.dismiss(val or self._default)
|
||||
|
||||
def action_abort(self) -> None:
|
||||
self.app.exit(None)
|
||||
|
||||
|
||||
# A screen that streams output from a background task into a scrollable log.
|
||||
# Used for long-running operations like installs and builds.
|
||||
# Press Enter or Escape to close once the task finishes.
|
||||
# Экран, который транслирует вывод фоновой задачи в прокручиваемый лог.
|
||||
# Используется для долгих операций, таких как установка и сборка.
|
||||
# После завершения задачи закрывается по нажатию Enter или Escape.
|
||||
class LogScreen(Screen[bool]):
|
||||
"""Log screen for long-running background tasks. Shows a scrollable log and optional
|
||||
progress bar. Returns True on success, False on failure after the task finishes.
|
||||
Экран лога для долгих фоновых задач. Показывает прокручиваемый лог и опциональный
|
||||
прогресс-бар. Возвращает True при успехе, False при ошибке после завершения задачи.
|
||||
"""
|
||||
BINDINGS = [Binding("enter,escape", "close", "Close", show=False)]
|
||||
|
||||
def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = False):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._run_fn = task
|
||||
self._finished = False
|
||||
self._success = False
|
||||
self._show_progress = show_progress
|
||||
# All subprocesses registered via set_proc() - every one gets killed on unmount.
|
||||
# Все подпроцессы зарегистрированные через set_proc() - каждый убивается при выходе.
|
||||
self._active_proc = None
|
||||
self._procs: list = []
|
||||
self._stopped = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._title, id="step")
|
||||
if self._show_progress:
|
||||
yield ProgressBar(id="progress", total=100, show_eta=False)
|
||||
yield Static("", id="step-label")
|
||||
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
|
||||
yield LoadingIndicator(id="loading")
|
||||
yield Static("", id="hint")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(RichLog).focus()
|
||||
# Run the task in a worker thread so the UI stays responsive.
|
||||
# Запускаем задачу в отдельном потоке, чтобы интерфейс не зависал.
|
||||
self.app.run_worker(lambda: self._run_fn(self), thread=True)
|
||||
|
||||
def set_proc(self, proc) -> None:
|
||||
# Register the subprocess that is currently running. Added to _procs so on_unmount
|
||||
# can kill it even if another proc is registered afterwards.
|
||||
# Регистрируем текущий subprocess. Добавляем в _procs, чтобы on_unmount мог его убить
|
||||
# даже если после него будет зарегистрирован другой процесс.
|
||||
self._active_proc = proc
|
||||
self._procs.append(proc)
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
# Return True if the user has closed the screen before the task finished.
|
||||
# Возвращает True если пользователь закрыл экран до завершения задачи.
|
||||
return self._stopped
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
# Kill every registered subprocess so nothing keeps running in the background after exit.
|
||||
# Use SIGKILL on the process group to also terminate any children spawned by the process
|
||||
# (e.g. dpkg or apt subprocesses spawned under sudo). Falls back to proc.kill() if the
|
||||
# process group is not available (e.g. already exited).
|
||||
# Убиваем все зарегистрированные подпроцессы, чтобы ничего не висело в фоне после выхода.
|
||||
# Используем SIGKILL по группе процессов, чтобы завершить и дочерние процессы
|
||||
# (например dpkg или apt запущенные под sudo). Откат на proc.kill() если группа недоступна.
|
||||
self._stopped = True
|
||||
for proc in list(self._procs):
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._procs.clear()
|
||||
|
||||
def set_progress(self, pct: float, label: str = "") -> None:
|
||||
# Thread-safe - this is called from the worker thread, not the UI thread.
|
||||
# Потокобезопасно - вызывается из рабочего потока, а не из потока интерфейса.
|
||||
if self._show_progress:
|
||||
self.app.call_from_thread(self._do_set_progress, pct, label)
|
||||
|
||||
def _do_set_progress(self, pct: float, label: str) -> None:
|
||||
self.query_one("#progress", ProgressBar).progress = pct
|
||||
if label:
|
||||
self.query_one("#step-label", Static).update(label)
|
||||
|
||||
def write(self, line: str) -> None:
|
||||
# Thread-safe - append a line to the log from a worker thread.
|
||||
# Потокобезопасно - добавляет строку в лог из рабочего потока.
|
||||
self.app.call_from_thread(self._append, line)
|
||||
|
||||
def _append(self, line: str) -> None:
|
||||
self.query_one(RichLog).write(line)
|
||||
|
||||
def finish(self, success: bool) -> None:
|
||||
# Thread-safe - called by the task when it is done to show the close hint.
|
||||
# Потокобезопасно - вызывается задачей по завершении, чтобы показать подсказку о закрытии.
|
||||
self.app.call_from_thread(self._do_finish, success)
|
||||
|
||||
def _do_finish(self, success: bool) -> None:
|
||||
self._finished = True
|
||||
self._success = success
|
||||
self.query_one("#loading", LoadingIndicator).display = False
|
||||
msg = (
|
||||
"[green]Done![/green] Press [bold]Enter[/bold] to close."
|
||||
if success
|
||||
else "[red]Failed.[/red] Press [bold]Enter[/bold] to close."
|
||||
)
|
||||
self.query_one("#hint", Static).update(msg)
|
||||
|
||||
def action_close(self) -> None:
|
||||
# Only allow closing after the task has finished, not while it is still running.
|
||||
# Разрешаем закрытие только после завершения задачи, а не во время её работы.
|
||||
if self._finished:
|
||||
self.dismiss(self._success)
|
||||
|
||||
|
||||
# A screen that shows a list of checkboxes for multi-selection.
|
||||
# The user toggles items with Space, confirms with Enter, cancels with Escape.
|
||||
# Экран с чекбоксами для множественного выбора.
|
||||
# Пользователь переключает пункты пробелом, подтверждает Enter, отменяет Escape.
|
||||
class MultiPickScreen(Screen[Optional[List[str]]]):
|
||||
"""Multi-choice screen using SelectionList. Navigate with arrows, toggle with Space,
|
||||
confirm with Enter, cancel with Escape. Returns selected option strings or None.
|
||||
Экран множественного выбора через SelectionList. Стрелки — навигация, пробел — выбор,
|
||||
Enter — подтверждение, Escape — отмена. Возвращает выбранные строки или None.
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "submit", "Confirm", priority=True),
|
||||
Binding("escape", "abort", "Cancel"),
|
||||
]
|
||||
|
||||
def __init__(self, step: str, question: str, options: List[str],
|
||||
defaults: Optional[List[str]] = None, note: str = ""):
|
||||
super().__init__()
|
||||
self._step = step
|
||||
self._question = question
|
||||
self._options = options
|
||||
# All options are selected by default when defaults is None.
|
||||
# Все пункты выбраны по умолчанию если defaults не передан.
|
||||
self._defaults = set(defaults) if defaults is not None else set(options)
|
||||
self._note = note
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._step, id="step")
|
||||
yield Static(self._question, id="question")
|
||||
if self._note:
|
||||
yield Static(self._note, id="note")
|
||||
yield SelectionList(
|
||||
*[Selection(opt, opt, opt in self._defaults) for opt in self._options]
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(SelectionList).focus()
|
||||
|
||||
def action_submit(self) -> None:
|
||||
self.dismiss(list(self.query_one(SelectionList).selected))
|
||||
|
||||
def action_abort(self) -> None:
|
||||
self.app.exit(None)
|
||||
|
||||
|
||||
# A screen for a long-running process that the user can stop at any time.
|
||||
# Shows a live log and offers S / Enter / Escape to stop or close.
|
||||
# Экран для долго работающего процесса, который пользователь может остановить в любой момент.
|
||||
# Показывает живой лог и предлагает S / Enter / Escape для остановки или закрытия.
|
||||
class RunScreen(Screen[None]):
|
||||
"""Run screen for a persistent process (e.g. ROS2 launch). Shows a live log and allows
|
||||
the user to stop the process with S or close after it exits with Enter/Escape.
|
||||
Экран запуска для постоянно работающего процесса (например ros2 launch). Показывает живой
|
||||
лог и позволяет остановить процесс клавишей S или закрыть после завершения через Enter/Escape.
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("s", "stop_close", "Stop", show=True, priority=True),
|
||||
Binding("enter", "stop_close", "Close", show=False),
|
||||
Binding("escape", "stop_close", "Close", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, title: str, task: Callable[[RunScreen], None]):
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._run_fn = task
|
||||
self._proc = None # the subprocess, set via set_proc()
|
||||
self._kill_fn = None # optional custom kill callable, set via set_kill_fn()
|
||||
self._finished = False
|
||||
self._stopped = False
|
||||
self._procs: list = [] # all registered procs for cleanup on forced exit
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._title, id="step")
|
||||
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
|
||||
yield LoadingIndicator(id="loading")
|
||||
yield Static(" Press [bold]S[/bold] to stop the process", id="hint")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(RichLog).focus()
|
||||
# Run the process task in a worker thread so the UI stays responsive.
|
||||
# Запускаем задачу с процессом в отдельном потоке, чтобы интерфейс не зависал.
|
||||
self.app.run_worker(lambda: self._run_fn(self), thread=True)
|
||||
|
||||
def set_proc(self, proc) -> None:
|
||||
# Register the subprocess so the Stop button knows what to terminate.
|
||||
# Регистрируем subprocess, чтобы кнопка Stop знала что завершать.
|
||||
self._proc = proc
|
||||
self._procs.append(proc)
|
||||
|
||||
def set_kill_fn(self, fn: Callable) -> None:
|
||||
# Override the default proc.terminate() with a custom kill function.
|
||||
# For example, docker kill or os.killpg for process groups.
|
||||
# Заменяем стандартный proc.terminate() кастомной функцией завершения.
|
||||
# Например, docker kill или os.killpg для групп процессов.
|
||||
self._kill_fn = fn
|
||||
|
||||
def write(self, line: str) -> None:
|
||||
# Thread-safe - called from the worker thread to append a log line.
|
||||
# Потокобезопасно - вызывается из рабочего потока для добавления строки в лог.
|
||||
self.app.call_from_thread(self._append, line)
|
||||
|
||||
def _append(self, line: str) -> None:
|
||||
self.query_one(RichLog).write(line)
|
||||
|
||||
def finish(self, stopped: bool = False) -> None:
|
||||
# Thread-safe - called by the task when the process exits naturally.
|
||||
# Потокобезопасно - вызывается задачей когда процесс завершается естественным образом.
|
||||
self.app.call_from_thread(self._do_finish, stopped)
|
||||
|
||||
def _do_finish(self, stopped: bool) -> None:
|
||||
self._finished = True
|
||||
self.query_one("#loading", LoadingIndicator).display = False
|
||||
if stopped:
|
||||
msg = "[yellow]Process stopped.[/yellow] Press [bold]Enter[/bold] to close."
|
||||
else:
|
||||
msg = "[green]Process exited.[/green] Press [bold]Enter[/bold] to close."
|
||||
self.query_one("#hint", Static).update(msg)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
# Kill all registered subprocesses when the screen is forcibly closed (e.g. Ctrl+Q).
|
||||
# Убиваем все зарегистрированные подпроцессы при принудительном закрытии экрана (Ctrl+Q).
|
||||
self._stopped = True
|
||||
if self._kill_fn is not None:
|
||||
try:
|
||||
self._kill_fn()
|
||||
except Exception:
|
||||
pass
|
||||
for proc in list(self._procs):
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._procs.clear()
|
||||
|
||||
def action_stop_close(self) -> None:
|
||||
# This runs in the UI thread, so we call _append() directly instead of write()
|
||||
# because write() uses call_from_thread() which only works from other threads.
|
||||
# Выполняется в потоке UI, поэтому вызываем _append() напрямую, а не write(),
|
||||
# потому что write() использует call_from_thread(), который работает только из других потоков.
|
||||
if self._finished:
|
||||
self.dismiss(None)
|
||||
return
|
||||
self._stopped = True
|
||||
if self._kill_fn is not None:
|
||||
try:
|
||||
self._kill_fn()
|
||||
except Exception:
|
||||
pass
|
||||
elif self._proc is not None and self._proc.poll() is None:
|
||||
try:
|
||||
self._proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
self._append("\n[yellow]Stopping process...[/yellow]")
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configure the Docker image and the MkDocs directory
|
||||
DOCKER_IMAGE="squidfunk/mkdocs-material"
|
||||
MKDOCS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/lwc-doc" && pwd)"
|
||||
|
||||
# Define color codes for logging
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
|
||||
# Check for Docker and the MkDocs directory
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker не найден. Установи Docker и повтори."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$MKDOCS_DIR" ]; then
|
||||
log_error "Папка mkdocs не найдена: $MKDOCS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$MKDOCS_DIR/mkdocs.yml" ]; then
|
||||
log_error "Файл mkdocs.yml не найден в $MKDOCS_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine the mode (build or serve)
|
||||
MODE="${1:-build}"
|
||||
|
||||
case "$MODE" in
|
||||
build)
|
||||
log_info "Сборка документации (build)..."
|
||||
docker run --rm \
|
||||
-v "$MKDOCS_DIR:/docs" \
|
||||
"$DOCKER_IMAGE" build
|
||||
if [ $? -eq 0 ]; then
|
||||
log_info "Готово! Результат: $MKDOCS_DIR/site/"
|
||||
else
|
||||
log_error "Сборка завершилась с ошибкой."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
serve)
|
||||
log_info "Запуск dev-сервера на http://localhost:8000 ..."
|
||||
log_warn "Остановка: Ctrl+C"
|
||||
docker run --rm -it \
|
||||
-p 8000:8000 \
|
||||
-v "$MKDOCS_DIR:/docs" \
|
||||
"$DOCKER_IMAGE" serve \
|
||||
--dev-addr=0.0.0.0:8000 \
|
||||
--watch /docs \
|
||||
--livereload
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Использование: $0 [build|serve]"
|
||||
echo " build — собрать статику в mkdocs/site/ (по умолчанию)"
|
||||
echo " serve — запустить live-preview на localhost:8000"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM squidfunk/mkdocs-material
|
||||
RUN apk add --no-cache \
|
||||
cairo \
|
||||
pango \
|
||||
gdk-pixbuf \
|
||||
libffi \
|
||||
fontconfig \
|
||||
ttf-dejavu \
|
||||
gcc \
|
||||
g++ \
|
||||
musl-dev \
|
||||
python3-dev \
|
||||
&& pip install mkdocs-with-pdf \
|
||||
&& apk del --no-cache gcc g++ musl-dev python3-dev
|
||||
@@ -0,0 +1,3 @@
|
||||
# Потоки данных
|
||||
|
||||
*Как данные идут от FRI до MoveIt: топики, сервисы, action-серверы.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_bringup
|
||||
|
||||
*Launch-файлы, точка входа системы.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_config
|
||||
|
||||
*URDF, SRDF, параметры контроллеров.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_controller
|
||||
|
||||
*Аппаратный интерфейс FRI: SystemInterface, контроллеры.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_description
|
||||
|
||||
*Визуальная и кинематическая модель робота.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_msgs
|
||||
|
||||
*Кастомные сообщения и сервисы.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_planning
|
||||
|
||||
*MoveIt, планирование траекторий, конфигурация планировщиков.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_sunrise
|
||||
|
||||
*Мост с Java-кодом Sunrise Workbench.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# iiwa_utils
|
||||
|
||||
*Вспомогательные утилиты.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# RealSense
|
||||
|
||||
*Пакеты камеры: драйвер, сообщения, MQTT-мост.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Обзор системы
|
||||
|
||||
*Диаграмма: робот ↔ FRI ↔ ROS2 ↔ интерфейсы управления.*
|
||||
@@ -0,0 +1,28 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const btn = document.createElement("a");
|
||||
btn.href = "/pdf/document.pdf";
|
||||
btn.download = "lwc-documentation.pdf";
|
||||
btn.title = "Скачать всю документацию в PDF";
|
||||
btn.style.cssText = [
|
||||
"position: fixed",
|
||||
"bottom: 24px",
|
||||
"right: 24px",
|
||||
"z-index: 9999",
|
||||
"background: var(--md-primary-fg-color, #1976d2)",
|
||||
"color: #fff",
|
||||
"padding: 12px 18px",
|
||||
"border-radius: 24px",
|
||||
"text-decoration: none",
|
||||
"font-weight: 600",
|
||||
"font-size: 14px",
|
||||
"box-shadow: 0 3px 10px rgba(0,0,0,0.25)",
|
||||
"display: flex",
|
||||
"align-items: center",
|
||||
"gap: 8px",
|
||||
"transition: opacity .2s",
|
||||
].join(";");
|
||||
btn.innerHTML = "📄 Скачать PDF";
|
||||
btn.onmouseenter = () => (btn.style.opacity = "0.85");
|
||||
btn.onmouseleave = () => (btn.style.opacity = "1");
|
||||
document.body.appendChild(btn);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# Foxglove Studio
|
||||
|
||||
*Подключение, полезные панели, отправка команд.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Симуляция
|
||||
|
||||
*Запуск в Webots и RViz.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Терминал (ROS2 CLI)
|
||||
|
||||
*`ros2 topic`, `ros2 action`, `ros2 service` — шпаргалка.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Web-интерфейс
|
||||
|
||||
*В разработке.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Сборка и пересборка
|
||||
|
||||
*`cobot rebuild`, `cobot clean`, `cobot update`.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Справочник CLI
|
||||
|
||||
*Полный список команд `cobot` с параметрами.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Решение проблем
|
||||
|
||||
*Частые ошибки и способы их исправления.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Декартова цель (pose)
|
||||
|
||||
*Пример задания цели в пространстве через MoveIt.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Движение в позицию по осям
|
||||
|
||||
*Пример отправки joint position goal.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# MoveIt Python API
|
||||
|
||||
*Управление роботом из Python-скрипта.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Чтение данных RealSense
|
||||
|
||||
*Получение изображений и облака точек.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Выполнение траектории
|
||||
|
||||
*Пример составной траектории.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Настройка
|
||||
|
||||
*`cobot robot-setup` и описание полей `cobot-setting.yaml`.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Ручная установка
|
||||
|
||||
*Нативная установка ROS2, Docker, Webots.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Обзор
|
||||
|
||||
*Описание проекта, что это такое и зачем.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Быстрый старт
|
||||
|
||||
*Установка одной командой и первый запуск.*
|
||||
@@ -0,0 +1,17 @@
|
||||
# Welcome to MkDocs выq
|
||||
|
||||
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
|
||||
|
||||
## Commands
|
||||
|
||||
* `mkdocs new [dir-name]` - Create a new project.
|
||||
* `mkdocs serve` - Start the live-reloading docs server.
|
||||
* `mkdocs build` - Build the documentation site.
|
||||
* `mkdocs -h` - Print help message and exit.
|
||||
|
||||
## Project layout
|
||||
|
||||
mkdocs.yml # The configuration file.
|
||||
docs/
|
||||
index.md # The documentation homepage.
|
||||
... # Other markdown pages, images and other files.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Загрузка на контроллер
|
||||
|
||||
*Сборка и деплой приложения на робота.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Настройка сети
|
||||
|
||||
*FRI-соединение, IP-адреса и порты.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Обзор Sunrise Workbench
|
||||
|
||||
*Что такое FRI и зачем нужен Sunrise Workbench.*
|
||||
@@ -0,0 +1,3 @@
|
||||
# Настройка проекта
|
||||
|
||||
*Импорт проекта, зависимости и библиотеки.*
|
||||
@@ -0,0 +1,78 @@
|
||||
site_name: Документация LWC
|
||||
site_description: Документация по использованию коллаборативного робота KUKA LBR iiwa 7 и его программированию в среде ROS 2.
|
||||
site_author: Даниил Грабарь
|
||||
|
||||
theme:
|
||||
name: material
|
||||
custom_dir: overrides
|
||||
language: ru
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.expand
|
||||
- navigation.top
|
||||
- search.highlight
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- with-pdf:
|
||||
author: Даниил Грабарь
|
||||
cover_title: Документация LWC
|
||||
cover_subtitle: Lightweight Cobot
|
||||
output_path: pdf/document.pdf
|
||||
|
||||
extra_javascript:
|
||||
- assets/pdf_button.js
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
|
||||
nav:
|
||||
- Главная: index.md
|
||||
|
||||
- Быстрый старт:
|
||||
- Обзор: getting-started/overview.md
|
||||
- Быстрый старт: getting-started/quick-start.md
|
||||
- Ручная установка: getting-started/manual-install.md
|
||||
- Настройка: getting-started/configuration.md
|
||||
|
||||
- Sunrise Workbench:
|
||||
- Обзор: sunrise/overview.md
|
||||
- Настройка проекта: sunrise/project-setup.md
|
||||
- Настройка сети: sunrise/network.md
|
||||
- Загрузка на контроллер: sunrise/deployment.md
|
||||
|
||||
- Архитектура:
|
||||
- Обзор системы: architecture/system-overview.md
|
||||
- Пакеты:
|
||||
- iiwa_controller: architecture/packages/iiwa-controller.md
|
||||
- iiwa_bringup: architecture/packages/iiwa-bringup.md
|
||||
- iiwa_planning: architecture/packages/iiwa-planning.md
|
||||
- iiwa_msgs: architecture/packages/iiwa-msgs.md
|
||||
- iiwa_config: architecture/packages/iiwa-config.md
|
||||
- iiwa_description: architecture/packages/iiwa-description.md
|
||||
- iiwa_sunrise: architecture/packages/iiwa-sunrise.md
|
||||
- iiwa_utils: architecture/packages/iiwa-utils.md
|
||||
- RealSense: architecture/packages/realsense.md
|
||||
- Потоки данных: architecture/data-flow.md
|
||||
|
||||
- Управление:
|
||||
- Foxglove Studio: control/foxglove.md
|
||||
- Терминал: control/terminal.md
|
||||
- Web-интерфейс: control/web-interface.md
|
||||
- Симуляция: control/simulation.md
|
||||
|
||||
- Примеры:
|
||||
- Joint Position: examples/joint-position.md
|
||||
- Cartesian Goal: examples/cartesian-goal.md
|
||||
- Траектория: examples/trajectory.md
|
||||
- MoveIt Python API: examples/moveit-python.md
|
||||
- RealSense данные: examples/realsense-data.md
|
||||
|
||||
- Разработка:
|
||||
- Сборка и пересборка: development/build-rebuild.md
|
||||
- CLI Reference: development/cli-reference.md
|
||||
- Решение проблем: development/troubleshooting.md
|
||||
@@ -0,0 +1,9 @@
|
||||
<footer class="md-footer">
|
||||
<div class="md-footer-meta md-typeset">
|
||||
<div class="md-footer-meta__inner md-grid">
|
||||
<div class="md-copyright">
|
||||
© 2026 — Lightweight Cobot
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
ARG IMAGE=evilfisru/ros:core-jazzy
|
||||
FROM ${IMAGE}
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
git \
|
||||
python3-colcon-common-extensions \
|
||||
python3-colcon-mixin \
|
||||
python3-rosdep \
|
||||
python3-vcstool \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rosdep init && \
|
||||
rosdep update --rosdistro $ROS_DISTRO || true
|
||||
|
||||
RUN colcon mixin add default \
|
||||
https://raw.githubusercontent.com/colcon/colcon-mixin-repository/master/index.yaml && \
|
||||
colcon mixin update && \
|
||||
colcon metadata add default \
|
||||
https://raw.githubusercontent.com/colcon/colcon-metadata-repository/master/index.yaml && \
|
||||
colcon metadata update
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ros-jazzy-ros-base \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM ubuntu:noble
|
||||
|
||||
ARG TIMEZONE=Etc/UTC
|
||||
|
||||
# Timezone
|
||||
RUN echo "${TIMEZONE}" > /etc/timezone && \
|
||||
ln -s /usr/share/zoneinfo/${TIMEZONE} /etc/localtime && \
|
||||
apt-get update && \
|
||||
apt-get install -q -y --no-install-recommends tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Базовые утилиты
|
||||
RUN apt-get update && apt-get install -q -y --no-install-recommends \
|
||||
curl \
|
||||
dirmngr \
|
||||
gnupg2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# Ключ и стабильный репозиторий ROS2
|
||||
RUN set -eux; \
|
||||
key='C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654'; \
|
||||
export GNUPGHOME="$(mktemp -d)"; \
|
||||
gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key"; \
|
||||
mkdir -p /usr/share/keyrings; \
|
||||
gpg --batch --export "$key" > /usr/share/keyrings/ros2-archive-keyring.gpg; \
|
||||
gpgconf --kill all; \
|
||||
rm -rf "$GNUPGHOME"
|
||||
|
||||
RUN echo "deb [ signed-by=/usr/share/keyrings/ros2-archive-keyring.gpg ] \
|
||||
http://packages.ros.org/ros2/ubuntu noble main" \
|
||||
> /etc/apt/sources.list.d/ros2.list
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
ENV ROS_DISTRO=jazzy
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ros-jazzy-ros-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY ./ros_entrypoint.sh /
|
||||
RUN chmod +x /ros_entrypoint.sh
|
||||
ENTRYPOINT ["/ros_entrypoint.sh"]
|
||||
CMD ["bash"]
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
source "/opt/ros/$ROS_DISTRO/setup.bash" --
|
||||
exec "$@"
|
||||
@@ -0,0 +1,62 @@
|
||||
ARG IMAGE=evilfisru/ros:base-jazzy
|
||||
FROM ${IMAGE}
|
||||
|
||||
ARG BUILD_TYPE=release
|
||||
|
||||
ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
|
||||
ENV WEBOTS_HOME=/usr/local/webots
|
||||
|
||||
# X11 utilities and Mesa software renderer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
libx11-6 \
|
||||
x11-utils \
|
||||
libglu1-mesa \
|
||||
libgl1-mesa-dri \
|
||||
mesa-utils \
|
||||
libegl-mesa0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Webots R2025a (compatible with ros-jazzy-webots-ros2)
|
||||
RUN wget -q https://github.com/cyberbotics/webots/releases/download/R2025a/webots_2025a_amd64.deb \
|
||||
-O /tmp/webots.deb && \
|
||||
apt-get update && \
|
||||
apt-get install -y /tmp/webots.deb && \
|
||||
rm /tmp/webots.deb && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ros-${ROS_DISTRO}-webots-ros2 \
|
||||
ros-${ROS_DISTRO}-ros2-control \
|
||||
ros-${ROS_DISTRO}-ros2-controllers \
|
||||
ros-${ROS_DISTRO}-rmw-cyclonedds-cpp \
|
||||
ros-${ROS_DISTRO}-moveit \
|
||||
ros-${ROS_DISTRO}-moveit-py \
|
||||
ros-${ROS_DISTRO}-ament-cmake-clang-format \
|
||||
ros-${ROS_DISTRO}-rosbag2-storage-mcap \
|
||||
python3-pil \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /ros2_ws
|
||||
|
||||
COPY src/iiwa_bringup src/iiwa_bringup
|
||||
COPY src/iiwa_config src/iiwa_config
|
||||
COPY src/iiwa_controller src/iiwa_controller
|
||||
COPY src/iiwa_description src/iiwa_description
|
||||
COPY src/iiwa_msgs src/iiwa_msgs
|
||||
COPY src/iiwa_planning src/iiwa_planning
|
||||
COPY src/iiwa_utils src/iiwa_utils
|
||||
|
||||
RUN apt-get update && \
|
||||
rosdep install --from-paths src -i -r -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN . /opt/ros/${ROS_DISTRO}/setup.sh && \
|
||||
colcon build --mixin release
|
||||
|
||||
RUN if [ "${BUILD_TYPE}" = "release" ]; then rm -rf src/ build/ log/; fi
|
||||
|
||||
RUN echo ". /ros2_ws/install/setup.bash" >> /root/.bashrc
|
||||
|
||||
CMD ["bash"]
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# Run the Webots container with X11 forwarding.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh [--gpu nvidia|mesa|software] [CMD...]
|
||||
#
|
||||
# Examples:
|
||||
# ./run.sh — software rendering (llvmpipe), по умолчанию
|
||||
# ./run.sh --gpu mesa — Intel/AMD GPU через DRI (ноутбук без NVIDIA)
|
||||
# ./run.sh --gpu nvidia — NVIDIA GPU rendering
|
||||
# ./run.sh --gpu nvidia ros2 launch ... — NVIDIA GPU rendering + команда
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE="${WEBOTS_IMAGE:-evilfisru/lwc:webots-jazzy-dev}"
|
||||
GPU_MODE="software"
|
||||
|
||||
# Resolve the project root (two levels above this script: docker/jazzy/ros-iiwa7-webots → project root)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
|
||||
CONFIG_FILE="${PROJECT_ROOT}/cobot-setting.yaml"
|
||||
|
||||
# Parse --gpu flag
|
||||
if [[ "${1}" == "--gpu" ]]; then
|
||||
GPU_MODE="${2:?--gpu requires an argument: nvidia|mesa|software}"
|
||||
shift 2
|
||||
fi
|
||||
|
||||
# Allow local Docker processes to connect to the X server
|
||||
xhost +local:docker > /dev/null
|
||||
|
||||
case "${GPU_MODE}" in
|
||||
nvidia)
|
||||
RENDER_FLAGS=(
|
||||
--gpus all
|
||||
-e NVIDIA_VISIBLE_DEVICES=all
|
||||
-e NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute
|
||||
)
|
||||
;;
|
||||
mesa)
|
||||
# Intel/AMD integrated/discrete — Mesa через DRI, без NVIDIA toolkit
|
||||
RENDER_FLAGS=(
|
||||
--device /dev/dri
|
||||
)
|
||||
;;
|
||||
software)
|
||||
RENDER_FLAGS=(
|
||||
-e LIBGL_ALWAYS_SOFTWARE=1
|
||||
-e GALLIUM_DRIVER=llvmpipe
|
||||
)
|
||||
;;
|
||||
*)
|
||||
echo "Unknown --gpu value: '${GPU_MODE}'. Use 'nvidia', 'mesa' or 'software'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
docker run -it --rm \
|
||||
--name ros-webots \
|
||||
--network host \
|
||||
-e USER=root \
|
||||
-e DISPLAY="${DISPLAY}" \
|
||||
-e QT_X11_NO_MITSHM=1 \
|
||||
"${RENDER_FLAGS[@]}" \
|
||||
-v /tmp/.X11-unix:/tmp/.X11-unix:rw \
|
||||
${CONFIG_FILE:+-v "${CONFIG_FILE}:/ros2_ws/cobot-setting.yaml:ro"} \
|
||||
"${IMAGE}" \
|
||||
"$@"
|
||||
@@ -0,0 +1,39 @@
|
||||
ARG IMAGE=evilfisru/ros:base-jazzy
|
||||
FROM ${IMAGE}
|
||||
|
||||
ARG BUILD_TYPE=release
|
||||
|
||||
ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ros-${ROS_DISTRO}-ros2-control \
|
||||
ros-${ROS_DISTRO}-ros2-controllers \
|
||||
ros-${ROS_DISTRO}-rmw-cyclonedds-cpp \
|
||||
ros-${ROS_DISTRO}-moveit \
|
||||
ros-${ROS_DISTRO}-moveit-py \
|
||||
ros-${ROS_DISTRO}-ament-cmake-clang-format \
|
||||
ros-${ROS_DISTRO}-rosbag2-storage-mcap \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /ros2_ws
|
||||
|
||||
COPY src/iiwa_bringup src/iiwa_bringup
|
||||
COPY src/iiwa_config src/iiwa_config
|
||||
COPY src/iiwa_controller src/iiwa_controller
|
||||
COPY src/iiwa_description src/iiwa_description
|
||||
COPY src/iiwa_msgs src/iiwa_msgs
|
||||
COPY src/iiwa_planning src/iiwa_planning
|
||||
COPY src/iiwa_utils src/iiwa_utils
|
||||
|
||||
RUN apt-get update && \
|
||||
rosdep install --from-paths src -i -r -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN . /opt/ros/$ROS_DISTRO/setup.sh && \
|
||||
colcon build --mixin release
|
||||
|
||||
RUN if [ "$BUILD_TYPE" = "release" ]; then rm -rf src/ build/ log/; fi
|
||||
|
||||
RUN echo ". /ros2_ws/install/setup.bash" >> /root/.bashrc
|
||||
|
||||
CMD ["bash"]
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Stop the script immediately if any command exits with an error.
|
||||
# Останавливаем скрипт сразу, если какая-либо команда завершилась с ошибкой.
|
||||
set -e
|
||||
|
||||
# Disable uv user/project config files so the environment is always clean.
|
||||
# Отключаем пользовательские и проектные конфиги uv, чтобы среда всегда была чистой.
|
||||
export UV_NO_CONFIG=1
|
||||
|
||||
# Terminal color codes for nicer output.
|
||||
# Коды цветов для красивого вывода в терминал.
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
BOLD='\033[1m'
|
||||
|
||||
# Python version that the cobot CLI requires.
|
||||
# Версия Python, которая нужна для работы cobot CLI.
|
||||
PYTHON_VERSION="3.11"
|
||||
REPO_URL="https://gitverse.ru/daniel-robotics/lightweight-cobot.git"
|
||||
|
||||
# Where to clone the project. Can be overridden by the user with COBOT_INSTALL_DIR.
|
||||
# Куда клонировать проект. Пользователь может переопределить через COBOT_INSTALL_DIR.
|
||||
INSTALL_DIR="${COBOT_INSTALL_DIR:-$HOME/.lwc}"
|
||||
|
||||
# Detect interactive mode - when run via curl | bash, stdin is not a terminal.
|
||||
# Определяем интерактивный режим - при запуске через curl | bash stdin не является терминалом.
|
||||
if [ -t 0 ]; then IS_INTERACTIVE=true; else IS_INTERACTIVE=false; fi
|
||||
|
||||
# Logging helpers - one line per severity level.
|
||||
# Вспомогательные функции логирования - одна строка на уровень важности.
|
||||
log_info() { echo -e "${CYAN}[*]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[ok]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[err]${NC} $1"; exit 1; }
|
||||
|
||||
# Run a command silently and only print its output if it fails.
|
||||
# This makes the normal install look clean while still showing errors when something breaks.
|
||||
# Запускает команду тихо и показывает вывод только если она завершилась с ошибкой.
|
||||
# Это делает обычную установку аккуратной, но при ошибке мы всё равно видим детали.
|
||||
run_quiet() {
|
||||
local _log
|
||||
_log="$(mktemp /tmp/lwc-cmd.XXXXXX.log)"
|
||||
if "$@" </dev/null >"$_log" 2>&1; then
|
||||
rm -f "$_log"
|
||||
else
|
||||
cat "$_log" >&2
|
||||
rm -f "$_log"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
echo ""
|
||||
echo -e "${BOLD}"
|
||||
echo "┌─────────────────────────────────────────────────────────┐"
|
||||
echo "│ Lightweight Cobot installer │"
|
||||
echo "└─────────────────────────────────────────────────────────┘"
|
||||
echo -e "${NC}"
|
||||
}
|
||||
|
||||
# Detect the current OS and package manager so later steps know how to install things.
|
||||
# Определяем текущую ОС и пакетный менеджер, чтобы следующие шаги знали как устанавливать пакеты.
|
||||
detect_os() {
|
||||
case "$(uname -s)" in
|
||||
Linux*)
|
||||
OS="linux"
|
||||
# Pick the first package manager we can find on this system.
|
||||
# Выбираем первый найденный пакетный менеджер.
|
||||
if command -v apt-get &>/dev/null; then PKG_MANAGER="apt"
|
||||
elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf"
|
||||
elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman"
|
||||
else PKG_MANAGER="unknown"
|
||||
fi
|
||||
;;
|
||||
Darwin*) OS="macos" ;;
|
||||
*) log_error "Unsupported OS: $(uname -s)" ;;
|
||||
esac
|
||||
log_info "OS: $OS"
|
||||
}
|
||||
|
||||
# Install system packages using whatever package manager was detected above.
|
||||
# Устанавливает системные пакеты через найденный пакетный менеджер.
|
||||
pkg_install() {
|
||||
case "$PKG_MANAGER" in
|
||||
apt) run_quiet sudo apt-get update -qq && run_quiet sudo apt-get install -y --no-install-recommends "$@" ;;
|
||||
dnf) run_quiet sudo dnf install -y "$@" ;;
|
||||
pacman) run_quiet sudo pacman -S --noconfirm "$@" ;;
|
||||
*) log_error "Cannot auto-install $* — unknown package manager" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check if git is installed and install it if not.
|
||||
# Проверяем наличие git и устанавливаем его если он отсутствует.
|
||||
check_git() {
|
||||
log_info "Checking git..."
|
||||
if command -v git &>/dev/null; then
|
||||
log_success "git $(git --version | awk '{print $3}') found"
|
||||
return
|
||||
fi
|
||||
log_info "Installing git..."
|
||||
case "$OS" in
|
||||
linux) pkg_install git ;;
|
||||
macos) run_quiet xcode-select --install 2>/dev/null || run_quiet brew install git ;;
|
||||
esac
|
||||
command -v git &>/dev/null || log_error "Failed to install git"
|
||||
log_success "git $(git --version | awk '{print $3}') installed"
|
||||
}
|
||||
|
||||
# Check if Docker is installed and install it if not.
|
||||
# Проверяем наличие Docker и устанавливаем его если он отсутствует.
|
||||
check_docker() {
|
||||
log_info "Checking Docker..."
|
||||
if command -v docker &>/dev/null; then
|
||||
log_success "Docker $(docker --version | awk '{print $3}' | tr -d ',') found"
|
||||
return
|
||||
fi
|
||||
log_info "Installing Docker..."
|
||||
# Download the installer to a temp file instead of piping directly through bash.
|
||||
# This way network errors and installer errors are shown separately.
|
||||
# Скачиваем установщик во временный файл, а не запускаем через pipe.
|
||||
# Так ошибки сети и ошибки самого установщика видны по отдельности.
|
||||
local _installer
|
||||
_installer="$(mktemp /tmp/lwc-docker.XXXXXX.sh)"
|
||||
if ! curl -fsSL https://get.docker.com -o "$_installer"; then
|
||||
rm -f "$_installer"
|
||||
log_error "Failed to download Docker installer"
|
||||
fi
|
||||
run_quiet sh "$_installer" || { rm -f "$_installer"; log_error "Docker installation failed"; }
|
||||
rm -f "$_installer"
|
||||
command -v docker &>/dev/null || log_error "Docker not found after installation"
|
||||
log_success "Docker $(docker --version | awk '{print $3}' | tr -d ',') installed"
|
||||
# Add the current user to the docker group so sudo is not needed every time.
|
||||
# Добавляем текущего пользователя в группу docker, чтобы не требовался sudo каждый раз.
|
||||
if [ "$(id -u)" -ne 0 ] && command -v usermod &>/dev/null; then
|
||||
sudo usermod -aG docker "$USER"
|
||||
log_warn "Added $USER to docker group — re-login to apply"
|
||||
fi
|
||||
}
|
||||
|
||||
# Install the uv package manager. We need it to create isolated Python environments.
|
||||
# Устанавливаем пакетный менеджер uv. Он нужен для создания изолированных Python-окружений.
|
||||
install_uv() {
|
||||
log_info "Checking uv..."
|
||||
# uv can end up in ~/.local/bin or ~/.cargo/bin depending on how it was installed.
|
||||
# uv может оказаться в ~/.local/bin или ~/.cargo/bin в зависимости от способа установки.
|
||||
UV_CMD=""
|
||||
for candidate in "uv" "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||
if command -v "$candidate" &>/dev/null 2>&1; then
|
||||
UV_CMD="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "$UV_CMD" ]; then
|
||||
log_success "uv $($UV_CMD --version | awk '{print $2}') found"
|
||||
return
|
||||
fi
|
||||
log_info "Installing uv..."
|
||||
# Two separate temp files - one for the installer script and one for its log.
|
||||
# This lets us tell apart "download failed" from "installer failed".
|
||||
# Два отдельных временных файла - для установщика и для его лога.
|
||||
# Это позволяет различить ошибку скачивания и ошибку самого установщика.
|
||||
local _log _installer
|
||||
_log="$(mktemp /tmp/lwc-uv.XXXXXX.log)"
|
||||
_installer="$(mktemp /tmp/lwc-uv-installer.XXXXXX.sh)"
|
||||
if ! curl -LsSf https://astral.sh/uv/install.sh -o "$_installer" 2>"$_log"; then
|
||||
cat "$_log" >&2; rm -f "$_log" "$_installer"
|
||||
log_error "Failed to download uv installer"
|
||||
fi
|
||||
if sh "$_installer" >> "$_log" 2>&1; then
|
||||
rm -f "$_installer"
|
||||
for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||
[ -x "$candidate" ] && UV_CMD="$candidate" && break
|
||||
done
|
||||
if [ -z "$UV_CMD" ]; then
|
||||
cat "$_log" >&2; rm -f "$_log"
|
||||
log_error "uv installed but binary not found"
|
||||
fi
|
||||
rm -f "$_log"
|
||||
log_success "uv $($UV_CMD --version | awk '{print $2}') installed"
|
||||
else
|
||||
cat "$_log" >&2; rm -f "$_log" "$_installer"
|
||||
log_error "Failed to install uv"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check that the required Python version is available via uv and install it if not.
|
||||
# Проверяем наличие нужной версии Python через uv и устанавливаем её если она отсутствует.
|
||||
check_python() {
|
||||
log_info "Checking Python $PYTHON_VERSION..."
|
||||
local py_path
|
||||
py_path="$("$UV_CMD" python find "$PYTHON_VERSION" </dev/null 2>/dev/null)" || true
|
||||
if [ -n "$py_path" ]; then
|
||||
local ver
|
||||
ver="$("$py_path" --version 2>&1)" || true
|
||||
log_success "${ver:-Python $PYTHON_VERSION} found"
|
||||
return
|
||||
fi
|
||||
log_info "Installing Python $PYTHON_VERSION via uv..."
|
||||
run_quiet "$UV_CMD" python install "$PYTHON_VERSION" || log_error "Failed to install Python $PYTHON_VERSION"
|
||||
log_success "Python $PYTHON_VERSION installed"
|
||||
}
|
||||
|
||||
# Figure out where the project lives. Three cases are handled:
|
||||
# 1. We are already inside the cloned repo - use it directly.
|
||||
# 2. The repo was cloned before - just pull the latest changes.
|
||||
# 3. First time - clone the repo fresh.
|
||||
# Определяем где находится проект. Обрабатываем три случая:
|
||||
# 1. Мы уже внутри клонированного репозитория - используем его напрямую.
|
||||
# 2. Репозиторий уже был клонирован ранее - просто тянем последние изменения.
|
||||
# 3. Первый запуск - клонируем репозиторий заново.
|
||||
resolve_install_dir() {
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)"
|
||||
|
||||
# Running directly from inside the cloned repo (not via curl|bash)
|
||||
if [ -f "$script_dir/setup.py" ] && [ -d "$script_dir/.git" ]; then
|
||||
INSTALL_DIR="$script_dir"
|
||||
log_info "Using existing repo: $INSTALL_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
# Repo already cloned — pull instead of re-clone
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
log_info "Repo already exists at $INSTALL_DIR — pulling latest..."
|
||||
git -C "$INSTALL_DIR" pull --ff-only origin dev </dev/null \
|
||||
&& log_success "Repo updated" \
|
||||
|| log_warn "Could not pull latest — using existing version"
|
||||
return
|
||||
fi
|
||||
|
||||
# Directory exists but is not a git repo (broken/partial) — remove it
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
log_warn "Removing incomplete directory $INSTALL_DIR..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
log_info "Cloning repo into $INSTALL_DIR..."
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
# Retry up to 5 times because the git server can be unreliable on slow connections.
|
||||
# Повторяем до 5 раз, потому что git-сервер может быть нестабильным на медленных соединениях.
|
||||
local attempt=1
|
||||
while [ $attempt -le 5 ]; do
|
||||
# TODO: Изменить на --depth 1 --branch main после слияния dev в main.
|
||||
if git clone --depth 1 --branch dev "$REPO_URL" "$INSTALL_DIR" </dev/null; then
|
||||
log_success "Repo cloned to $INSTALL_DIR"
|
||||
return
|
||||
fi
|
||||
log_warn "Clone failed (attempt $attempt/5), retrying..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
log_error "Failed to clone repo after 5 attempts"
|
||||
}
|
||||
|
||||
# Install the cobot CLI as an editable uv tool so changes in the source are reflected immediately.
|
||||
# Устанавливаем cobot CLI как редактируемый uv-инструмент, чтобы изменения в исходниках применялись сразу.
|
||||
install_cobot() {
|
||||
log_info "Installing cobot CLI..."
|
||||
cd "$INSTALL_DIR"
|
||||
# uv tool install creates an isolated environment and puts the cobot binary into ~/.local/bin.
|
||||
# uv tool install создаёт изолированное окружение и кладёт бинарник cobot в ~/.local/bin.
|
||||
run_quiet "$UV_CMD" tool install --python "$PYTHON_VERSION" --editable . \
|
||||
|| log_error "Failed to install cobot"
|
||||
log_success "cobot installed"
|
||||
}
|
||||
|
||||
# Add ~/.local/bin to PATH in the user's shell config if it is not there yet.
|
||||
# Добавляем ~/.local/bin в PATH в конфиге оболочки пользователя, если его там ещё нет.
|
||||
setup_path() {
|
||||
local bin_dir="$HOME/.local/bin"
|
||||
local shell_rc
|
||||
case "$SHELL" in
|
||||
*/zsh) shell_rc="$HOME/.zshrc" ;;
|
||||
*/fish) shell_rc="$HOME/.config/fish/config.fish" ;;
|
||||
*) shell_rc="$HOME/.bashrc" ;;
|
||||
esac
|
||||
# Also export into the current session right now so cobot setup works below without a re-login.
|
||||
# Также экспортируем прямо сейчас, чтобы cobot setup заработал ниже без перезахода.
|
||||
if [[ ":$PATH:" != *":$bin_dir:"* ]]; then
|
||||
echo "" >> "$shell_rc"
|
||||
echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_rc"
|
||||
export PATH="$bin_dir:$PATH"
|
||||
fi
|
||||
command -v cobot &>/dev/null && log_success "cobot -> $(command -v cobot)"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD}Installation complete!${NC}"
|
||||
echo ""
|
||||
echo " Next steps:"
|
||||
echo ""
|
||||
echo -e " ${CYAN}cobot setup${NC} — install ROS2, build the project and configure the robot"
|
||||
echo -e " ${CYAN}cobot --help${NC} — show all available commands"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Launch the interactive setup wizard right after installation.
|
||||
# Запускаем интерактивный мастер настройки сразу после установки.
|
||||
run_setup() {
|
||||
# Explicit check because PATH might not include ~/.local/bin yet in this shell session.
|
||||
# Явная проверка, потому что PATH может ещё не включать ~/.local/bin в этой сессии.
|
||||
if ! command -v cobot &>/dev/null; then
|
||||
log_warn "cobot not found on PATH, trying full path..."
|
||||
local cobot_bin="$HOME/.local/bin/cobot"
|
||||
if [ -x "$cobot_bin" ]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
log_error "cobot binary not found — try: source ~/.bashrc && cobot setup"
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Running cobot setup..."
|
||||
|
||||
# When run via curl | bash the child process cannot update the parent terminal's environment.
|
||||
# При запуске через curl | bash дочерний процесс не может обновить окружение родительского терминала.
|
||||
if [ "$IS_INTERACTIVE" = false ]; then
|
||||
echo ""
|
||||
echo " To apply PATH changes in this terminal, run:"
|
||||
echo " source ~/.bashrc (bash)"
|
||||
echo " source ~/.zshrc (zsh)"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
print_banner
|
||||
|
||||
# Ask for the sudo password upfront before anything else runs.
|
||||
# This way all later sudo calls work silently without interrupting the flow.
|
||||
# Skip if we are already root - no password needed in that case.
|
||||
# Запрашиваем пароль sudo в самом начале, до запуска всего остального.
|
||||
# Тогда все последующие sudo-вызовы работают молча, не прерывая процесс.
|
||||
# Пропускаем если уже запущены от root - пароль в этом случае не нужен.
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
log_info "Some steps require administrator privileges. Please enter your password:"
|
||||
sudo -v || log_error "sudo authentication failed"
|
||||
fi
|
||||
|
||||
detect_os
|
||||
check_git
|
||||
check_docker
|
||||
install_uv
|
||||
check_python
|
||||
resolve_install_dir
|
||||
install_cobot
|
||||
setup_path
|
||||
print_success
|
||||
run_setup
|
||||
}
|
||||
|
||||
main
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Download and install the Webots simulator from the Cyberbotics GitHub release.
|
||||
# Emits PROGRESS:<pct>:<label> lines so the Python caller can update its progress bar.
|
||||
# Скачивает и устанавливает симулятор Webots из релизов GitHub Cyberbotics.
|
||||
# Выводит строки PROGRESS:<pct>:<метка> для обновления прогресс-бара в Python.
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
PROGRESS() { echo "PROGRESS:$1:$2"; }
|
||||
|
||||
WEBOTS_VERSION="2025a"
|
||||
WEBOTS_HOME="/usr/local/webots"
|
||||
DEB_URL="https://github.com/cyberbotics/webots/releases/download/R${WEBOTS_VERSION}/webots_${WEBOTS_VERSION}_amd64.deb"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
DEB_PATH="${TMP_DIR}/webots_${WEBOTS_VERSION}_amd64.deb"
|
||||
|
||||
cleanup() { rm -rf "$TMP_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
PROGRESS 5 "Downloading Webots ${WEBOTS_VERSION}..."
|
||||
echo "Downloading Webots ${WEBOTS_VERSION}..."
|
||||
echo "URL: ${DEB_URL}"
|
||||
|
||||
# wget writes download progress to stderr; redirect to stdout so it appears in the TUI log.
|
||||
wget --progress=dot:mega -O "$DEB_PATH" "$DEB_URL" 2>&1
|
||||
|
||||
PROGRESS 70 "Installing Webots package..."
|
||||
echo "Download complete. Installing..."
|
||||
|
||||
sudo apt-get install -y "$DEB_PATH"
|
||||
|
||||
PROGRESS 90 "Configuring WEBOTS_HOME..."
|
||||
echo "Setting WEBOTS_HOME=${WEBOTS_HOME}..."
|
||||
|
||||
WEBOTS_BLOCK="# Webots\nexport WEBOTS_HOME=${WEBOTS_HOME}"
|
||||
|
||||
for RC in "$HOME/.bashrc" "$HOME/.zshrc"; do
|
||||
[ -f "$RC" ] || continue
|
||||
if ! grep -q "WEBOTS_HOME" "$RC"; then
|
||||
printf "\n%b\n" "$WEBOTS_BLOCK" >> "$RC"
|
||||
echo " Added WEBOTS_HOME to $RC"
|
||||
fi
|
||||
done
|
||||
|
||||
export WEBOTS_HOME="${WEBOTS_HOME}"
|
||||
|
||||
PROGRESS 100 "Done"
|
||||
echo "Webots ${WEBOTS_VERSION} installed successfully."
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# Install ROS2 Jazzy Desktop on Ubuntu 24.04.
|
||||
# Emits PROGRESS:<pct>:<label> lines so the Python caller can update its progress bar.
|
||||
# Устанавливает ROS2 Jazzy Desktop на Ubuntu 24.04.
|
||||
# Выводит строки PROGRESS:<pct>:<метка> для обновления прогресс-бара в Python.
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
PROGRESS() { echo "PROGRESS:$1:$2"; }
|
||||
|
||||
PROGRESS 0 "Checking dpkg state..."
|
||||
echo "Checking dpkg state..."
|
||||
sudo dpkg --configure -a
|
||||
|
||||
PROGRESS 5 "Setting up locale..."
|
||||
echo "Setting up locale..."
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y -q locales
|
||||
sudo locale-gen en_US en_US.UTF-8
|
||||
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
PROGRESS 12 "Adding universe repository..."
|
||||
echo "Adding universe repository..."
|
||||
sudo apt-get install -y -q software-properties-common
|
||||
sudo add-apt-repository -y universe
|
||||
|
||||
PROGRESS 20 "Adding ROS2 GPG key..."
|
||||
echo "Adding ROS2 GPG key..."
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y -q curl
|
||||
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
|
||||
-o /usr/share/keyrings/ros-archive-keyring.gpg
|
||||
|
||||
PROGRESS 26 "Configuring ROS2 apt repository..."
|
||||
echo "Configuring ROS2 apt repository..."
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
|
||||
http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo "$UBUNTU_CODENAME") main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
|
||||
|
||||
PROGRESS 32 "Updating package lists..."
|
||||
echo "Updating package lists..."
|
||||
sudo apt-get update -q
|
||||
sudo apt-get upgrade -y -q
|
||||
|
||||
PROGRESS 38 "Installing ros-jazzy-desktop (this may take a while)..."
|
||||
echo "Installing ros-jazzy-desktop..."
|
||||
sudo apt-get install -y ros-jazzy-desktop
|
||||
|
||||
PROGRESS 78 "Installing ROS2 dev tools..."
|
||||
echo "Installing ROS2 dev tools..."
|
||||
sudo apt-get install -y ros-dev-tools
|
||||
|
||||
PROGRESS 90 "Initializing rosdep..."
|
||||
echo "Initializing rosdep..."
|
||||
sudo rosdep init 2>/dev/null || true
|
||||
rosdep update
|
||||
|
||||
PROGRESS 100 "Done"
|
||||
echo "ROS2 Jazzy Desktop installed successfully."
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# Install ROS2 Jazzy (ros-base) on Ubuntu 24.04.
|
||||
# Emits PROGRESS:<pct>:<label> lines so the Python caller can update its progress bar.
|
||||
# Устанавливает ROS2 Jazzy (ros-base) на Ubuntu 24.04.
|
||||
# Выводит строки PROGRESS:<pct>:<метка> для обновления прогресс-бара в Python.
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
PROGRESS() { echo "PROGRESS:$1:$2"; }
|
||||
|
||||
PROGRESS 0 "Checking dpkg state..."
|
||||
echo "Checking dpkg state..."
|
||||
sudo dpkg --configure -a
|
||||
|
||||
PROGRESS 5 "Setting up locale..."
|
||||
echo "Setting up locale..."
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y -q locales
|
||||
sudo locale-gen en_US en_US.UTF-8
|
||||
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
PROGRESS 12 "Adding universe repository..."
|
||||
echo "Adding universe repository..."
|
||||
sudo apt-get install -y -q software-properties-common
|
||||
sudo add-apt-repository -y universe
|
||||
|
||||
PROGRESS 20 "Adding ROS2 GPG key..."
|
||||
echo "Adding ROS2 GPG key..."
|
||||
sudo apt-get update -q
|
||||
sudo apt-get install -y -q curl
|
||||
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
|
||||
-o /usr/share/keyrings/ros-archive-keyring.gpg
|
||||
|
||||
PROGRESS 26 "Configuring ROS2 apt repository..."
|
||||
echo "Configuring ROS2 apt repository..."
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
|
||||
http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo "$UBUNTU_CODENAME") main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
|
||||
|
||||
PROGRESS 32 "Updating package lists..."
|
||||
echo "Updating package lists..."
|
||||
sudo apt-get update -q
|
||||
sudo apt-get upgrade -y -q
|
||||
|
||||
PROGRESS 38 "Installing ros-jazzy-ros-base (this may take a while)..."
|
||||
echo "Installing ros-jazzy-ros-base..."
|
||||
sudo apt-get install -y ros-jazzy-ros-base
|
||||
|
||||
PROGRESS 78 "Installing ROS2 dev tools..."
|
||||
echo "Installing ROS2 dev tools..."
|
||||
sudo apt-get install -y ros-dev-tools
|
||||
|
||||
PROGRESS 90 "Initializing rosdep..."
|
||||
echo "Initializing rosdep..."
|
||||
sudo rosdep init 2>/dev/null || true
|
||||
rosdep update
|
||||
|
||||
PROGRESS 100 "Done"
|
||||
echo "ROS2 Jazzy (ros-base) installed successfully."
|
||||
@@ -0,0 +1,18 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="lightweight-cobot",
|
||||
version="2026.05.31",
|
||||
description="CLI tool for installing, configuring and managing the ROS 2 cobot workspace",
|
||||
packages=find_packages(),
|
||||
python_requires=">=3.11",
|
||||
install_requires=[
|
||||
"textual",
|
||||
"ruamel.yaml",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"cobot=cobot.cli:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.8)
|
||||
project(iiwa_bringup)
|
||||
|
||||
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
find_package(ament_cmake REQUIRED)
|
||||
|
||||
install(
|
||||
DIRECTORY launch
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
ament_package()
|
||||
@@ -1,30 +0,0 @@
|
||||
controller_manager:
|
||||
ros__parameters:
|
||||
update_rate: 31
|
||||
|
||||
joint_state_broadcaster:
|
||||
type: "joint_state_broadcaster/JointStateBroadcaster"
|
||||
|
||||
iiwa_arm_controller:
|
||||
type: "joint_trajectory_controller/JointTrajectoryController"
|
||||
|
||||
|
||||
iiwa_arm_controller:
|
||||
ros__parameters:
|
||||
joints:
|
||||
- joint1
|
||||
- joint2
|
||||
- joint3
|
||||
- joint4
|
||||
- joint5
|
||||
- joint6
|
||||
- joint7
|
||||
|
||||
command_interfaces:
|
||||
- position
|
||||
|
||||
state_interfaces:
|
||||
- position
|
||||
|
||||
allow_partial_joints_goal: false
|
||||
interpolate_from_desired_state: true
|
||||
@@ -1,156 +1,160 @@
|
||||
from iiwa_bringup.utils import converter
|
||||
|
||||
|
||||
from launch_ros.actions import Node
|
||||
from launch import LaunchDescription
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.substitutions import PathJoinSubstitution, LaunchConfiguration
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.actions import (IncludeLaunchDescription,
|
||||
from launch.actions import (
|
||||
DeclareLaunchArgument,
|
||||
OpaqueFunction)
|
||||
EmitEvent,
|
||||
IncludeLaunchDescription,
|
||||
OpaqueFunction,
|
||||
RegisterEventHandler,
|
||||
)
|
||||
from launch.conditions import IfCondition
|
||||
from launch.event_handlers import OnProcessExit
|
||||
from launch.events import Shutdown
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||
from launch_ros.actions import Node
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from moveit_configs_utils import MoveItConfigsBuilder
|
||||
|
||||
|
||||
PACKAGE = "iiwa_bringup"
|
||||
DESCRIPTION_PKG = "iiwa_description"
|
||||
from iiwa_utils import converter, setting_loader
|
||||
|
||||
|
||||
def _runtime_setup(context, *args, **kwatgs):
|
||||
setup = []
|
||||
|
||||
model_path = LaunchConfiguration('model').perform(context)
|
||||
robot_name = LaunchConfiguration('robot_name').perform(context)
|
||||
world_path = LaunchConfiguration('world').perform(context)
|
||||
rviz_status = LaunchConfiguration('rviz').perform(context).lower() in ['true', '1', 'yes']
|
||||
settings = setting_loader.build_settings(
|
||||
settings_path=LaunchConfiguration("setting").perform(context), check_files=True
|
||||
)
|
||||
|
||||
transform = LaunchConfiguration('transform').perform(context)
|
||||
rotation = LaunchConfiguration('rotation').perform(context)
|
||||
timer = LaunchConfiguration('controller_timer').perform(context)
|
||||
|
||||
robot_description = converter.load_robot_description(model_path=model_path,
|
||||
robot_name=robot_name)
|
||||
robot_description = converter.load_robot_description(
|
||||
model_path=settings.robot.description,
|
||||
robot_name=settings.robot.name,
|
||||
xacro_args={
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions
|
||||
},
|
||||
)
|
||||
|
||||
rsp_node = Node(
|
||||
package="robot_state_publisher",
|
||||
executable="robot_state_publisher",
|
||||
name="robot_state_publisher",
|
||||
output="screen",
|
||||
parameters=[{
|
||||
"robot_description": robot_description,
|
||||
"use_sim_time": False
|
||||
}]
|
||||
parameters=[{"robot_description": robot_description, "use_sim_time": True}],
|
||||
)
|
||||
|
||||
webots_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
PathJoinSubstitution([
|
||||
FindPackageShare(PACKAGE),
|
||||
'launch',
|
||||
'supported', 'webots_spawn.launch.py'
|
||||
])
|
||||
PathJoinSubstitution(
|
||||
[
|
||||
FindPackageShare("iiwa_bringup"),
|
||||
"launch",
|
||||
"supported",
|
||||
"webots_spawn.launch.py",
|
||||
]
|
||||
)
|
||||
),
|
||||
launch_arguments={
|
||||
'robot_name': robot_name,
|
||||
'model': model_path,
|
||||
'world': world_path,
|
||||
'transform': transform,
|
||||
'rotation': rotation,
|
||||
'controller_timer': timer
|
||||
}.items()
|
||||
"robot_name": str(settings.robot.name),
|
||||
"description": str(settings.robot.description),
|
||||
"world": str(settings.digital_twin.webots.world),
|
||||
"transform": str(settings.digital_twin.webots.transform),
|
||||
"rotation": str(settings.digital_twin.webots.rotation),
|
||||
"controller_timer": str(settings.digital_twin.webots.controller_timer),
|
||||
"controller": str(settings.controller.controller_path),
|
||||
"initial_positions_file": str(settings.controller.moveit.initial_positions),
|
||||
}.items(),
|
||||
)
|
||||
|
||||
|
||||
setup += [rsp_node, webots_launch]
|
||||
|
||||
|
||||
if rviz_status:
|
||||
rviz_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
PathJoinSubstitution([FindPackageShare(PACKAGE),
|
||||
"launch", "supported", 'rviz.launch.py'
|
||||
])),
|
||||
launch_arguments={'package_name': PACKAGE}.items()
|
||||
moveit_configs = (
|
||||
MoveItConfigsBuilder("iiwa7", package_name="iiwa_config")
|
||||
.robot_description(
|
||||
file_path=settings.robot.description,
|
||||
mappings={
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions
|
||||
},
|
||||
)
|
||||
.robot_description_semantic(file_path=settings.controller.moveit.srdf)
|
||||
.robot_description_kinematics(file_path=settings.controller.moveit.kinematics)
|
||||
.joint_limits(file_path=settings.controller.moveit.joint_limits)
|
||||
.pilz_cartesian_limits(file_path=settings.controller.moveit.pilz_limits)
|
||||
.trajectory_execution(file_path=settings.controller.moveit.moveit_controllers)
|
||||
.moveit_cpp(file_path=settings.controller.moveit.moveit_cpp)
|
||||
.to_moveit_configs()
|
||||
)
|
||||
|
||||
setup.append(rviz_launch)
|
||||
move_group = Node(
|
||||
package="moveit_ros_move_group",
|
||||
executable="move_group",
|
||||
output="screen",
|
||||
parameters=[
|
||||
moveit_configs.to_dict(),
|
||||
{"robot_description": robot_description},
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
|
||||
# TODO: не забудь поменять правильное название и имя пакета
|
||||
# moveit_py_node = Node(
|
||||
# # name="motion_planning_node",
|
||||
# package="iiwa_planning",
|
||||
# executable="motion_planning",
|
||||
# output="both",
|
||||
# parameters=[moveit_configs.to_dict()],
|
||||
# )
|
||||
|
||||
|
||||
rviz_launch = Node(
|
||||
condition=IfCondition(LaunchConfiguration("rviz")),
|
||||
package="rviz2",
|
||||
executable="rviz2",
|
||||
name="rviz2",
|
||||
arguments=["-d", settings.digital_twin.rviz.config],
|
||||
output="log",
|
||||
parameters=[
|
||||
moveit_configs.robot_description,
|
||||
moveit_configs.robot_description_semantic,
|
||||
moveit_configs.planning_pipelines,
|
||||
moveit_configs.planning_scene_monitor,
|
||||
{"use_sim_time": True},
|
||||
],
|
||||
)
|
||||
|
||||
shutdown_on_rviz_exit = RegisterEventHandler(
|
||||
OnProcessExit(target_action=rviz_launch, on_exit=[EmitEvent(event=Shutdown())])
|
||||
)
|
||||
|
||||
setup += [
|
||||
rsp_node,
|
||||
webots_launch,
|
||||
move_group,
|
||||
# moveit_py_node,
|
||||
rviz_launch,
|
||||
shutdown_on_rviz_exit,
|
||||
]
|
||||
|
||||
return setup
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
|
||||
# Объявление аргументов командной строки
|
||||
declare_model_arg = DeclareLaunchArgument(
|
||||
name="model",
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare(DESCRIPTION_PKG),
|
||||
"urdf",
|
||||
"iiwa7.urdf.xacro"
|
||||
]),
|
||||
description="Path to robot xacro or urdf file (used to build robot_description)."
|
||||
)
|
||||
|
||||
declare_robot_name_arg = DeclareLaunchArgument(
|
||||
name="robot_name",
|
||||
default_value="iiwa7",
|
||||
description="Robot name (used for TF and naming spawned robot)."
|
||||
)
|
||||
|
||||
declare_world_arg = DeclareLaunchArgument(
|
||||
name="world",
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare(DESCRIPTION_PKG),
|
||||
"worlds",
|
||||
"iiwa.wbt"
|
||||
]),
|
||||
description="Path to the Webots world (.wbt) to launch."
|
||||
)
|
||||
|
||||
declare_controller_arg = DeclareLaunchArgument(
|
||||
name="controller",
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare(PACKAGE),
|
||||
"config",
|
||||
"iiwa_controller.yaml"
|
||||
]),
|
||||
description="Path to controllers YAML file (used by spawner and controller_manager)."
|
||||
)
|
||||
|
||||
declare_transform_arg = DeclareLaunchArgument(
|
||||
name="transform",
|
||||
default_value="-0.25 0 0.79",
|
||||
description="Translation applied when spawning the robot in the Webots world (x y z)."
|
||||
)
|
||||
|
||||
declare_rotation_arg = DeclareLaunchArgument(
|
||||
name="rotation",
|
||||
default_value="0 0 1 0",
|
||||
description="Rotation (axis-angle) applied when spawning the robot in the Webots world."
|
||||
)
|
||||
|
||||
declare_rviz_arg = DeclareLaunchArgument(
|
||||
declare_rviz = DeclareLaunchArgument(
|
||||
name="rviz",
|
||||
default_value="0",
|
||||
description="If true|1|yes then launch RViz and joint_state_publisher_gui instead of controllers."
|
||||
description="If true|1|yes then launch RViz/MoveIt branch (instead of controllers branch)",
|
||||
)
|
||||
|
||||
declare_controller_manager_arg = DeclareLaunchArgument(
|
||||
name="controller_timer",
|
||||
default_value="50",
|
||||
description="Timeout (seconds) for controller_manager spawners (--controller-manager-timeout)."
|
||||
declacre_setting = DeclareLaunchArgument(
|
||||
name="setting",
|
||||
default_value=PathJoinSubstitution(
|
||||
[FindPackageShare("iiwa_config"), "config", "setting.yaml"]
|
||||
),
|
||||
description="Absolute path to settings file",
|
||||
)
|
||||
|
||||
runtime_setup = OpaqueFunction(
|
||||
function=_runtime_setup
|
||||
)
|
||||
runtime_setup = OpaqueFunction(function=_runtime_setup)
|
||||
|
||||
return LaunchDescription([
|
||||
declare_model_arg,
|
||||
declare_robot_name_arg,
|
||||
declare_world_arg,
|
||||
declare_controller_arg,
|
||||
declare_transform_arg,
|
||||
declare_rotation_arg,
|
||||
declare_rviz_arg,
|
||||
declare_controller_manager_arg,
|
||||
runtime_setup
|
||||
])
|
||||
return LaunchDescription(
|
||||
[
|
||||
declare_rviz,
|
||||
declacre_setting,
|
||||
runtime_setup,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import (
|
||||
DeclareLaunchArgument,
|
||||
EmitEvent,
|
||||
IncludeLaunchDescription,
|
||||
OpaqueFunction,
|
||||
RegisterEventHandler,
|
||||
)
|
||||
from launch.conditions import IfCondition
|
||||
from launch.event_handlers import OnProcessExit
|
||||
from launch.events import Shutdown
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||
from launch_ros.actions import Node
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from moveit_configs_utils import MoveItConfigsBuilder
|
||||
from webots_ros2_driver.webots_controller import WebotsController
|
||||
|
||||
from iiwa_utils import converter, setting_loader
|
||||
from iiwa_utils.camera_spawner import load_camera_config, build_ros_urdf # type: ignore
|
||||
|
||||
|
||||
def _foxglove_params(fg, use_sim_time: bool) -> dict:
|
||||
params = asdict(fg)
|
||||
params.pop("enabled")
|
||||
params["use_sim_time"] = use_sim_time
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _runtime_setup(context, *args, **kwargs):
|
||||
setup = []
|
||||
|
||||
# Настройка параметров
|
||||
simulate = LaunchConfiguration("simulate").perform(context) in ("true", "1", "yes")
|
||||
|
||||
settings = setting_loader.build_settings(
|
||||
settings_path=LaunchConfiguration("setting").perform(context),
|
||||
check_files=True,
|
||||
)
|
||||
|
||||
joint_limits_ros2 = converter.wrap_for_ros2_params(
|
||||
settings.controller.moveit.joint_limits,
|
||||
"robot_description_planning",
|
||||
)
|
||||
kinematics_ros2 = converter.wrap_for_ros2_params(
|
||||
settings.controller.moveit.kinematics,
|
||||
"robot_description_kinematics",
|
||||
)
|
||||
|
||||
description_path = settings.robot.description
|
||||
|
||||
if simulate:
|
||||
xacro_args = {
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions,
|
||||
"simulate": "true",
|
||||
}
|
||||
use_sim_time = True
|
||||
else:
|
||||
xacro_args = {
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions,
|
||||
"robot_ip": settings.robot.ip,
|
||||
"fri_port": str(settings.robot.port),
|
||||
"simulate": "false",
|
||||
"command_mode": settings.robot.command_mode,
|
||||
"joint_position_tau": str(settings.robot.joint_position_tau),
|
||||
}
|
||||
use_sim_time = False
|
||||
|
||||
robot_description = converter.load_robot_description(
|
||||
model_path=description_path,
|
||||
robot_name=settings.robot.name,
|
||||
xacro_args=xacro_args,
|
||||
)
|
||||
|
||||
# Вызов нод
|
||||
rsp_node = Node(
|
||||
package="robot_state_publisher",
|
||||
executable="robot_state_publisher",
|
||||
name="robot_state_publisher",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{"robot_description": robot_description},
|
||||
{"use_sim_time": use_sim_time},
|
||||
],
|
||||
)
|
||||
|
||||
setup += [rsp_node]
|
||||
|
||||
# webots spawn
|
||||
if simulate:
|
||||
webots_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
PathJoinSubstitution(
|
||||
[
|
||||
FindPackageShare("iiwa_bringup"),
|
||||
"launch",
|
||||
"supported",
|
||||
"webots_spawn.launch.py",
|
||||
]
|
||||
)
|
||||
),
|
||||
launch_arguments={
|
||||
"robot_name": str(settings.robot.name),
|
||||
"description": str(settings.robot.description),
|
||||
"world": str(settings.digital_twin.webots.world),
|
||||
"transform": str(settings.digital_twin.webots.transform),
|
||||
"rotation": str(settings.digital_twin.webots.rotation),
|
||||
"controller_timer": str(settings.digital_twin.webots.controller_timer),
|
||||
"controller": str(settings.controller.controller_path),
|
||||
"initial_positions_file": str(settings.controller.moveit.initial_positions),
|
||||
}.items(),
|
||||
)
|
||||
|
||||
setup += [webots_launch]
|
||||
|
||||
# Controller launch
|
||||
if simulate:
|
||||
controller_args = {
|
||||
"robot_name": settings.robot.name,
|
||||
"description": description_path,
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions,
|
||||
"controller_path": settings.controller.controller_path,
|
||||
"simulate": "true",
|
||||
"transform": str(settings.digital_twin.webots.transform),
|
||||
"rotation": str(settings.digital_twin.webots.rotation),
|
||||
"controller_timer": str(settings.digital_twin.webots.controller_timer),
|
||||
}
|
||||
|
||||
if settings.digital_twin.webots.cameras:
|
||||
# Спавн камеры
|
||||
camera_spawner_node = Node(
|
||||
package="iiwa_utils",
|
||||
executable="camera_spawner",
|
||||
name="camera_spawner",
|
||||
output="screen",
|
||||
parameters=[{
|
||||
"camera_configs": json.dumps(settings.digital_twin.webots.cameras)
|
||||
}],
|
||||
)
|
||||
setup.append(camera_spawner_node)
|
||||
|
||||
# WebotsController для каждой камеры
|
||||
for cam_path in settings.digital_twin.webots.cameras:
|
||||
cam_cfg = load_camera_config(cam_path)
|
||||
urdf = build_ros_urdf(cam_cfg)
|
||||
|
||||
camera_controller = WebotsController(
|
||||
robot_name=f"{cam_cfg.name}_robot",
|
||||
parameters=[{
|
||||
"robot_description": urdf,
|
||||
"use_sim_time": True,
|
||||
"set_robot_state_publisher": False,
|
||||
}],
|
||||
respawn=True,
|
||||
)
|
||||
setup.append(camera_controller)
|
||||
|
||||
else:
|
||||
controller_args = {
|
||||
"robot_name": settings.robot.name,
|
||||
"description": description_path,
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions,
|
||||
"controller_path": settings.controller.controller_path,
|
||||
"transform": str(settings.digital_twin.webots.transform),
|
||||
"rotation": str(settings.digital_twin.webots.rotation),
|
||||
"simulate": "false",
|
||||
"command_mode": settings.robot.command_mode,
|
||||
"controller_timer": str(settings.digital_twin.webots.controller_timer),
|
||||
"fri_cycle_ms": str(settings.robot.fri_cycle_ms),
|
||||
"joint_position_tau": str(settings.robot.joint_position_tau),
|
||||
"controller": settings.robot.active_controller,
|
||||
}
|
||||
|
||||
controllers_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
PathJoinSubstitution(
|
||||
[
|
||||
FindPackageShare("iiwa_bringup"),
|
||||
"launch",
|
||||
"supported",
|
||||
"controllers.launch.py",
|
||||
]
|
||||
)
|
||||
),
|
||||
launch_arguments={k: str(v) for k, v in controller_args.items()}.items(),
|
||||
)
|
||||
|
||||
# Moveit launch
|
||||
moveit_configs = (
|
||||
MoveItConfigsBuilder("iiwa7", package_name="iiwa_config")
|
||||
.robot_description(
|
||||
file_path=description_path,
|
||||
mappings={
|
||||
"initial_positions_file": settings.controller.moveit.initial_positions
|
||||
},
|
||||
)
|
||||
.robot_description_semantic(file_path=settings.controller.moveit.srdf)
|
||||
.robot_description_kinematics(file_path=settings.controller.moveit.kinematics)
|
||||
.joint_limits(file_path=settings.controller.moveit.joint_limits)
|
||||
.pilz_cartesian_limits(file_path=settings.controller.moveit.pilz_limits)
|
||||
.trajectory_execution(file_path=settings.controller.moveit.moveit_controllers)
|
||||
.moveit_cpp(file_path=settings.controller.moveit.moveit_cpp)
|
||||
.to_moveit_configs()
|
||||
)
|
||||
|
||||
move_group = Node(
|
||||
package="moveit_ros_move_group",
|
||||
executable="move_group",
|
||||
output="screen",
|
||||
parameters=[
|
||||
moveit_configs.to_dict(),
|
||||
{"robot_description": robot_description},
|
||||
{"use_sim_time": use_sim_time},
|
||||
],
|
||||
)
|
||||
|
||||
move_to_pose_server = Node(
|
||||
package="iiwa_planning",
|
||||
executable="move_to_pose_server",
|
||||
output="screen",
|
||||
parameters=[
|
||||
moveit_configs.to_dict(),
|
||||
{"robot_description": robot_description},
|
||||
{"use_sim_time": use_sim_time},
|
||||
{
|
||||
"pose_link": settings.planning.pose_link,
|
||||
"planning_group": settings.planning.planning_group,
|
||||
"default_frame": settings.planning.default_frame,
|
||||
"default_planner": settings.planning.default_planner,
|
||||
"planning_attempts": settings.planning.planning_attempts,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Rviz launch
|
||||
rviz_launch = Node(
|
||||
condition=IfCondition(LaunchConfiguration("rviz")),
|
||||
package="rviz2",
|
||||
executable="rviz2",
|
||||
name="rviz2",
|
||||
arguments=["-d", settings.digital_twin.rviz.config],
|
||||
output="log",
|
||||
parameters=[
|
||||
moveit_configs.robot_description,
|
||||
moveit_configs.robot_description_semantic,
|
||||
moveit_configs.planning_pipelines,
|
||||
joint_limits_ros2,
|
||||
kinematics_ros2,
|
||||
{"use_sim_time": use_sim_time},
|
||||
],
|
||||
)
|
||||
|
||||
shutdown_on_rviz_exit = RegisterEventHandler(
|
||||
OnProcessExit(
|
||||
target_action=rviz_launch,
|
||||
on_exit=[EmitEvent(event=Shutdown())],
|
||||
)
|
||||
)
|
||||
|
||||
setup += [
|
||||
controllers_launch,
|
||||
move_group,
|
||||
move_to_pose_server,
|
||||
rviz_launch,
|
||||
shutdown_on_rviz_exit,
|
||||
]
|
||||
|
||||
if settings.foxglove.enabled:
|
||||
foxglove_bridge = Node(
|
||||
package="foxglove_bridge",
|
||||
executable="foxglove_bridge",
|
||||
output="screen",
|
||||
name="foxglove_bridge",
|
||||
parameters=[_foxglove_params(settings.foxglove, use_sim_time)]
|
||||
)
|
||||
|
||||
setup += [foxglove_bridge]
|
||||
|
||||
return setup
|
||||
|
||||
def generate_launch_description():
|
||||
declare_simulate = DeclareLaunchArgument(
|
||||
name="simulate",
|
||||
default_value="false",
|
||||
description="true = Gazebo симуляция, false = реальный робот через FRI",
|
||||
)
|
||||
|
||||
declare_rviz = DeclareLaunchArgument(
|
||||
name="rviz",
|
||||
default_value="false",
|
||||
description="true = запустить RViz",
|
||||
)
|
||||
|
||||
declare_setting = DeclareLaunchArgument(
|
||||
name="setting",
|
||||
default_value=PathJoinSubstitution(
|
||||
[FindPackageShare("iiwa_config"), "config", "setting.yaml"]
|
||||
),
|
||||
description="Путь к файлу настроек",
|
||||
)
|
||||
|
||||
runtime_setup = OpaqueFunction(function=_runtime_setup)
|
||||
|
||||
return LaunchDescription([
|
||||
declare_simulate,
|
||||
declare_rviz,
|
||||
declare_setting,
|
||||
runtime_setup,
|
||||
])
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
from launch_ros.actions import Node
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument, EmitEvent, RegisterEventHandler
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.substitutions import Command, LaunchConfiguration, PathJoinSubstitution
|
||||
from launch.events import Shutdown
|
||||
from launch.event_handlers import OnProcessExit
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
package_name="iiwa_bringup"
|
||||
description_package_name = "iiwa_description"
|
||||
|
||||
declare_model_arg = DeclareLaunchArgument(
|
||||
name="model",
|
||||
default_value=PathJoinSubstitution([
|
||||
FindPackageShare(description_package_name),
|
||||
"urdf",
|
||||
"iiwa7.urdf.xacro"
|
||||
]),
|
||||
description="Path to the robot URDF/Xacro file"
|
||||
)
|
||||
|
||||
declare_robot_name_arg = DeclareLaunchArgument(
|
||||
name="robot_name",
|
||||
default_value="iiwa7",
|
||||
description="Robot name fro the TF tree"
|
||||
)
|
||||
|
||||
robot_description = Command([
|
||||
"xacro ", LaunchConfiguration('model'),
|
||||
" robot_name:=", LaunchConfiguration("robot_name")
|
||||
])
|
||||
|
||||
robot_state_publisher = Node(
|
||||
package="robot_state_publisher",
|
||||
executable="robot_state_publisher",
|
||||
name="robot_state_publisher",
|
||||
output="both",
|
||||
parameters=[{
|
||||
"robot_description": robot_description,
|
||||
"use_sim_time": False,
|
||||
}]
|
||||
)
|
||||
|
||||
joint_state_publisher_gui = Node(
|
||||
package='joint_state_publisher_gui',
|
||||
executable='joint_state_publisher_gui',
|
||||
name='joint_state_publisher_gui',
|
||||
parameters=[{"use_sim_time": False}]
|
||||
)
|
||||
|
||||
rviz_config = PathJoinSubstitution([
|
||||
FindPackageShare(package_name),
|
||||
'config',
|
||||
'rviz_iiwa.rviz'
|
||||
])
|
||||
|
||||
rviz = Node(
|
||||
package='rviz2',
|
||||
executable='rviz2',
|
||||
name='rviz2',
|
||||
arguments=['-d', rviz_config],
|
||||
output='log'
|
||||
)
|
||||
|
||||
shutdown_on_rviz_exit = RegisterEventHandler(
|
||||
OnProcessExit(
|
||||
target_action=rviz,
|
||||
on_exit=[EmitEvent(event=Shutdown())]
|
||||
)
|
||||
)
|
||||
|
||||
return LaunchDescription([
|
||||
declare_model_arg,
|
||||
declare_robot_name_arg,
|
||||
robot_state_publisher,
|
||||
joint_state_publisher_gui,
|
||||
rviz,
|
||||
shutdown_on_rviz_exit
|
||||
])
|
||||
@@ -0,0 +1,167 @@
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument, OpaqueFunction
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch.event_handlers import OnProcessExit
|
||||
from launch.actions import RegisterEventHandler
|
||||
from launch_ros.actions import Node
|
||||
from webots_ros2_driver.urdf_spawner import URDFSpawner
|
||||
|
||||
|
||||
from iiwa_utils import converter
|
||||
|
||||
|
||||
def _setup_controllers(context, *args, **kwargs):
|
||||
robot_name = LaunchConfiguration("robot_name").perform(context)
|
||||
description = LaunchConfiguration("description").perform(context)
|
||||
transform = LaunchConfiguration("transform").perform(context)
|
||||
rotation = LaunchConfiguration("rotation").perform(context)
|
||||
initial_positions_file = LaunchConfiguration("initial_positions_file").perform(context)
|
||||
controller_timer = LaunchConfiguration("controller_timer").perform(context)
|
||||
controller_path = LaunchConfiguration("controller_path").perform(context)
|
||||
simulate = LaunchConfiguration("simulate").perform(context).lower() in ("true", "1", "yes")
|
||||
command_mode = LaunchConfiguration("command_mode").perform(context)
|
||||
controller = LaunchConfiguration("controller").perform(context) # "jtc" | "forward"
|
||||
fri_cycle_ms = int(LaunchConfiguration("fri_cycle_ms").perform(context))
|
||||
joint_position_tau = LaunchConfiguration("joint_position_tau").perform(context)
|
||||
update_rate = 1000 // fri_cycle_ms
|
||||
|
||||
xacro_args = {"initial_positions_file": initial_positions_file}
|
||||
|
||||
if simulate:
|
||||
xacro_args["simulate"] = "true"
|
||||
else:
|
||||
xacro_args["joint_position_tau"] = joint_position_tau
|
||||
|
||||
robot_description = converter.load_robot_description(
|
||||
model_path=description,
|
||||
robot_name=robot_name,
|
||||
xacro_args=xacro_args,
|
||||
)
|
||||
|
||||
# Webots
|
||||
if simulate:
|
||||
tmo = ["--controller-manager-timeout", str(controller_timer)]
|
||||
|
||||
spawner_urdf = URDFSpawner(
|
||||
name=robot_name,
|
||||
robot_description=robot_description,
|
||||
translation=transform,
|
||||
rotation=rotation,
|
||||
)
|
||||
|
||||
jsb = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=["joint_state_broadcaster"] + tmo,
|
||||
parameters=[{"use_sim_time": True}],
|
||||
)
|
||||
|
||||
jtc = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=["iiwa_arm_controller"] + tmo,
|
||||
parameters=[{"use_sim_time": True}],
|
||||
)
|
||||
|
||||
torque_controller_spawner = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=["iiwa_arm_torque_controller", "--inactive"] + tmo,
|
||||
parameters=[{"use_sim_time": True}]
|
||||
)
|
||||
|
||||
jtc_after_jsb = RegisterEventHandler(
|
||||
OnProcessExit(
|
||||
target_action=jsb,
|
||||
on_exit=[jtc, torque_controller_spawner],
|
||||
)
|
||||
)
|
||||
|
||||
return [spawner_urdf, jsb, jtc_after_jsb]
|
||||
|
||||
# FRI
|
||||
else:
|
||||
ros2_control_node = Node(
|
||||
package="controller_manager",
|
||||
executable="ros2_control_node",
|
||||
output="screen",
|
||||
parameters=[
|
||||
{"robot_description": robot_description},
|
||||
controller_path,
|
||||
{"update_rate": update_rate},
|
||||
],
|
||||
)
|
||||
|
||||
jsb = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=[
|
||||
"joint_state_broadcaster",
|
||||
"--controller-manager", "/controller_manager",
|
||||
],
|
||||
)
|
||||
|
||||
cm = ["--controller-manager", "/controller_manager"]
|
||||
|
||||
# JTC: активен если controller=jtc (и command_mode=position), иначе --inactive
|
||||
jtc_args = ["iiwa_arm_controller"] + cm
|
||||
if command_mode == "torque" or controller == "forward":
|
||||
jtc_args += ["--inactive"]
|
||||
|
||||
# ForwardCommandController: активен если controller=forward, иначе --inactive
|
||||
forward_args = ["forward_position_controller"] + cm
|
||||
if controller != "forward":
|
||||
forward_args += ["--inactive"]
|
||||
|
||||
# TorqueController: активен если command_mode=torque и controller=jtc
|
||||
torque_args = ["iiwa_arm_torque_controller"] + cm
|
||||
if not (command_mode == "torque" and controller == "jtc"):
|
||||
torque_args += ["--inactive"]
|
||||
|
||||
jtc = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=jtc_args,
|
||||
)
|
||||
|
||||
forward_controller = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=forward_args,
|
||||
)
|
||||
|
||||
torque_controller = Node(
|
||||
package="controller_manager",
|
||||
executable="spawner",
|
||||
output="screen",
|
||||
arguments=torque_args,
|
||||
)
|
||||
|
||||
jtc_after_jsb = RegisterEventHandler(
|
||||
OnProcessExit(
|
||||
target_action=jsb,
|
||||
on_exit=[jtc, forward_controller, torque_controller],
|
||||
)
|
||||
)
|
||||
|
||||
return [
|
||||
ros2_control_node,
|
||||
jsb,
|
||||
jtc_after_jsb,
|
||||
]
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
DeclareLaunchArgument("command_mode", default_value="position"),
|
||||
DeclareLaunchArgument("fri_cycle_ms", default_value="5"),
|
||||
DeclareLaunchArgument("joint_position_tau", default_value="0.04"),
|
||||
DeclareLaunchArgument("controller", default_value="jtc"),
|
||||
OpaqueFunction(function=_setup_controllers),
|
||||
])
|
||||
@@ -1,46 +0,0 @@
|
||||
from launch_ros.actions import Node
|
||||
from launch.events import Shutdown
|
||||
from launch import LaunchDescription
|
||||
from launch.event_handlers import OnProcessExit
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||
from launch.actions import RegisterEventHandler, EmitEvent, DeclareLaunchArgument
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
|
||||
declare_package_arg = DeclareLaunchArgument(
|
||||
'package_name',
|
||||
default_value='iiwa_bringup',
|
||||
description='Package name where RViz config is stored'
|
||||
)
|
||||
|
||||
package_name = LaunchConfiguration('package_name')
|
||||
|
||||
rviz_config = PathJoinSubstitution([
|
||||
FindPackageShare(package_name),
|
||||
'config',
|
||||
'rviz_iiwa.rviz'
|
||||
])
|
||||
|
||||
rviz = Node(
|
||||
package='rviz2',
|
||||
executable='rviz2',
|
||||
name='rviz2',
|
||||
arguments=['-d', rviz_config],
|
||||
output='log'
|
||||
)
|
||||
|
||||
# При закрытии rviz закрываем все
|
||||
shutdown_on_rviz_exit = RegisterEventHandler(
|
||||
OnProcessExit(
|
||||
target_action=rviz,
|
||||
on_exit=[EmitEvent(event=Shutdown())]
|
||||
)
|
||||
)
|
||||
|
||||
return LaunchDescription([
|
||||
declare_package_arg,
|
||||
rviz,
|
||||
shutdown_on_rviz_exit
|
||||
])
|
||||
@@ -1,51 +0,0 @@
|
||||
from launch_ros.actions import Node
|
||||
from webots_ros2_driver.urdf_spawner import URDFSpawner
|
||||
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import OpaqueFunction
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from iiwa_bringup.utils import converter
|
||||
|
||||
|
||||
def _setup_controllers(context, *args, **kwargs):
|
||||
model_path = LaunchConfiguration('model').perform(context)
|
||||
robot_name = LaunchConfiguration('robot_name').perform(context)
|
||||
transform = LaunchConfiguration('transform').perform(context)
|
||||
rotation = LaunchConfiguration('rotation').perform(context)
|
||||
timer = LaunchConfiguration('controller_timer').perform(context)
|
||||
|
||||
robot_description = converter.load_robot_description(model_path=model_path,
|
||||
robot_name=robot_name)
|
||||
|
||||
tmo = ['--controller-manager-timeout', str(timer)]
|
||||
|
||||
jsb = Node(
|
||||
package='controller_manager',
|
||||
executable='spawner',
|
||||
output='screen',
|
||||
arguments=['joint_state_broadcaster'] + tmo,
|
||||
parameters=[{'use_sim_time': False}],
|
||||
)
|
||||
|
||||
jtc = Node(
|
||||
package='controller_manager',
|
||||
executable='spawner',
|
||||
output='screen',
|
||||
arguments=['iiwa_arm_controller'] + tmo,
|
||||
parameters=[{'use_sim_time': False}],
|
||||
)
|
||||
|
||||
spawner_urdf = URDFSpawner(
|
||||
name=robot_name,
|
||||
robot_description=robot_description,
|
||||
translation=transform,
|
||||
rotation=rotation
|
||||
)
|
||||
|
||||
return [jsb, jtc, spawner_urdf]
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
OpaqueFunction(function=_setup_controllers)
|
||||
])
|
||||
@@ -1,90 +1,60 @@
|
||||
from launch.events import Shutdown
|
||||
from launch import LaunchDescription
|
||||
from launch.event_handlers import OnProcessExit
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch_ros.substitutions import FindPackageShare
|
||||
from launch.event_handlers import OnProcessExit, OnProcessStart
|
||||
from launch.substitutions import PathJoinSubstitution, LaunchConfiguration
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.actions import (IncludeLaunchDescription,
|
||||
from launch.actions import (
|
||||
EmitEvent,
|
||||
OpaqueFunction,
|
||||
TimerAction,
|
||||
RegisterEventHandler,
|
||||
EmitEvent)
|
||||
|
||||
|
||||
from webots_ros2_driver.webots_launcher import WebotsLauncher
|
||||
)
|
||||
from launch.event_handlers import OnProcessExit
|
||||
from launch.events import Shutdown
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from webots_ros2_driver.webots_controller import WebotsController
|
||||
from webots_ros2_driver.webots_launcher import WebotsLauncher
|
||||
|
||||
from iiwa_utils import converter
|
||||
|
||||
|
||||
def _spawn_setup(context, *args, **kwargs):
|
||||
model_path = LaunchConfiguration('model').perform(context)
|
||||
robot_name = LaunchConfiguration('robot_name').perform(context)
|
||||
world_path = LaunchConfiguration('world').perform(context)
|
||||
transform = LaunchConfiguration('transform').perform(context)
|
||||
rotation = LaunchConfiguration('rotation').perform(context)
|
||||
timer = LaunchConfiguration('controller_timer').perform(context)
|
||||
robot_name = LaunchConfiguration("robot_name").perform(context)
|
||||
description = LaunchConfiguration("description").perform(context)
|
||||
world = LaunchConfiguration("world").perform(context)
|
||||
controller = LaunchConfiguration("controller").perform(context)
|
||||
initial_positions_file = LaunchConfiguration("initial_positions_file").perform(context)
|
||||
|
||||
webots = WebotsLauncher(world=world_path,
|
||||
ros2_supervisor=True)
|
||||
robot_description = converter.load_robot_description(
|
||||
model_path=description,
|
||||
robot_name=robot_name,
|
||||
xacro_args={
|
||||
"simulate": "true",
|
||||
"initial_positions_file": initial_positions_file,
|
||||
},
|
||||
)
|
||||
|
||||
webots = WebotsLauncher(world=world, ros2_supervisor=True)
|
||||
|
||||
driver = WebotsController(
|
||||
robot_name=robot_name,
|
||||
parameters=[
|
||||
{
|
||||
"robot_description": model_path,
|
||||
"use_sim_time": False,
|
||||
"set_robot_state_publisher": False
|
||||
"robot_description": robot_description,
|
||||
"use_sim_time": True,
|
||||
"set_robot_state_publisher": False,
|
||||
},
|
||||
LaunchConfiguration('controller').perform(context)
|
||||
controller,
|
||||
],
|
||||
respawn=True
|
||||
)
|
||||
|
||||
controllers_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
PathJoinSubstitution([
|
||||
FindPackageShare('iiwa_bringup'),
|
||||
'launch',
|
||||
'supported',
|
||||
'webots_controllers.launch.py'
|
||||
])
|
||||
),
|
||||
launch_arguments={
|
||||
'robot_name': robot_name,
|
||||
'model': model_path,
|
||||
'transform': transform,
|
||||
'rotation': rotation,
|
||||
'controller_timer': timer
|
||||
}.items()
|
||||
)
|
||||
|
||||
spawn_on_driver_start = RegisterEventHandler(
|
||||
event_handler=OnProcessStart(
|
||||
target_action=driver,
|
||||
on_start=lambda evt, ctx: [
|
||||
TimerAction(period=5.0,
|
||||
actions=[controllers_launch]
|
||||
)
|
||||
]
|
||||
)
|
||||
respawn=True,
|
||||
)
|
||||
|
||||
shutdown_on_webots_exit = RegisterEventHandler(
|
||||
OnProcessExit(
|
||||
target_action=webots,
|
||||
on_exit=[EmitEvent(event=Shutdown())]
|
||||
)
|
||||
OnProcessExit(target_action=webots, on_exit=[EmitEvent(event=Shutdown())])
|
||||
)
|
||||
|
||||
return [webots,
|
||||
return [
|
||||
webots,
|
||||
webots._supervisor,
|
||||
driver,
|
||||
spawn_on_driver_start,
|
||||
shutdown_on_webots_exit]
|
||||
shutdown_on_webots_exit,
|
||||
]
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
return LaunchDescription([
|
||||
OpaqueFunction(function=_spawn_setup)
|
||||
])
|
||||
return LaunchDescription([OpaqueFunction(function=_spawn_setup)])
|
||||
|
||||
@@ -2,18 +2,43 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>iiwa_bringup</name>
|
||||
<version>0.0.1</version>
|
||||
<description>TODO: Package description</description>
|
||||
<maintainer email="grabardm@ml-dev.ru">Grabar Daniil</maintainer>
|
||||
<version>2026.05.31</version>
|
||||
<description>Launch files for cobot: Webots simulation, real robot via FRI, MoveIt motion planning and RViz visualization</description>
|
||||
<maintainer email="grabardm@ml-dev.ru">daniel</maintainer>
|
||||
<license>Apache-2.0</license>
|
||||
|
||||
<test_depend>ament_copyright</test_depend>
|
||||
<test_depend>ament_flake8</test_depend>
|
||||
<test_depend>ament_pep257</test_depend>
|
||||
<test_depend>ament_xmllint</test_depend>
|
||||
<test_depend>python3-pytest</test_depend>
|
||||
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||
|
||||
<!-- Внутренние пакеты проекта -->
|
||||
<exec_depend>iiwa_utils</exec_depend>
|
||||
<exec_depend>iiwa_config</exec_depend>
|
||||
<exec_depend>iiwa_description</exec_depend>
|
||||
<exec_depend>foxglove_bridge</exec_depend>
|
||||
|
||||
<!-- ROS 2 infrastructure -->
|
||||
<exec_depend>launch</exec_depend>
|
||||
<exec_depend>launch_ros</exec_depend>
|
||||
<exec_depend>robot_state_publisher</exec_depend>
|
||||
|
||||
<!-- MoveIt 2 -->
|
||||
<exec_depend>moveit_ros_move_group</exec_depend>
|
||||
<exec_depend>moveit_configs_utils</exec_depend>
|
||||
<exec_depend>rviz2</exec_depend>
|
||||
|
||||
<!-- ros2_control -->
|
||||
<exec_depend>controller_manager</exec_depend>
|
||||
<exec_depend>joint_state_broadcaster</exec_depend>
|
||||
<exec_depend>joint_trajectory_controller</exec_depend>
|
||||
<exec_depend>forward_command_controller</exec_depend>
|
||||
|
||||
<!-- Webots -->
|
||||
<exec_depend>webots_ros2_driver</exec_depend>
|
||||
<exec_depend>webots_ros2_control</exec_depend>
|
||||
|
||||
<test_depend>ament_lint_auto</test_depend>
|
||||
<test_depend>ament_lint_common</test_depend>
|
||||
|
||||
<export>
|
||||
<build_type>ament_python</build_type>
|
||||
<build_type>ament_cmake</build_type>
|
||||
</export>
|
||||
</package>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[develop]
|
||||
script_dir=$base/lib/iiwa_bringup
|
||||
[install]
|
||||
install_scripts=$base/lib/iiwa_bringup
|
||||
@@ -1,55 +0,0 @@
|
||||
import os
|
||||
from setuptools import setup
|
||||
|
||||
package_name = 'iiwa_bringup'
|
||||
|
||||
# Собираем список пакетов: основной + вложенные (если они есть).
|
||||
packages = [package_name]
|
||||
# Если есть подпапка utils с __init__.py — зарегистрируем её как iiwa_bringup.utils
|
||||
if os.path.isdir('utils') and os.path.isfile(os.path.join('utils', '__init__.py')):
|
||||
packages.append(f'{package_name}.utils')
|
||||
|
||||
# Соответствие имени пакета -> директория на диске.
|
||||
# iiwa_bringup -> текущая папка '.'
|
||||
# iiwa_bringup.utils -> ./utils
|
||||
package_dir = {
|
||||
package_name: '.',
|
||||
}
|
||||
if f'{package_name}.utils' in packages:
|
||||
package_dir[f'{package_name}.utils'] = os.path.join('.', 'utils')
|
||||
|
||||
def data_files_from_tree(src_dir: str, dst_root: str) -> list:
|
||||
entries = []
|
||||
if not os.path.isdir(src_dir):
|
||||
return entries
|
||||
EXCLUDE_DIRS = {'.git', '__pycache__', '.pytest_cache', '.idea'}
|
||||
EXCLUDES = {'.DS_Store', 'Thumbs.db'}
|
||||
for root, dirs, files in os.walk(src_dir):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
|
||||
file_list = [os.path.join(root, f) for f in files if f not in EXCLUDES]
|
||||
if not file_list:
|
||||
continue
|
||||
rel = os.path.relpath(root, src_dir)
|
||||
dst_dir = os.path.join(dst_root, rel) if rel != '.' else dst_root
|
||||
entries.append((dst_dir, file_list))
|
||||
return entries
|
||||
|
||||
data_files = [(f'share/{package_name}', ['package.xml'])]
|
||||
data_files += data_files_from_tree('launch', f'share/{package_name}/launch')
|
||||
data_files += data_files_from_tree('config', f'share/{package_name}/config')
|
||||
data_files += data_files_from_tree('resource', f'share/{package_name}/resource')
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.0.1',
|
||||
packages=packages,
|
||||
package_dir=package_dir,
|
||||
include_package_data=True,
|
||||
data_files=data_files,
|
||||
install_requires=['setuptools'],
|
||||
zip_safe=False,
|
||||
maintainer='Grabar Daniil',
|
||||
maintainer_email='grabardm@ml-dev.ru',
|
||||
description='iiwa bringup package',
|
||||
license='Apache-2.0',
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
import xacro
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_robot_description(model_path: Path, robot_name: str) -> str:
|
||||
suffix = Path(model_path).suffix.lower()
|
||||
if suffix == ".xacro":
|
||||
return xacro.process_file(model_path, mappings={'name': str(robot_name)}).toxml()
|
||||
elif suffix == ".urdf":
|
||||
return Path(model_path).read_text(encoding="utf-8")
|
||||
else:
|
||||
raise FileNotFoundError(f"Supported file formats: xacro/urdf")
|
||||
@@ -0,0 +1,17 @@
|
||||
cmake_minimum_required(VERSION 3.8)
|
||||
project(iiwa_config)
|
||||
|
||||
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# find dependencies
|
||||
find_package(ament_cmake REQUIRED)
|
||||
|
||||
install(
|
||||
DIRECTORY config
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
|
||||
ament_package()
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Имя камеры - используется как:
|
||||
# - имя Robot-ноды в Webots: "{name}_robot"
|
||||
# - имя Camera-девайса: "{name}"
|
||||
# - имя RangeFinder-девайса: "{name}_depth"
|
||||
# - префикс ROS2-топиков
|
||||
|
||||
name: d455_top
|
||||
# Положение в сцене (x y z)
|
||||
translation: "0.25 0 2.49"
|
||||
# Ориентация (ось_x ось_y ось_z угол_рад)
|
||||
rotation: "0 1 0 1.5708"
|
||||
|
||||
# Блок camera
|
||||
# Если блок отсутствует — Camera-девайс не создаётся
|
||||
# Поля соответствуют документации Webots:
|
||||
# https://cyberbotics.com/doc/reference/camera
|
||||
camera:
|
||||
width: 640
|
||||
height: 480
|
||||
fieldOfView: 1.047 # ~60° в радианах
|
||||
near: 0.1
|
||||
far: 0.0 # 0 = без ограничений
|
||||
exposure: 1.0
|
||||
antiAliasing: false
|
||||
ambientOcclusionRadius: 0.0
|
||||
bloomThreshold: -1.0 # -1 = отключён
|
||||
noise: 0.0
|
||||
motionBlur: 0.0
|
||||
|
||||
# Параметры публикации в ROS2 (не часть Webots-ноды, обрабатываются отдельно)
|
||||
ros:
|
||||
topic: /d455_top/image_raw
|
||||
update_rate: 30
|
||||
|
||||
|
||||
# Блок range_finder
|
||||
# Если блок отсутствует - RangeFinder-девайс не создаётся
|
||||
# Поля соответствуют документации Webots:
|
||||
# https://cyberbotics.com/doc/reference/rangefinder
|
||||
range_finder:
|
||||
width: 640
|
||||
height: 480
|
||||
fieldOfView: 1.047
|
||||
minRange: 0.1
|
||||
maxRange: 5.0
|
||||
resolution: -1.0 # -1 = без ограничений по дискретизации
|
||||
noise: 0.0
|
||||
|
||||
# Параметры публикации в ROS2
|
||||
ros:
|
||||
topic: /d455_top/depth/image_raw
|
||||
update_rate: 15
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--This does not replace URDF, and is not an extension of URDF.
|
||||
This is a format for representing semantic information about the robot structure.
|
||||
A URDF file must exist for this robot as well, where the joints and the links that are referenced are defined
|
||||
-->
|
||||
<robot name="iiwa7">
|
||||
<!--GROUPS: Representation of a set of joints and links. This can be useful for specifying DOF to plan for, defining arms, end effectors, etc-->
|
||||
<!--LINKS: When a link is specified, the parent joint of that link (if it exists) is automatically included-->
|
||||
<!--JOINTS: When a joint is specified, the child link of that joint (which will always exist) is automatically included-->
|
||||
<!--CHAINS: When a chain is specified, all the links along the chain (including endpoints) are included in the group. Additionally, all the joints that are parents to included links are also included. This means that joints along the chain and the parent joint of the base link are included in the group-->
|
||||
<!--SUBGROUPS: Groups can also be formed by referencing to already defined group names-->
|
||||
<group name="iiwa_arm">
|
||||
<!-- <joint name="world_base_joint"/>
|
||||
<joint name="joint1"/>
|
||||
<joint name="joint2"/>
|
||||
<joint name="joint3"/>
|
||||
<joint name="joint4"/>
|
||||
<joint name="joint5"/>
|
||||
<joint name="joint6"/>
|
||||
<joint name="joint7"/>
|
||||
<joint name="tools_joint"/>
|
||||
<joint name="tool"/>
|
||||
<joint name="camera_holder_patron"/>
|
||||
<joint name="camera_holder_corner"/>
|
||||
<joint name="camera_corner_camera"/>
|
||||
<joint name="camera_hand_to_optical"/> -->
|
||||
<chain base_link="base_link" tip_link="patron"/>
|
||||
</group>
|
||||
<!--GROUP STATES: Purpose: Define a named state for a particular group, in terms of joint values. This is useful to define states like 'folded arms'-->
|
||||
<group_state name="home" group="iiwa_arm">
|
||||
<joint name="joint1" value="0"/>
|
||||
<joint name="joint2" value="0"/>
|
||||
<joint name="joint3" value="0"/>
|
||||
<joint name="joint4" value="0"/>
|
||||
<joint name="joint5" value="0"/>
|
||||
<joint name="joint6" value="0"/>
|
||||
<joint name="joint7" value="0"/>
|
||||
</group_state>
|
||||
<group_state name="work" group="iiwa_arm">
|
||||
<joint name="joint1" value="0"/>
|
||||
<joint name="joint2" value="0"/>
|
||||
<joint name="joint3" value="0"/>
|
||||
<joint name="joint4" value="-1.57"/>
|
||||
<joint name="joint5" value="0"/>
|
||||
<joint name="joint6" value="1.57"/>
|
||||
<joint name="joint7" value="0"/>
|
||||
</group_state>
|
||||
<!--END EFFECTOR: Purpose: Represent information about an end effector.-->
|
||||
<end_effector name="patron" parent_link="patron" group="iiwa_arm"/>
|
||||
<!--DISABLE COLLISIONS: By default it is assumed that any link of the robot could potentially come into collision with any other link in the robot. This tag disables collision checking between a specified pair of links. -->
|
||||
<disable_collisions link1="base_link" link2="link1" reason="Adjacent"/>
|
||||
<disable_collisions link1="base_link" link2="link2" reason="Never"/>
|
||||
<disable_collisions link1="base_link" link2="link3" reason="Never"/>
|
||||
<disable_collisions link1="base_link" link2="link4" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="link2" reason="Adjacent"/>
|
||||
<disable_collisions link1="link1" link2="link3" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="link4" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="link5" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="link6" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="link2" link2="link3" reason="Adjacent"/>
|
||||
<disable_collisions link1="link2" link2="link4" reason="Never"/>
|
||||
<disable_collisions link1="link2" link2="link5" reason="Never"/>
|
||||
<disable_collisions link1="link2" link2="link6" reason="Never"/>
|
||||
<disable_collisions link1="link2" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="link2" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="link3" link2="link4" reason="Adjacent"/>
|
||||
<disable_collisions link1="link3" link2="link5" reason="Never"/>
|
||||
<disable_collisions link1="link3" link2="link6" reason="Never"/>
|
||||
<disable_collisions link1="link3" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="link3" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="link4" link2="link5" reason="Adjacent"/>
|
||||
<disable_collisions link1="link4" link2="link6" reason="Never"/>
|
||||
<disable_collisions link1="link4" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="link4" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="link5" link2="link6" reason="Adjacent"/>
|
||||
<disable_collisions link1="link5" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="link5" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="link6" link2="link7" reason="Adjacent"/>
|
||||
<disable_collisions link1="link6" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="link7" link2="patron" reason="Adjacent"/>
|
||||
<disable_collisions link1="link7" link2="camera_holder" reason="Adjacent"/>
|
||||
<disable_collisions link1="link6" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="link5" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="link4" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="link3" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="link2" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="link1" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="base_link" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="camera_holder" link2="patron" reason="Adjacent"/>
|
||||
<disable_collisions link1="camera_holder" link2="camera_corner" reason="Adjacent"/>
|
||||
<disable_collisions link1="camera_corner" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link6" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link5" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link4" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link3" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link2" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="link1" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="base_link" reason="Never"/>
|
||||
<disable_collisions link1="camera_corner" link2="camera_hand" reason="Adjacent"/>
|
||||
<disable_collisions link1="camera_hand" link2="camera_holder" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="patron" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link7" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link6" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link5" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link4" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link3" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link2" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="link1" reason="Never"/>
|
||||
<disable_collisions link1="camera_hand" link2="base_link" reason="Never"/>
|
||||
</robot>
|
||||
@@ -0,0 +1,72 @@
|
||||
controller_manager:
|
||||
ros__parameters:
|
||||
update_rate: 200 # резервное значение; при запуске через launch: 1000 // fri_cycle_ms
|
||||
|
||||
joint_state_broadcaster:
|
||||
type: "joint_state_broadcaster/JointStateBroadcaster"
|
||||
|
||||
iiwa_arm_controller:
|
||||
type: "joint_trajectory_controller/JointTrajectoryController"
|
||||
|
||||
forward_position_controller:
|
||||
type: "forward_command_controller/ForwardCommandController"
|
||||
|
||||
joint_state_broadcaster:
|
||||
ros__parameters:
|
||||
interfaces:
|
||||
- position
|
||||
- velocity
|
||||
- effort
|
||||
- external_torque
|
||||
# commanded_torque и ipo_joint_position доступны только при активном v2
|
||||
# при переключении на v2 — раскомментировать:
|
||||
# - commanded_torque
|
||||
# - ipo_joint_position
|
||||
|
||||
iiwa_arm_controller:
|
||||
ros__parameters:
|
||||
joints:
|
||||
- joint1
|
||||
- joint2
|
||||
- joint3
|
||||
- joint4
|
||||
- joint5
|
||||
- joint6
|
||||
- joint7
|
||||
|
||||
command_interfaces:
|
||||
- position
|
||||
|
||||
state_interfaces:
|
||||
- position
|
||||
- velocity
|
||||
|
||||
interpolate_from_desired_state: true
|
||||
allow_partial_joints_goal: false
|
||||
allow_nonzero_velocity_at_trajectory_end: false
|
||||
|
||||
state_publish_rate: 50.0 # половина от update_rate, как в референсе lbr_fri_ros2_stack
|
||||
action_monitor_rate: 20.0
|
||||
|
||||
# constraints:
|
||||
# stopped_velocity_tolerance: 0.01 # [рад/с] — допуск скорости в конечной точке
|
||||
# goal_time: 2.0 # [с] — допуск на время достижения цели
|
||||
# joint1: { trajectory: 0.1, goal: 0.01 }
|
||||
# joint2: { trajectory: 0.1, goal: 0.01 }
|
||||
# joint3: { trajectory: 0.1, goal: 0.01 }
|
||||
# joint4: { trajectory: 0.1, goal: 0.01 }
|
||||
# joint5: { trajectory: 0.1, goal: 0.01 }
|
||||
# joint6: { trajectory: 0.1, goal: 0.01 }
|
||||
# joint7: { trajectory: 0.1, goal: 0.01 }
|
||||
|
||||
forward_position_controller:
|
||||
ros__parameters:
|
||||
joints:
|
||||
- joint1
|
||||
- joint2
|
||||
- joint3
|
||||
- joint4
|
||||
- joint5
|
||||
- joint6
|
||||
- joint7
|
||||
interface_name: position
|
||||
@@ -0,0 +1,10 @@
|
||||
# Default initial positions for iiwa7's ros2_control fake system
|
||||
|
||||
initial_positions:
|
||||
joint1: 0
|
||||
joint2: 0
|
||||
joint3: 0
|
||||
joint4: 0
|
||||
joint5: 0
|
||||
joint6: 0
|
||||
joint7: 0
|
||||
@@ -0,0 +1,81 @@
|
||||
# joint_limits.yaml allows the dynamics properties specified in the URDF to be overwritten or augmented as needed
|
||||
|
||||
# For beginners, we downscale velocity and acceleration limits.
|
||||
# You can always specify higher scaling factors (<= 1.0) in your motion requests. # Increase the values below to 1.0 to always move at maximum speed.
|
||||
default_velocity_scaling_factor: 0.1
|
||||
default_acceleration_scaling_factor: 0.1
|
||||
|
||||
# Specific joint properties can be changed with the keys [max_position, min_position, max_velocity, max_acceleration]
|
||||
# Joint limits can be turned off with [has_velocity_limits, has_acceleration_limits]
|
||||
joint_limits:
|
||||
joint1:
|
||||
has_position_limits: true
|
||||
min_position: -2.97
|
||||
max_position: 2.97
|
||||
has_velocity_limits: true
|
||||
max_velocity: 1.71
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 8.5521
|
||||
has_jerk_limits: true
|
||||
max_jerk: 85.0
|
||||
joint2:
|
||||
has_position_limits: true
|
||||
min_position: -2.09
|
||||
max_position: 2.09
|
||||
has_velocity_limits: true
|
||||
max_velocity: 1.71
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 8.5521
|
||||
has_jerk_limits: true
|
||||
max_jerk: 85.0
|
||||
joint3:
|
||||
has_position_limits: true
|
||||
min_position: -2.97
|
||||
max_position: 2.97
|
||||
has_velocity_limits: true
|
||||
max_velocity: 1.75
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 8.7266
|
||||
has_jerk_limits: true
|
||||
max_jerk: 87.0
|
||||
joint4:
|
||||
has_position_limits: true
|
||||
min_position: -2.09
|
||||
max_position: 2.09
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.27
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 11.3446
|
||||
has_jerk_limits: true
|
||||
max_jerk: 113.0
|
||||
joint5:
|
||||
has_position_limits: true
|
||||
min_position: -2.97
|
||||
max_position: 2.97
|
||||
has_velocity_limits: true
|
||||
max_velocity: 2.44
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 12.2173
|
||||
has_jerk_limits: true
|
||||
max_jerk: 122.0
|
||||
joint6:
|
||||
has_position_limits: true
|
||||
min_position: -2.09
|
||||
max_position: 2.09
|
||||
has_velocity_limits: true
|
||||
max_velocity: 3.14
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 15.7080
|
||||
has_jerk_limits: true
|
||||
max_jerk: 157.0
|
||||
joint7:
|
||||
has_position_limits: true
|
||||
min_position: -3.05
|
||||
max_position: 3.05
|
||||
has_velocity_limits: true
|
||||
max_velocity: 3.14
|
||||
has_acceleration_limits: true
|
||||
max_acceleration: 15.7080
|
||||
has_jerk_limits: true
|
||||
max_jerk: 157.0
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
iiwa_arm:
|
||||
kinematics_solver: kdl_kinematics_plugin/KDLKinematicsPlugin
|
||||
kinematics_solver_search_resolution: 0.005
|
||||
kinematics_solver_timeout: 0.005
|
||||
kinematics_solver_attempts: 3
|
||||
@@ -0,0 +1,20 @@
|
||||
# MoveIt uses this configuration for controller management
|
||||
|
||||
moveit_controller_manager: moveit_simple_controller_manager/MoveItSimpleControllerManager
|
||||
|
||||
moveit_simple_controller_manager:
|
||||
controller_names:
|
||||
- iiwa_arm_controller
|
||||
|
||||
iiwa_arm_controller:
|
||||
type: FollowJointTrajectory
|
||||
joints:
|
||||
- joint1
|
||||
- joint2
|
||||
- joint3
|
||||
- joint4
|
||||
- joint5
|
||||
- joint6
|
||||
- joint7
|
||||
action_ns: follow_joint_trajectory
|
||||
default: true
|
||||
@@ -0,0 +1,52 @@
|
||||
planning_scene_monitor_options:
|
||||
name: "planning_scene_monitor"
|
||||
robot_description: "robot_description"
|
||||
joint_state_topic: "/joint_states"
|
||||
attached_collision_object_topic: "/moveit_cpp/planning_scene_monitor"
|
||||
publish_planning_scene_topic: "/moveit_cpp/publish_planning_scene"
|
||||
monitored_planning_scene_topic: "/moveit_cpp/monitored_planning_scene"
|
||||
wait_for_initial_state_timeout: 10.0
|
||||
|
||||
planning_pipelines:
|
||||
pipeline_names: ["ompl", "pilz_industrial_motion_planner", "chomp"]
|
||||
|
||||
plan_request_params:
|
||||
planning_attempts: 1
|
||||
planning_pipeline: ompl
|
||||
max_velocity_scaling_factor: 0.5
|
||||
max_acceleration_scaling_factor: 1.0
|
||||
|
||||
ompl_rrtc:
|
||||
plan_request_params:
|
||||
planning_attempts: 1
|
||||
planning_pipeline: ompl
|
||||
planner_id: "RRTConnectkConfigDefault"
|
||||
max_velocity_scaling_factor: 1.0
|
||||
max_acceleration_scaling_factor: 1.0
|
||||
planning_time: 1.0
|
||||
|
||||
pilz_ptp:
|
||||
plan_request_params:
|
||||
planning_attempts: 1
|
||||
planning_pipeline: pilz_industrial_motion_planner
|
||||
planner_id: "PTP"
|
||||
max_velocity_scaling_factor: 1.0
|
||||
max_acceleration_scaling_factor: 1.0
|
||||
planning_time: 0.8
|
||||
|
||||
pilz_lin:
|
||||
plan_request_params:
|
||||
planning_attempts: 1
|
||||
planning_pipeline: pilz_industrial_motion_planner
|
||||
planner_id: "LIN"
|
||||
max_velocity_scaling_factor: 1.0
|
||||
max_acceleration_scaling_factor: 1.0
|
||||
planning_time: 0.8
|
||||
|
||||
chomp_planner:
|
||||
plan_request_params:
|
||||
planning_attempts: 1
|
||||
planning_pipeline: chomp
|
||||
max_velocity_scaling_factor: 1.0
|
||||
max_acceleration_scaling_factor: 1.0
|
||||
planning_time: 1.5
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user