feat: update robot setup to use ruamel.yaml for preserving YAML comments and formatting
feat: enhance run command with Docker volume for Webots asset caching and GPU detection feat: improve setup command to sequentially run sub-commands for documentation, environment, and robot parameters fix: update update command to provide clearer logging during git operations and installation refactor: enhance TUI components with better threading and logging for long-running tasks chore: improve install script with better error handling, logging, and interactive setup wizard
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# Import each command module so we can register its subparser.
|
||||||
|
# Импортируем каждый модуль команды, чтобы зарегистрировать его подпарсер.
|
||||||
from cobot.commands import delete as cmd_delete
|
from cobot.commands import delete as cmd_delete
|
||||||
from cobot.commands import docker_setup as cmd_docker_setup
|
from cobot.commands import docker_setup as cmd_docker_setup
|
||||||
from cobot.commands import doc_setup as cmd_doc_setup
|
from cobot.commands import doc_setup as cmd_doc_setup
|
||||||
@@ -12,6 +14,8 @@ from cobot.commands import update as cmd_update
|
|||||||
|
|
||||||
# Command groups shown in --help output.
|
# Command groups shown in --help output.
|
||||||
# Add new commands here when introducing other categories.
|
# Add new commands here when introducing other categories.
|
||||||
|
# Группы команд, отображаемые в --help.
|
||||||
|
# Добавляйте новые команды сюда при создании новых категорий.
|
||||||
_GROUPS = [
|
_GROUPS = [
|
||||||
("Setup", [
|
("Setup", [
|
||||||
("setup", "first-time setup: docs, build environment, robot config"),
|
("setup", "first-time setup: docs, build environment, robot config"),
|
||||||
@@ -32,6 +36,8 @@ _GROUPS = [
|
|||||||
_DESCRIPTION = "Lightweight Cobot"
|
_DESCRIPTION = "Lightweight Cobot"
|
||||||
|
|
||||||
|
|
||||||
|
# Custom --help action that prints commands grouped by category instead of a flat list.
|
||||||
|
# Кастомный обработчик --help, который выводит команды по категориям, а не одним списком.
|
||||||
class _GroupedHelpAction(argparse.Action):
|
class _GroupedHelpAction(argparse.Action):
|
||||||
def __init__(self, option_strings, dest, default=None, required=False, help=None):
|
def __init__(self, option_strings, dest, default=None, required=False, help=None):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -79,6 +85,8 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
def _register_commands(subparsers):
|
def _register_commands(subparsers):
|
||||||
|
# Each module registers its own subparser and sets args.func to its run() function.
|
||||||
|
# Каждый модуль регистрирует свой подпарсер и устанавливает args.func на свою функцию run().
|
||||||
cmd_setup.register(subparsers)
|
cmd_setup.register(subparsers)
|
||||||
cmd_local_setup.register(subparsers)
|
cmd_local_setup.register(subparsers)
|
||||||
cmd_docker_setup.register(subparsers)
|
cmd_docker_setup.register(subparsers)
|
||||||
|
|||||||
+121
-33
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -14,6 +13,8 @@ from cobot.tui import SCREEN_CSS, LogScreen, PickScreen
|
|||||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
_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:
|
def _stop_docker_containers(write) -> None:
|
||||||
write("[cyan][*][/cyan] Stopping Docker containers...")
|
write("[cyan][*][/cyan] Stopping Docker containers...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -29,6 +30,8 @@ def _stop_docker_containers(write) -> None:
|
|||||||
write(f"[green][ok][/green] Removed container: {name}")
|
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:
|
def _remove_docker_images(write) -> None:
|
||||||
write("[cyan][*][/cyan] Removing Docker images...")
|
write("[cyan][*][/cyan] Removing Docker images...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -47,14 +50,50 @@ def _remove_docker_images(write) -> None:
|
|||||||
write(f"[green][ok][/green] Removed image: {img}")
|
write(f"[green][ok][/green] Removed image: {img}")
|
||||||
|
|
||||||
|
|
||||||
def _remove_ros2(write) -> None:
|
# Remove the Docker volume that stores the Webots asset cache.
|
||||||
write("[cyan][*][/cyan] Removing ROS2 Jazzy...")
|
# Удаляем Docker volume с кэшем ассетов Webots.
|
||||||
if Path("/opt/ros/jazzy").exists():
|
def _remove_webots_volume(write) -> None:
|
||||||
subprocess.run(["sudo", "rm", "-rf", "/opt/ros/jazzy"])
|
result = subprocess.run(
|
||||||
write("[green][ok][/green] Removed /opt/ros/jazzy")
|
["docker", "volume", "inspect", "lwc-webots-cache"],
|
||||||
else:
|
capture_output=True,
|
||||||
write("[dim]ROS2 Jazzy not found, skipping.[/dim]")
|
)
|
||||||
|
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:
|
||||||
|
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"
|
source_line = "source /opt/ros/jazzy/setup.bash"
|
||||||
for rc_name in [".bashrc", ".zshrc"]:
|
for rc_name in [".bashrc", ".zshrc"]:
|
||||||
rc = Path.home() / rc_name
|
rc = Path.home() / rc_name
|
||||||
@@ -63,12 +102,28 @@ def _remove_ros2(write) -> None:
|
|||||||
content = rc.read_text()
|
content = rc.read_text()
|
||||||
if source_line not in content:
|
if source_line not in content:
|
||||||
continue
|
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 = content.replace(f"\n# ROS2 Jazzy\n{source_line}\n", "\n")
|
||||||
new_content = new_content.replace(source_line, "")
|
new_content = new_content.replace(source_line, "")
|
||||||
rc.write_text(new_content)
|
rc.write_text(new_content)
|
||||||
write(f"[green][ok][/green] Cleaned up ~/{rc_name}")
|
write(f"[green][ok][/green] Cleaned up ~/{rc_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# Remove Webots from the system via apt.
|
||||||
|
# Удаляем Webots из системы через apt.
|
||||||
|
def _remove_webots(write) -> None:
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# Uninstall the cobot CLI from the uv tool store.
|
||||||
|
# Удаляем cobot CLI из хранилища инструментов uv.
|
||||||
def _uninstall_cobot(write) -> None:
|
def _uninstall_cobot(write) -> None:
|
||||||
write("[cyan][*][/cyan] Uninstalling cobot CLI...")
|
write("[cyan][*][/cyan] Uninstalling cobot CLI...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -81,6 +136,8 @@ def _uninstall_cobot(write) -> None:
|
|||||||
write(f"[yellow]Warning:[/yellow] {result.stderr.strip() or 'could not uninstall cobot'}")
|
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:
|
def _remove_project_dir(write) -> None:
|
||||||
write(f"[cyan][*][/cyan] Removing project directory...")
|
write(f"[cyan][*][/cyan] Removing project directory...")
|
||||||
try:
|
try:
|
||||||
@@ -91,31 +148,35 @@ def _remove_project_dir(write) -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Run all deletion steps in order.
|
||||||
def _task_delete(screen: LogScreen, remove_ros: bool) -> None:
|
# 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:
|
||||||
try:
|
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:
|
if remove_ros:
|
||||||
# stop(0-20) images(20-45) ros2(45-70) cobot(70-85) dir(85-100)
|
screen.set_progress(pct, "Removing ROS2 Jazzy...")
|
||||||
screen.set_progress(0, "Stopping containers...")
|
|
||||||
_stop_docker_containers(screen.write)
|
|
||||||
screen.set_progress(20, "Removing Docker images...")
|
|
||||||
_remove_docker_images(screen.write)
|
|
||||||
screen.set_progress(45, "Removing ROS2 Jazzy...")
|
|
||||||
_remove_ros2(screen.write)
|
_remove_ros2(screen.write)
|
||||||
screen.set_progress(70, "Uninstalling cobot CLI...")
|
pct = 65
|
||||||
_uninstall_cobot(screen.write)
|
|
||||||
screen.set_progress(85, "Removing project directory...")
|
if remove_webots:
|
||||||
_remove_project_dir(screen.write)
|
screen.set_progress(pct, "Removing Webots...")
|
||||||
else:
|
_remove_webots(screen.write)
|
||||||
# stop(0-25) images(25-60) cobot(60-85) dir(85-100)
|
pct = 75
|
||||||
screen.set_progress(0, "Stopping containers...")
|
|
||||||
_stop_docker_containers(screen.write)
|
screen.set_progress(pct, "Uninstalling cobot CLI...")
|
||||||
screen.set_progress(25, "Removing Docker images...")
|
_uninstall_cobot(screen.write)
|
||||||
_remove_docker_images(screen.write)
|
|
||||||
screen.set_progress(60, "Uninstalling cobot CLI...")
|
screen.set_progress(88, "Removing project directory...")
|
||||||
_uninstall_cobot(screen.write)
|
_remove_project_dir(screen.write)
|
||||||
screen.set_progress(85, "Removing project directory...")
|
|
||||||
_remove_project_dir(screen.write)
|
|
||||||
|
|
||||||
screen.set_progress(100, "Done")
|
screen.set_progress(100, "Done")
|
||||||
screen.write("\n[green]Project fully removed.[/green]")
|
screen.write("\n[green]Project fully removed.[/green]")
|
||||||
@@ -126,7 +187,10 @@ def _task_delete(screen: LogScreen, remove_ros: bool) -> None:
|
|||||||
screen.finish(False)
|
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]):
|
class _DeleteApp(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -148,7 +212,7 @@ class _DeleteApp(App[None]):
|
|||||||
self.push_screen(
|
self.push_screen(
|
||||||
PickScreen(
|
PickScreen(
|
||||||
"ROS2 Jazzy",
|
"ROS2 Jazzy",
|
||||||
"Also remove ROS2 Jazzy (/opt/ros/jazzy)?",
|
"Also remove ROS2 Jazzy from the system?",
|
||||||
["No, keep ROS2", "Yes, remove ROS2 Jazzy"],
|
["No, keep ROS2", "Yes, remove ROS2 Jazzy"],
|
||||||
"No, keep ROS2",
|
"No, keep ROS2",
|
||||||
),
|
),
|
||||||
@@ -157,8 +221,32 @@ class _DeleteApp(App[None]):
|
|||||||
|
|
||||||
def _on_ros_choice(self, choice: Optional[str]) -> None:
|
def _on_ros_choice(self, choice: Optional[str]) -> None:
|
||||||
remove_ros = choice is not None and choice.startswith("Yes")
|
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(
|
self.push_screen(
|
||||||
LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros), show_progress=True),
|
LogScreen(
|
||||||
|
"Deleting project",
|
||||||
|
lambda s: _task_delete(s, remove_ros, remove_webots),
|
||||||
|
show_progress=True,
|
||||||
|
),
|
||||||
lambda _: self.exit(),
|
lambda _: self.exit(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ from textual.app import App
|
|||||||
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen
|
from cobot.tui import SCREEN_CSS, InputScreen, LogScreen
|
||||||
|
|
||||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
_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"
|
_DOC_DIR = _PROJECT_DIR / "doc" / "lwc-doc"
|
||||||
_IMAGE_NAME = "lwc-docs"
|
_IMAGE_NAME = "lwc-docs"
|
||||||
_CONTAINER_NAME = "lwc-docs"
|
_CONTAINER_NAME = "lwc-docs"
|
||||||
@@ -22,24 +27,36 @@ _DEFAULT_PORT = "8000"
|
|||||||
Write = Callable[[str], None]
|
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:
|
def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
|
||||||
return subprocess.run(["docker", *args], capture_output=capture, text=True)
|
return subprocess.run(["docker", *args], capture_output=capture, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Check whether the docs container is currently running.
|
||||||
|
# Проверяем, запущен ли сейчас контейнер с документацией.
|
||||||
def _is_running() -> bool:
|
def _is_running() -> bool:
|
||||||
r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
|
r = _docker("ps", "--filter", f"name={_CONTAINER_NAME}", "--format", "{{.Names}}", capture=True)
|
||||||
return _CONTAINER_NAME in r.stdout
|
return _CONTAINER_NAME in r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
# Check whether the docs Docker image has already been built.
|
||||||
|
# Проверяем, был ли уже собран Docker-образ для документации.
|
||||||
def _image_exists() -> bool:
|
def _image_exists() -> bool:
|
||||||
return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip())
|
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(
|
def _build_docs_image(
|
||||||
write: Write,
|
write: Write,
|
||||||
on_progress: Optional[Callable[[float], None]] = None,
|
on_progress: Optional[Callable[[float], None]] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
write("[cyan][*][/cyan] Building documentation image (runs once)...")
|
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"}
|
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
|
["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)],
|
||||||
@@ -62,6 +79,8 @@ def _build_docs_image(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Start the docs server. Builds the image first if it does not exist yet.
|
||||||
|
# Запускаем сервер документации. Сначала собирает образ, если он ещё не существует.
|
||||||
def _task_up(screen: LogScreen, port: str) -> None:
|
def _task_up(screen: LogScreen, port: str) -> None:
|
||||||
try:
|
try:
|
||||||
if _is_running():
|
if _is_running():
|
||||||
@@ -91,6 +110,8 @@ def _task_up(screen: LogScreen, port: str) -> None:
|
|||||||
result = _docker(
|
result = _docker(
|
||||||
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
"run", "-d", "--name", _CONTAINER_NAME, "--rm",
|
||||||
"-p", f"{port}:8000",
|
"-p", f"{port}:8000",
|
||||||
|
# Mount the docs directory so edits appear live without restarting the container.
|
||||||
|
# Монтируем директорию с документацией, чтобы изменения появлялись сразу без перезапуска.
|
||||||
"-v", f"{_DOC_DIR}:/docs",
|
"-v", f"{_DOC_DIR}:/docs",
|
||||||
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
_IMAGE_NAME, "serve", "--dev-addr=0.0.0.0:8000",
|
||||||
capture=True,
|
capture=True,
|
||||||
@@ -111,6 +132,8 @@ def _task_up(screen: LogScreen, port: str) -> None:
|
|||||||
screen.finish(False)
|
screen.finish(False)
|
||||||
|
|
||||||
|
|
||||||
|
# Stop the running docs container.
|
||||||
|
# Останавливаем работающий контейнер с документацией.
|
||||||
def _task_down(screen: LogScreen) -> None:
|
def _task_down(screen: LogScreen) -> None:
|
||||||
try:
|
try:
|
||||||
if not _is_running():
|
if not _is_running():
|
||||||
@@ -128,6 +151,8 @@ def _task_down(screen: LogScreen) -> None:
|
|||||||
screen.finish(False)
|
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:
|
def _task_rebuild(screen: LogScreen, port: str) -> None:
|
||||||
try:
|
try:
|
||||||
if _is_running():
|
if _is_running():
|
||||||
@@ -174,6 +199,8 @@ def _task_rebuild(screen: LogScreen, port: str) -> None:
|
|||||||
screen.finish(False)
|
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]):
|
class _DocApp(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -202,6 +229,8 @@ class _DocApp(App[None]):
|
|||||||
if port is None:
|
if port is None:
|
||||||
self.exit()
|
self.exit()
|
||||||
return
|
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
|
p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True),
|
LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True),
|
||||||
|
|||||||
@@ -16,12 +16,19 @@ from cobot.tui import SCREEN_CSS, InputScreen, LogScreen, PickScreen
|
|||||||
|
|
||||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
_DOCKER_DIR = _PROJECT_DIR / "docker"
|
_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_HUB_REPO = "evilfisru/lwc"
|
||||||
_DEFAULT_PREFIX = "lwc-local"
|
_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"]
|
_CONTROLLER_CHAIN = ["ros-core", "ros-base", "ros-iiwa7"]
|
||||||
_WEBOTS_CHAIN = ["ros-core", "ros-base", "ros-iiwa7-webots"]
|
_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] = {
|
_IMAGE_PARENT: dict[str, str | None] = {
|
||||||
"ros-core": None,
|
"ros-core": None,
|
||||||
"ros-base": "ros-core",
|
"ros-base": "ros-core",
|
||||||
@@ -29,11 +36,15 @@ _IMAGE_PARENT: dict[str, str | None] = {
|
|||||||
"ros-iiwa7-webots": "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"}
|
_NEEDS_PROJECT_CTX = {"ros-iiwa7", "ros-iiwa7-webots"}
|
||||||
|
|
||||||
Write = Callable[[str], None]
|
Write = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
# All the choices the user makes in the wizard are stored here before we start the actual build.
|
||||||
|
# Все выборы пользователя в мастере хранятся здесь перед началом фактической сборки.
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Config:
|
class _Config:
|
||||||
ros_version: str
|
ros_version: str
|
||||||
@@ -44,6 +55,10 @@ class _Config:
|
|||||||
hub_repo: 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(
|
def _build_image(
|
||||||
name: str,
|
name: str,
|
||||||
tag: str,
|
tag: str,
|
||||||
@@ -55,6 +70,8 @@ def _build_image(
|
|||||||
build_type: str = "release",
|
build_type: str = "release",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
write(f"[cyan][*][/cyan] Building [bold]{name}[/bold]...")
|
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"}
|
env = {**os.environ, "DOCKER_BUILDKIT": "0"}
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "build", "-t", tag, "-f", str(dockerfile),
|
"docker", "build", "-t", tag, "-f", str(dockerfile),
|
||||||
@@ -84,6 +101,8 @@ def _build_image(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Pull a Docker image from Hub and track progress by counting downloaded layers.
|
||||||
|
# Скачиваем Docker-образ с Hub и отслеживаем прогресс по количеству скачанных слоёв.
|
||||||
def _pull_image(
|
def _pull_image(
|
||||||
name: str,
|
name: str,
|
||||||
tag: str,
|
tag: str,
|
||||||
@@ -104,6 +123,8 @@ def _pull_image(
|
|||||||
s = line.rstrip()
|
s = line.rstrip()
|
||||||
if s:
|
if s:
|
||||||
write(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:
|
if "Pulling fs layer" in line or "Waiting" in line:
|
||||||
layers_total += 1
|
layers_total += 1
|
||||||
elif "Pull complete" in line or "Already exists" in line:
|
elif "Pull complete" in line or "Already exists" in line:
|
||||||
@@ -120,6 +141,10 @@ def _pull_image(
|
|||||||
return False
|
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:
|
def _task_execute(screen: LogScreen, cfg: _Config) -> None:
|
||||||
try:
|
try:
|
||||||
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
|
chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN
|
||||||
@@ -190,15 +215,23 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None:
|
|||||||
screen.finish(False)
|
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]:
|
def _discover_versions() -> List[str]:
|
||||||
if not _DOCKER_DIR.exists():
|
if not _DOCKER_DIR.exists():
|
||||||
return ["jazzy"]
|
return ["jazzy"]
|
||||||
dirs = sorted(d.name for d in _DOCKER_DIR.iterdir() if d.is_dir())
|
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:
|
if "jazzy" in dirs:
|
||||||
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
|
dirs = ["jazzy"] + [d for d in dirs if d != "jazzy"]
|
||||||
return dirs or ["jazzy"]
|
return dirs or ["jazzy"]
|
||||||
|
|
||||||
|
|
||||||
|
# Multi-step wizard that collects all build options before starting the actual image build.
|
||||||
|
# Многошаговый мастер, который собирает все параметры сборки перед запуском фактической сборки образа.
|
||||||
class _Wizard(App[None]):
|
class _Wizard(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -264,6 +297,8 @@ class _Wizard(App[None]):
|
|||||||
self.exit()
|
self.exit()
|
||||||
return
|
return
|
||||||
self._state["build_type"] = v or "release"
|
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":
|
if self._state["source"] == "pull":
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO),
|
InputScreen("Step 5 of 5", "Docker Hub repository:", _DEFAULT_HUB_REPO),
|
||||||
@@ -290,6 +325,8 @@ class _Wizard(App[None]):
|
|||||||
self._finish()
|
self._finish()
|
||||||
|
|
||||||
def _finish(self) -> None:
|
def _finish(self) -> None:
|
||||||
|
# Assemble the config and hand it off to the log screen that does the actual work.
|
||||||
|
# Собираем конфиг и передаём его экрану лога, который выполняет фактическую работу.
|
||||||
s = self._state
|
s = self._state
|
||||||
cfg = _Config(
|
cfg = _Config(
|
||||||
ros_version=s["ros_version"],
|
ros_version=s["ros_version"],
|
||||||
|
|||||||
+167
-15
@@ -17,12 +17,19 @@ from cobot.commands.docker_setup import run as _docker_setup
|
|||||||
|
|
||||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
# Paths used for the ROS2 apt repository signing key and sources list.
|
||||||
|
# Пути для ключа подписи apt-репозитория ROS2 и файла sources list.
|
||||||
_ROS_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg")
|
_ROS_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg")
|
||||||
_ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list")
|
_ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list")
|
||||||
_ROS_KEY_URL = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key"
|
_ROS_KEY_URL = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key"
|
||||||
|
|
||||||
|
# Suppress apt interactive prompts such as "restart services?".
|
||||||
|
# Подавляем интерактивные запросы apt, например "перезапустить службы?".
|
||||||
_APT_ENV = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
|
_APT_ENV = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
|
||||||
|
|
||||||
|
|
||||||
|
# Check whether we are running on Ubuntu 24.04, which is required for ROS2 Jazzy.
|
||||||
|
# Проверяем, запущены ли мы на Ubuntu 24.04, которая требуется для ROS2 Jazzy.
|
||||||
def _detect_ubuntu_2404() -> bool:
|
def _detect_ubuntu_2404() -> bool:
|
||||||
path = Path("/etc/os-release")
|
path = Path("/etc/os-release")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -35,6 +42,8 @@ def _detect_ubuntu_2404() -> bool:
|
|||||||
return info.get("ID") == "ubuntu" and info.get("VERSION_ID") == "24.04"
|
return info.get("ID") == "ubuntu" and info.get("VERSION_ID") == "24.04"
|
||||||
|
|
||||||
|
|
||||||
|
# Check whether ROS2 Jazzy is already installed by looking for its directory.
|
||||||
|
# Проверяем, установлен ли ROS2 Jazzy, проверяя наличие его директории.
|
||||||
def _detect_ros2_jazzy() -> bool:
|
def _detect_ros2_jazzy() -> bool:
|
||||||
return Path("/opt/ros/jazzy").is_dir()
|
return Path("/opt/ros/jazzy").is_dir()
|
||||||
|
|
||||||
@@ -42,6 +51,8 @@ def _detect_ros2_jazzy() -> bool:
|
|||||||
Write = Callable[[str], None]
|
Write = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
# Run a command and capture output. Print it to the log only if the command fails.
|
||||||
|
# Запускаем команду и перехватываем вывод. Выводим в лог только если команда завершилась с ошибкой.
|
||||||
def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = None, cwd=None) -> None:
|
def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = None, cwd=None) -> None:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd, capture_output=True, text=True,
|
cmd, capture_output=True, text=True,
|
||||||
@@ -55,6 +66,8 @@ def _run_quiet(cmd: List[str], write: Write | None = None, env: dict | None = No
|
|||||||
raise RuntimeError(f"Command failed: {cmd[0]}")
|
raise RuntimeError(f"Command failed: {cmd[0]}")
|
||||||
|
|
||||||
|
|
||||||
|
# Run a command and stream every output line to the log in real time.
|
||||||
|
# Запускаем команду и транслируем каждую строку вывода в лог в реальном времени.
|
||||||
def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) -> None:
|
def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) -> None:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -80,6 +93,10 @@ def _run_apt_with_progress(
|
|||||||
env: dict | None = None,
|
env: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100)."""
|
"""Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100)."""
|
||||||
|
# APT::Status-Fd makes apt write progress lines to a pipe descriptor instead of stdout.
|
||||||
|
# We read that pipe in a background thread so we can update the progress bar live.
|
||||||
|
# APT::Status-Fd заставляет apt писать строки прогресса в дескриптор канала, а не в stdout.
|
||||||
|
# Читаем этот канал в фоновом потоке, чтобы обновлять прогресс-бар в реальном времени.
|
||||||
r_fd, w_fd = os.pipe()
|
r_fd, w_fd = os.pipe()
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
@@ -91,6 +108,8 @@ def _run_apt_with_progress(
|
|||||||
pass_fds=(w_fd,),
|
pass_fds=(w_fd,),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
|
# Close the write end in the parent process so the reader thread gets EOF when apt exits.
|
||||||
|
# Закрываем пишущий конец в родительском процессе, чтобы читающий поток получил EOF при выходе apt.
|
||||||
os.close(w_fd)
|
os.close(w_fd)
|
||||||
|
|
||||||
def _read_status() -> None:
|
def _read_status() -> None:
|
||||||
@@ -116,10 +135,8 @@ def _run_apt_with_progress(
|
|||||||
raise RuntimeError(f"Command failed: {cmd[0]}")
|
raise RuntimeError(f"Command failed: {cmd[0]}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Make sure the system has a UTF-8 locale, which ROS2 requires to work correctly.
|
||||||
# Installation steps
|
# Убеждаемся, что в системе есть локаль UTF-8, которая требуется ROS2 для корректной работы.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _setup_locale(write: Write) -> None:
|
def _setup_locale(write: Write) -> None:
|
||||||
write("[cyan][*][/cyan] Checking locale...")
|
write("[cyan][*][/cyan] Checking locale...")
|
||||||
if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout:
|
if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout:
|
||||||
@@ -133,6 +150,8 @@ def _setup_locale(write: Write) -> None:
|
|||||||
write("[green][ok][/green] Locale configured")
|
write("[green][ok][/green] Locale configured")
|
||||||
|
|
||||||
|
|
||||||
|
# Add the official ROS2 apt repository and its signing key so we can install ROS2 packages.
|
||||||
|
# Добавляем официальный apt-репозиторий ROS2 и его ключ подписи, чтобы можно было установить пакеты ROS2.
|
||||||
def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
|
def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
|
||||||
def _prog(p: float) -> None:
|
def _prog(p: float) -> None:
|
||||||
if on_progress:
|
if on_progress:
|
||||||
@@ -159,6 +178,8 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]]
|
|||||||
try:
|
try:
|
||||||
urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path)
|
urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path)
|
||||||
_prog(60)
|
_prog(60)
|
||||||
|
# Convert the ASCII-armored key to binary GPG format that apt understands.
|
||||||
|
# Конвертируем ключ из ASCII-armor формата в бинарный GPG, который понимает apt.
|
||||||
_run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path])
|
_run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path])
|
||||||
finally:
|
finally:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
@@ -192,6 +213,8 @@ def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]]
|
|||||||
_prog(100)
|
_prog(100)
|
||||||
|
|
||||||
|
|
||||||
|
# Install the full ROS2 Jazzy Desktop and the developer tools (colcon, rosdep, etc.).
|
||||||
|
# Устанавливаем полный ROS2 Jazzy Desktop и инструменты разработчика (colcon, rosdep и т.д.).
|
||||||
def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
|
def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None:
|
||||||
write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...")
|
write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...")
|
||||||
_run_apt_with_progress(
|
_run_apt_with_progress(
|
||||||
@@ -203,6 +226,8 @@ def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], No
|
|||||||
write("[green][ok][/green] ROS2 Jazzy Desktop installed")
|
write("[green][ok][/green] ROS2 Jazzy Desktop installed")
|
||||||
|
|
||||||
|
|
||||||
|
# Install colcon if it is not already available. It is used to build the project packages.
|
||||||
|
# Устанавливаем colcon если он ещё не доступен. Он используется для сборки пакетов проекта.
|
||||||
def _install_colcon(write: Write) -> None:
|
def _install_colcon(write: Write) -> None:
|
||||||
if shutil.which("colcon"):
|
if shutil.which("colcon"):
|
||||||
write("[green][ok][/green] colcon already available")
|
write("[green][ok][/green] colcon already available")
|
||||||
@@ -216,6 +241,10 @@ def _install_colcon(write: Write) -> None:
|
|||||||
write("[green][ok][/green] colcon installed")
|
write("[green][ok][/green] colcon installed")
|
||||||
|
|
||||||
|
|
||||||
|
# Add "source /opt/ros/jazzy/setup.bash" to the user's shell config file.
|
||||||
|
# This makes ROS2 commands available in every new terminal session.
|
||||||
|
# Добавляем "source /opt/ros/jazzy/setup.bash" в конфиг оболочки пользователя.
|
||||||
|
# Это делает команды ROS2 доступными в каждой новой сессии терминала.
|
||||||
def _setup_shell_rc(write: Write) -> None:
|
def _setup_shell_rc(write: Write) -> None:
|
||||||
shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name
|
shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name
|
||||||
rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc")
|
rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc")
|
||||||
@@ -228,10 +257,8 @@ def _setup_shell_rc(write: Write) -> None:
|
|||||||
write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}")
|
write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Full ROS2 Jazzy installation split into 5 clearly visible steps with individual progress ranges.
|
||||||
# Background tasks (run inside LogScreen worker)
|
# Полная установка ROS2 Jazzy, разбитая на 5 наглядных шагов с отдельными диапазонами прогресса.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _task_install_jazzy(screen: LogScreen) -> None:
|
def _task_install_jazzy(screen: LogScreen) -> None:
|
||||||
try:
|
try:
|
||||||
# Step 1 — locale (0 → 5 %)
|
# Step 1 — locale (0 → 5 %)
|
||||||
@@ -275,6 +302,8 @@ def _task_install_jazzy(screen: LogScreen) -> None:
|
|||||||
screen.finish(False)
|
screen.finish(False)
|
||||||
|
|
||||||
|
|
||||||
|
# Build all project packages with colcon and track progress by counting finished packages.
|
||||||
|
# Собираем все пакеты проекта с помощью colcon и отслеживаем прогресс по количеству завершённых пакетов.
|
||||||
def _task_build(screen: LogScreen) -> None:
|
def _task_build(screen: LogScreen) -> None:
|
||||||
try:
|
try:
|
||||||
if not shutil.which("colcon"):
|
if not shutil.which("colcon"):
|
||||||
@@ -283,7 +312,8 @@ def _task_build(screen: LogScreen) -> None:
|
|||||||
screen.finish(False)
|
screen.finish(False)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Count packages so we can show X/total progress
|
# Count packages first so we can show X/total in the progress label.
|
||||||
|
# Сначала считаем пакеты, чтобы показывать X/всего в подписи прогресса.
|
||||||
list_result = subprocess.run(
|
list_result = subprocess.run(
|
||||||
["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR,
|
["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR,
|
||||||
)
|
)
|
||||||
@@ -296,6 +326,8 @@ def _task_build(screen: LogScreen) -> None:
|
|||||||
def _track(line: str) -> None:
|
def _track(line: str) -> None:
|
||||||
nonlocal built
|
nonlocal built
|
||||||
screen.write(line)
|
screen.write(line)
|
||||||
|
# colcon prints "Finished <<<" or "Failed <<<" when each package is done.
|
||||||
|
# colcon печатает "Finished <<<" или "Failed <<<" когда каждый пакет готов.
|
||||||
if "Finished <<<" in line or "Failed <<<" in line:
|
if "Finished <<<" in line or "Failed <<<" in line:
|
||||||
built += 1
|
built += 1
|
||||||
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
|
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
|
||||||
@@ -310,10 +342,109 @@ def _task_build(screen: LogScreen) -> None:
|
|||||||
screen.finish(False)
|
screen.finish(False)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Webots version that matches the Docker images used in this project.
|
||||||
# Textual apps
|
# Версия Webots, соответствующая Docker-образам используемым в этом проекте.
|
||||||
# ---------------------------------------------------------------------------
|
_WEBOTS_VERSION = "2025a"
|
||||||
|
_WEBOTS_DEB_URL = (
|
||||||
|
f"https://github.com/cyberbotics/webots/releases/download/"
|
||||||
|
f"R{_WEBOTS_VERSION}/webots_{_WEBOTS_VERSION}_amd64.deb"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def webots_installed() -> bool:
|
||||||
|
"""Return True if Webots is available on PATH."""
|
||||||
|
return shutil.which("webots") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# Download the Webots .deb from GitHub and install it with apt.
|
||||||
|
# Progress: download (0-65%), apt install (65-100%).
|
||||||
|
# Скачиваем .deb Webots с GitHub и устанавливаем через apt.
|
||||||
|
# Прогресс: скачивание (0-65%), установка apt (65-100%).
|
||||||
|
def _task_install_webots(screen: LogScreen) -> None:
|
||||||
|
try:
|
||||||
|
screen.write(f"[bold]Installing Webots {_WEBOTS_VERSION}[/bold]\n")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
deb_path = Path(tmp) / f"webots_{_WEBOTS_VERSION}_amd64.deb"
|
||||||
|
|
||||||
|
screen.write(f"[dim]{_WEBOTS_DEB_URL}[/dim]\n")
|
||||||
|
screen.set_progress(0, "Downloading Webots...")
|
||||||
|
|
||||||
|
# urllib calls this hook periodically with how many bytes have been downloaded.
|
||||||
|
# urllib вызывает этот обратный вызов периодически с количеством скачанных байт.
|
||||||
|
def _hook(blocks: int, block_size: int, total: int) -> None:
|
||||||
|
if total > 0:
|
||||||
|
pct = min(blocks * block_size / total * 65, 65)
|
||||||
|
mb = blocks * block_size / 1_048_576
|
||||||
|
total_mb = total / 1_048_576
|
||||||
|
screen.set_progress(pct, f"Downloading... {mb:.0f} / {total_mb:.0f} MB")
|
||||||
|
|
||||||
|
urllib.request.urlretrieve(_WEBOTS_DEB_URL, deb_path, _hook)
|
||||||
|
screen.write("[green]Download complete.[/green]")
|
||||||
|
screen.set_progress(65, "Installing package...")
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["sudo", "apt-get", "install", "-y", str(deb_path)],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
for line in proc.stdout:
|
||||||
|
s = line.rstrip()
|
||||||
|
if s:
|
||||||
|
screen.write(s)
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
screen.write("\n[red]Installation failed.[/red]")
|
||||||
|
screen.finish(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
screen.set_progress(100, "Done")
|
||||||
|
screen.write("\n[green]Webots installed successfully.[/green]")
|
||||||
|
screen.finish(True)
|
||||||
|
except Exception as exc:
|
||||||
|
screen.write(f"\n[red]Error:[/red] {exc}")
|
||||||
|
screen.finish(False)
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal single-question app used between steps where a full wizard is not needed.
|
||||||
|
# Минимальное приложение с одним вопросом, используемое между шагами где полный мастер не нужен.
|
||||||
|
class _Ask(App[Optional[str]]):
|
||||||
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
|
def __init__(self, step: str, question: str, options: list, 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, default: str) -> Optional[str]:
|
||||||
|
return _Ask(step, question, options, default).run()
|
||||||
|
|
||||||
|
|
||||||
|
# Public app used by run.py to install Webots before launching locally.
|
||||||
|
# Публичное приложение, используемое run.py для установки Webots перед локальным запуском.
|
||||||
|
class WebotsInstallApp(App[bool]):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Ask the user if they want to install ROS2 Jazzy, then run the installer if they say yes.
|
||||||
|
# Спрашиваем пользователя хочет ли он установить ROS2 Jazzy, и запускаем установщик если да.
|
||||||
class _InstallJazzyApp(App[None]):
|
class _InstallJazzyApp(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -338,6 +469,8 @@ class _InstallJazzyApp(App[None]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Run the colcon build without asking any questions - used when ROS2 is already installed.
|
||||||
|
# Запускаем сборку colcon без лишних вопросов - используется когда ROS2 уже установлен.
|
||||||
class _BuildApp(App[None]):
|
class _BuildApp(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -348,6 +481,8 @@ class _BuildApp(App[None]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Shown when the OS is not Ubuntu 24.04. Offers to fall back to docker-setup instead.
|
||||||
|
# Показывается когда ОС не Ubuntu 24.04. Предлагает перейти к docker-setup вместо этого.
|
||||||
class _DockerPromptApp(App[bool]):
|
class _DockerPromptApp(App[bool]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -363,9 +498,6 @@ class _DockerPromptApp(App[bool]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CLI registration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||||
p = subparsers.add_parser(
|
p = subparsers.add_parser(
|
||||||
@@ -376,13 +508,33 @@ def register(subparsers: argparse._SubParsersAction) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def run(args: argparse.Namespace) -> None:
|
def run(args: argparse.Namespace) -> None:
|
||||||
|
# If this is not Ubuntu 24.04 we cannot install ROS2 Jazzy natively - offer Docker instead.
|
||||||
|
# Если это не Ubuntu 24.04 мы не можем установить ROS2 Jazzy нативно - предлагаем Docker вместо этого.
|
||||||
if not _detect_ubuntu_2404():
|
if not _detect_ubuntu_2404():
|
||||||
if _DockerPromptApp().run():
|
if _DockerPromptApp().run():
|
||||||
_docker_setup(args)
|
_docker_setup(args)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ROS2 not installed yet - show the installer.
|
||||||
|
# After installation the user must restart the terminal, so we stop here.
|
||||||
|
# ROS2 ещё не установлен - показываем установщик.
|
||||||
|
# После установки пользователь должен перезапустить терминал, поэтому останавливаемся здесь.
|
||||||
if not _detect_ros2_jazzy():
|
if not _detect_ros2_jazzy():
|
||||||
_InstallJazzyApp().run()
|
_InstallJazzyApp().run()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ROS2 is ready - build the project.
|
||||||
|
# ROS2 готов - собираем проект.
|
||||||
_BuildApp().run()
|
_BuildApp().run()
|
||||||
|
|
||||||
|
# Ask about Webots only after a successful build, and only if it is not already installed.
|
||||||
|
# Спрашиваем про Webots только после успешной сборки и только если он ещё не установлен.
|
||||||
|
if not webots_installed():
|
||||||
|
v = _ask(
|
||||||
|
"Optional: Webots",
|
||||||
|
f"Install Webots {_WEBOTS_VERSION} simulator? (can also be done later via cobot run)",
|
||||||
|
[f"Yes, install Webots {_WEBOTS_VERSION}", "No, skip"],
|
||||||
|
"No, skip",
|
||||||
|
)
|
||||||
|
if v and v.startswith("Yes"):
|
||||||
|
WebotsInstallApp().run()
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ from cobot.tui import SCREEN_CSS, InputScreen, PickScreen
|
|||||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
_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 = YAML()
|
||||||
_yaml.preserve_quotes = True
|
_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
|
@dataclass
|
||||||
class _Field:
|
class _Field:
|
||||||
key: str # dot-separated path within the block, e.g. "webots.world"
|
key: str # dot-separated path within the block, e.g. "webots.world"
|
||||||
@@ -33,6 +39,8 @@ class _Field:
|
|||||||
return self.key.split(".")[-1]
|
return self.key.split(".")[-1]
|
||||||
|
|
||||||
|
|
||||||
|
# A group of related fields shown together under one "Configure X?" question.
|
||||||
|
# Группа связанных полей, показываемая вместе под одним вопросом "Настроить X?".
|
||||||
@dataclass
|
@dataclass
|
||||||
class _Block:
|
class _Block:
|
||||||
yaml_key: str # top-level key in cobot-setting.yaml
|
yaml_key: str # top-level key in cobot-setting.yaml
|
||||||
@@ -40,6 +48,8 @@ class _Block:
|
|||||||
fields: List[_Field]
|
fields: List[_Field]
|
||||||
|
|
||||||
|
|
||||||
|
# All configuration blocks. Each block maps to a top-level key in cobot-setting.yaml.
|
||||||
|
# Все блоки конфигурации. Каждый блок соответствует ключу верхнего уровня в cobot-setting.yaml.
|
||||||
_BLOCKS: List[_Block] = [
|
_BLOCKS: List[_Block] = [
|
||||||
_Block(
|
_Block(
|
||||||
yaml_key="foxglove",
|
yaml_key="foxglove",
|
||||||
@@ -101,17 +111,20 @@ _BLOCKS: List[_Block] = [
|
|||||||
_Field("active_controller", "Active ROS controller:", "jtc",
|
_Field("active_controller", "Active ROS controller:", "jtc",
|
||||||
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
|
note="jtc = JointTrajectoryController (MoveIt), forward = ForwardCommandController",
|
||||||
options=["jtc", "forward"]),
|
options=["jtc", "forward"]),
|
||||||
_Field("joint_position_tau", "Position EMA filter τ (s):", "0.04",
|
_Field("joint_position_tau", "Position EMA filter tau (s):", "0.04",
|
||||||
note="Smooths position commands before sending to FRI"),
|
note="Smooths position commands before sending to FRI"),
|
||||||
_Field("joint_velocity_tau", "Velocity EMA filter τ (s):", "0.01",
|
_Field("joint_velocity_tau", "Velocity EMA filter tau (s):", "0.01",
|
||||||
note="Removes spikes from finite-difference velocity estimation"),
|
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:
|
def _coerce(value: str, original: Any) -> Any:
|
||||||
"""Try to preserve the original YAML scalar type."""
|
|
||||||
if isinstance(original, bool):
|
if isinstance(original, bool):
|
||||||
return value.lower() == "true"
|
return value.lower() == "true"
|
||||||
if isinstance(original, int):
|
if isinstance(original, int):
|
||||||
@@ -127,6 +140,8 @@ def _coerce(value: str, original: Any) -> Any:
|
|||||||
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:
|
def _get_nested(mapping: Any, path: str) -> Any:
|
||||||
keys = path.split(".")
|
keys = path.split(".")
|
||||||
cur = mapping
|
cur = mapping
|
||||||
@@ -137,6 +152,8 @@ def _get_nested(mapping: Any, path: str) -> Any:
|
|||||||
return cur
|
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:
|
def _set_nested(mapping: Any, path: str, value: Any) -> None:
|
||||||
keys = path.split(".")
|
keys = path.split(".")
|
||||||
cur = mapping
|
cur = mapping
|
||||||
@@ -146,6 +163,8 @@ def _set_nested(mapping: Any, path: str, value: Any) -> None:
|
|||||||
cur[keys[-1]] = _coerce(value, original)
|
cur[keys[-1]] = _coerce(value, original)
|
||||||
|
|
||||||
|
|
||||||
|
# Shown after all blocks have been configured to confirm the file was saved.
|
||||||
|
# Показывается после настройки всех блоков для подтверждения сохранения файла.
|
||||||
class _SavedScreen(Screen[None]):
|
class _SavedScreen(Screen[None]):
|
||||||
BINDINGS = [Binding("enter,escape", "close", "Close")]
|
BINDINGS = [Binding("enter,escape", "close", "Close")]
|
||||||
|
|
||||||
@@ -159,13 +178,17 @@ class _SavedScreen(Screen[None]):
|
|||||||
self.dismiss(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]):
|
class _Wizard(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
def __init__(self, data: Any):
|
def __init__(self, data: Any):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._data = data
|
self._data = data
|
||||||
self._blocks = list(_BLOCKS) # copy so we can pop
|
self._blocks = list(_BLOCKS)
|
||||||
self._block_idx = 0
|
self._block_idx = 0
|
||||||
self._field_idx = 0
|
self._field_idx = 0
|
||||||
self._current_block: Optional[_Block] = None
|
self._current_block: Optional[_Block] = None
|
||||||
@@ -174,9 +197,10 @@ class _Wizard(App[None]):
|
|||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self._next_block()
|
self._next_block()
|
||||||
|
|
||||||
|
|
||||||
def _next_block(self) -> None:
|
def _next_block(self) -> None:
|
||||||
if self._block_idx >= len(self._blocks):
|
if self._block_idx >= len(self._blocks):
|
||||||
|
# All blocks done - save and show the confirmation screen.
|
||||||
|
# Все блоки пройдены - сохраняем и показываем экран подтверждения.
|
||||||
_save_config(self._data)
|
_save_config(self._data)
|
||||||
self.push_screen(_SavedScreen(), lambda _: self.exit())
|
self.push_screen(_SavedScreen(), lambda _: self.exit())
|
||||||
return
|
return
|
||||||
@@ -204,9 +228,10 @@ class _Wizard(App[None]):
|
|||||||
self._field_idx = 0
|
self._field_idx = 0
|
||||||
self._next_field()
|
self._next_field()
|
||||||
else:
|
else:
|
||||||
|
# Skip all fields in this block and jump to the next block.
|
||||||
|
# Пропускаем все поля этого блока и переходим к следующему.
|
||||||
self._next_block()
|
self._next_block()
|
||||||
|
|
||||||
|
|
||||||
def _next_field(self) -> None:
|
def _next_field(self) -> None:
|
||||||
if not self._pending_fields:
|
if not self._pending_fields:
|
||||||
self._next_block()
|
self._next_block()
|
||||||
@@ -220,7 +245,7 @@ class _Wizard(App[None]):
|
|||||||
field_num = self._field_idx
|
field_num = self._field_idx
|
||||||
total_fields = len(block.fields)
|
total_fields = len(block.fields)
|
||||||
|
|
||||||
step = f"Block {block_num} of {total_blocks} · Field {field_num} of {total_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
|
# Resolve current value from loaded YAML as the pre-filled default
|
||||||
yaml_val = _get_nested(self._data[block.yaml_key], f.key)
|
yaml_val = _get_nested(self._data[block.yaml_key], f.key)
|
||||||
@@ -241,16 +266,21 @@ class _Wizard(App[None]):
|
|||||||
return
|
return
|
||||||
block = self._current_block
|
block = self._current_block
|
||||||
_set_nested(self._data[block.yaml_key], f.key, v)
|
_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._pending_fields.pop(0)
|
||||||
self._next_field()
|
self._next_field()
|
||||||
|
|
||||||
|
|
||||||
|
# Load the config file preserving all comments and key order.
|
||||||
|
# Загружаем конфиг-файл, сохраняя все комментарии и порядок ключей.
|
||||||
def _load_config() -> Any:
|
def _load_config() -> Any:
|
||||||
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
|
with open(_CONFIG_PATH, "r", encoding="utf-8") as fh:
|
||||||
return _yaml.load(fh)
|
return _yaml.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
# Write the modified config back to disk preserving comments and formatting.
|
||||||
|
# Записываем изменённый конфиг обратно на диск, сохраняя комментарии и форматирование.
|
||||||
def _save_config(data: Any) -> None:
|
def _save_config(data: Any) -> None:
|
||||||
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
|
with open(_CONFIG_PATH, "w", encoding="utf-8") as fh:
|
||||||
_yaml.dump(data, fh)
|
_yaml.dump(data, fh)
|
||||||
|
|||||||
+98
-35
@@ -11,17 +11,32 @@ from typing import Callable, List, Optional
|
|||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
|
||||||
from cobot.tui import SCREEN_CSS, LogScreen, PickScreen, RunScreen
|
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
|
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
||||||
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
_CONFIG_PATH = _PROJECT_DIR / "cobot-setting.yaml"
|
||||||
_INSTALL_DIR = _PROJECT_DIR / "install"
|
_INSTALL_DIR = _PROJECT_DIR / "install"
|
||||||
_JAZZY_DIR = Path("/opt/ros/jazzy")
|
_JAZZY_DIR = Path("/opt/ros/jazzy")
|
||||||
|
|
||||||
|
# Path where the config file is mounted inside the Docker container.
|
||||||
|
# Путь по которому конфиг-файл монтируется внутри Docker-контейнера.
|
||||||
_CONFIG_IN_CONTAINER = "/ros2_ws/cobot-setting.yaml"
|
_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_CONTROLLER = "lwc-controller"
|
||||||
_CONTAINER_WEBOTS = "lwc-webots"
|
_CONTAINER_WEBOTS = "lwc-webots"
|
||||||
|
|
||||||
# Candidates checked in order; for controller the webots image is a valid fallback
|
# 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 = [
|
_CONTROLLER_IMAGES = [
|
||||||
"lwc-local:ros-iiwa7-jazzy",
|
"lwc-local:ros-iiwa7-jazzy",
|
||||||
"evilfisru/lwc:iiwa-jazzy",
|
"evilfisru/lwc:iiwa-jazzy",
|
||||||
@@ -37,10 +52,10 @@ _WEBOTS_IMAGES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# A minimal app that asks one question and exits immediately with the chosen value.
|
||||||
# Small utilities
|
# We need a full App because Textual screens cannot run outside one.
|
||||||
# ---------------------------------------------------------------------------
|
# Минимальное приложение, которое задаёт один вопрос и сразу выходит с выбранным значением.
|
||||||
|
# Нам нужен полноценный App, потому что экраны Textual не могут работать вне него.
|
||||||
class _Ask(App[Optional[str]]):
|
class _Ask(App[Optional[str]]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -59,9 +74,13 @@ class _Ask(App[Optional[str]]):
|
|||||||
|
|
||||||
|
|
||||||
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
||||||
|
# Returns None when the user pressed Escape to cancel.
|
||||||
|
# Возвращает None когда пользователь нажал Escape для отмены.
|
||||||
return _Ask(step, question, options, default).run()
|
return _Ask(step, question, options, default).run()
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
def _detect_gpu() -> str:
|
||||||
if shutil.which("nvidia-smi"):
|
if shutil.which("nvidia-smi"):
|
||||||
if subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0:
|
if subprocess.run(["nvidia-smi"], capture_output=True).returncode == 0:
|
||||||
@@ -71,6 +90,8 @@ def _detect_gpu() -> str:
|
|||||||
return "software"
|
return "software"
|
||||||
|
|
||||||
|
|
||||||
|
# List all Docker images currently available on this machine.
|
||||||
|
# Получаем список всех Docker-образов доступных на этой машине.
|
||||||
def _docker_images() -> set:
|
def _docker_images() -> set:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||||
@@ -79,6 +100,8 @@ def _docker_images() -> set:
|
|||||||
return set(r.stdout.strip().splitlines())
|
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]:
|
def _find_image(candidates: List[str]) -> Optional[str]:
|
||||||
available = _docker_images()
|
available = _docker_images()
|
||||||
for img in candidates:
|
for img in candidates:
|
||||||
@@ -87,14 +110,16 @@ def _find_image(candidates: List[str]) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Build the ROS2 project locally with colcon. Used when launching in local mode
|
||||||
# Build project (colcon build --mixin release)
|
# and the install/ directory does not exist yet.
|
||||||
# ---------------------------------------------------------------------------
|
# Собираем ROS2-проект локально с помощью colcon. Используется при запуске в локальном режиме,
|
||||||
|
# если директория install/ ещё не существует.
|
||||||
def _task_build(screen: LogScreen) -> None:
|
def _task_build(screen: LogScreen) -> None:
|
||||||
try:
|
try:
|
||||||
screen.write("[bold]Building project with colcon[/bold]\n")
|
screen.write("[bold]Building project with colcon[/bold]\n")
|
||||||
|
|
||||||
|
# Count packages first so we can show X/total progress.
|
||||||
|
# Сначала считаем пакеты, чтобы показывать X/всего в прогрессе.
|
||||||
list_proc = subprocess.run(
|
list_proc = subprocess.run(
|
||||||
["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"],
|
["bash", "-c", f"source {_JAZZY_DIR}/setup.bash && colcon list"],
|
||||||
capture_output=True, text=True, cwd=_PROJECT_DIR,
|
capture_output=True, text=True, cwd=_PROJECT_DIR,
|
||||||
@@ -113,6 +138,8 @@ def _task_build(screen: LogScreen) -> None:
|
|||||||
s = line.rstrip()
|
s = line.rstrip()
|
||||||
if s:
|
if s:
|
||||||
screen.write(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:
|
if "Finished <<<" in line or "Failed <<<" in line:
|
||||||
built += 1
|
built += 1
|
||||||
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
|
screen.set_progress(built / total * 100, f"{built} / {total} packages done")
|
||||||
@@ -141,10 +168,10 @@ class _BuildApp(App[bool]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Start the ROS2 launch file directly on this machine without Docker.
|
||||||
# Local launch task
|
# 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:
|
def _task_run_local(screen: RunScreen, mode: str) -> None:
|
||||||
config = str(_CONFIG_PATH)
|
config = str(_CONFIG_PATH)
|
||||||
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}"
|
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={config}"
|
||||||
@@ -168,6 +195,8 @@ def _task_run_local(screen: RunScreen, mode: str) -> None:
|
|||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
)
|
)
|
||||||
screen.set_proc(proc)
|
screen.set_proc(proc)
|
||||||
|
# Kill the entire process group so all child processes (nodes) are terminated together.
|
||||||
|
# Убиваем всю группу процессов, чтобы все дочерние процессы (узлы) завершились вместе.
|
||||||
screen.set_kill_fn(lambda: os.killpg(os.getpgid(proc.pid), signal.SIGTERM))
|
screen.set_kill_fn(lambda: os.killpg(os.getpgid(proc.pid), signal.SIGTERM))
|
||||||
|
|
||||||
for line in proc.stdout:
|
for line in proc.stdout:
|
||||||
@@ -179,18 +208,22 @@ def _task_run_local(screen: RunScreen, mode: str) -> None:
|
|||||||
screen.finish(stopped=screen._stopped)
|
screen.finish(stopped=screen._stopped)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Start the ROS2 launch file inside a Docker container.
|
||||||
# Docker launch task
|
# 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:
|
def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None:
|
||||||
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER
|
container = _CONTAINER_WEBOTS if mode == "webots" else _CONTAINER_CONTROLLER
|
||||||
|
|
||||||
ros_cmd = f"ros2 launch iiwa_bringup iiwa.launch.py setting:={_CONFIG_IN_CONTAINER}"
|
ros_cmd = (
|
||||||
|
"source /ros2_ws/install/setup.bash && "
|
||||||
|
f"ros2 launch iiwa_bringup iiwa.launch.py setting:={_CONFIG_IN_CONTAINER}"
|
||||||
|
)
|
||||||
if mode == "webots":
|
if mode == "webots":
|
||||||
ros_cmd += " simulate:=1"
|
ros_cmd += " simulate:=1"
|
||||||
|
|
||||||
# Remove stale container with the same name
|
# Remove any stale container with the same name left from a previous run.
|
||||||
|
# Удаляем устаревший контейнер с таким же именем, оставшийся от предыдущего запуска.
|
||||||
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -201,11 +234,16 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
|||||||
]
|
]
|
||||||
|
|
||||||
if mode == "webots":
|
if mode == "webots":
|
||||||
|
# Allow the container to open windows on the host display.
|
||||||
|
# Разрешаем контейнеру открывать окна на дисплее хоста.
|
||||||
subprocess.run(["xhost", "+local:docker"], capture_output=True)
|
subprocess.run(["xhost", "+local:docker"], capture_output=True)
|
||||||
cmd += [
|
cmd += [
|
||||||
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
|
"-e", f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
|
||||||
"-e", "QT_X11_NO_MITSHM=1",
|
"-e", "QT_X11_NO_MITSHM=1",
|
||||||
"-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw",
|
"-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":
|
if gpu == "nvidia":
|
||||||
cmd += [
|
cmd += [
|
||||||
@@ -214,13 +252,19 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
|||||||
"-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
|
"-e", "NVIDIA_DRIVER_CAPABILITIES=graphics,utility,compute",
|
||||||
]
|
]
|
||||||
elif gpu == "mesa":
|
elif gpu == "mesa":
|
||||||
|
# Pass through the DRI device for Intel/AMD hardware acceleration.
|
||||||
|
# Пробрасываем DRI-устройство для аппаратного ускорения Intel/AMD.
|
||||||
cmd += ["--device", "/dev/dri"]
|
cmd += ["--device", "/dev/dri"]
|
||||||
else:
|
else:
|
||||||
|
# No GPU found - fall back to software rendering via llvmpipe.
|
||||||
|
# GPU не найден - используем программный рендеринг через llvmpipe.
|
||||||
cmd += [
|
cmd += [
|
||||||
"-e", "LIBGL_ALWAYS_SOFTWARE=1",
|
"-e", "LIBGL_ALWAYS_SOFTWARE=1",
|
||||||
"-e", "GALLIUM_DRIVER=llvmpipe",
|
"-e", "GALLIUM_DRIVER=llvmpipe",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Mount the config file so the container uses our local cobot-setting.yaml.
|
||||||
|
# Монтируем конфиг-файл, чтобы контейнер использовал наш локальный cobot-setting.yaml.
|
||||||
if _CONFIG_PATH.exists():
|
if _CONFIG_PATH.exists():
|
||||||
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"]
|
cmd += ["-v", f"{_CONFIG_PATH}:{_CONFIG_IN_CONTAINER}:ro"]
|
||||||
|
|
||||||
@@ -244,6 +288,11 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
screen.set_proc(proc)
|
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:
|
for line in proc.stdout:
|
||||||
s = line.rstrip()
|
s = line.rstrip()
|
||||||
@@ -254,10 +303,8 @@ def _task_run_docker(screen: RunScreen, image: str, mode: str, gpu: str) -> None
|
|||||||
screen.finish(stopped=screen._stopped)
|
screen.finish(stopped=screen._stopped)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Wraps a RunScreen in an App so it can be launched with .run().
|
||||||
# RunApp wrapper
|
# Оборачивает RunScreen в App, чтобы его можно было запустить через .run().
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class _RunApp(App[None]):
|
class _RunApp(App[None]):
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
@@ -270,10 +317,10 @@ class _RunApp(App[None]):
|
|||||||
self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit())
|
self.push_screen(RunScreen(self._title, self._run_fn), lambda _: self.exit())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Guide the user through launching locally - asks what to run, checks prerequisites,
|
||||||
# Local flow
|
# installs Webots and builds the project if needed, then launches.
|
||||||
# ---------------------------------------------------------------------------
|
# Ведёт пользователя через локальный запуск - спрашивает что запустить, проверяет
|
||||||
|
# предварительные условия, устанавливает Webots и собирает проект при необходимости, затем запускает.
|
||||||
def _local_flow(args: argparse.Namespace) -> None:
|
def _local_flow(args: argparse.Namespace) -> None:
|
||||||
mode_v = _ask(
|
mode_v = _ask(
|
||||||
"Run local",
|
"Run local",
|
||||||
@@ -285,6 +332,20 @@ def _local_flow(args: argparse.Namespace) -> None:
|
|||||||
return
|
return
|
||||||
mode = "webots" if mode_v == "Webots simulator" else "controller"
|
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
|
# Check ROS2 Jazzy
|
||||||
if not _JAZZY_DIR.is_dir():
|
if not _JAZZY_DIR.is_dir():
|
||||||
v = _ask(
|
v = _ask(
|
||||||
@@ -316,10 +377,10 @@ def _local_flow(args: argparse.Namespace) -> None:
|
|||||||
_RunApp(f"Running {label} — local", lambda s: _task_run_local(s, mode)).run()
|
_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,
|
||||||
# Docker flow
|
# detects the GPU for Webots, and launches.
|
||||||
# ---------------------------------------------------------------------------
|
# Ведёт пользователя через запуск в Docker - спрашивает что запустить, ищет подходящий образ,
|
||||||
|
# определяет GPU для Webots и запускает.
|
||||||
def _docker_flow(args: argparse.Namespace) -> None:
|
def _docker_flow(args: argparse.Namespace) -> None:
|
||||||
if not shutil.which("docker"):
|
if not shutil.which("docker"):
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
@@ -340,6 +401,8 @@ def _docker_flow(args: argparse.Namespace) -> None:
|
|||||||
image = _find_image(candidates)
|
image = _find_image(candidates)
|
||||||
|
|
||||||
if image is None:
|
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"
|
what = "Webots" if mode == "webots" else "controller or Webots"
|
||||||
v = _ask(
|
v = _ask(
|
||||||
"No image found",
|
"No image found",
|
||||||
@@ -352,6 +415,8 @@ def _docker_flow(args: argparse.Namespace) -> None:
|
|||||||
_docker_setup(args)
|
_docker_setup(args)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only detect GPU for Webots - the controller does not need a display.
|
||||||
|
# GPU определяем только для Webots - контроллеру дисплей не нужен.
|
||||||
gpu = _detect_gpu() if mode == "webots" else "software"
|
gpu = _detect_gpu() if mode == "webots" else "software"
|
||||||
|
|
||||||
label = "Webots simulator" if mode == "webots" else "Controller"
|
label = "Webots simulator" if mode == "webots" else "Controller"
|
||||||
@@ -361,10 +426,6 @@ def _docker_flow(args: argparse.Namespace) -> None:
|
|||||||
).run()
|
).run()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CLI
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def register(subparsers: argparse._SubParsersAction) -> None:
|
def register(subparsers: argparse._SubParsersAction) -> None:
|
||||||
p = subparsers.add_parser(
|
p = subparsers.add_parser(
|
||||||
"run",
|
"run",
|
||||||
@@ -388,6 +449,8 @@ def run(args: argparse.Namespace) -> None:
|
|||||||
elif mode == "docker":
|
elif mode == "docker":
|
||||||
_docker_flow(args)
|
_docker_flow(args)
|
||||||
else:
|
else:
|
||||||
|
# No mode given - ask the user how they want to run.
|
||||||
|
# Режим не указан - спрашиваем пользователя как он хочет запустить.
|
||||||
v = _ask(
|
v = _ask(
|
||||||
"Run",
|
"Run",
|
||||||
"How do you want to run the project?",
|
"How do you want to run the project?",
|
||||||
|
|||||||
+14
-4
@@ -3,6 +3,8 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from textual.app import App
|
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.doc_setup import run as _doc_setup
|
||||||
from cobot.commands.docker_setup import run as _docker_setup
|
from cobot.commands.docker_setup import run as _docker_setup
|
||||||
from cobot.commands.local_setup import run as _local_setup
|
from cobot.commands.local_setup import run as _local_setup
|
||||||
@@ -10,8 +12,11 @@ from cobot.commands.robot_setup import run as _robot_setup
|
|||||||
from cobot.tui import SCREEN_CSS, PickScreen
|
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]]):
|
class _Ask(App[Optional[str]]):
|
||||||
"""Single-question picker that exits immediately with the chosen value."""
|
|
||||||
CSS = SCREEN_CSS
|
CSS = SCREEN_CSS
|
||||||
|
|
||||||
def __init__(self, step: str, question: str, options: List[str], default: str):
|
def __init__(self, step: str, question: str, options: List[str], default: str):
|
||||||
@@ -29,6 +34,8 @@ class _Ask(App[Optional[str]]):
|
|||||||
|
|
||||||
|
|
||||||
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]:
|
||||||
|
# Returns None if the user pressed Escape to cancel the whole wizard.
|
||||||
|
# Возвращает None если пользователь нажал Escape для отмены всего мастера.
|
||||||
return _Ask(step, question, options, default).run()
|
return _Ask(step, question, options, default).run()
|
||||||
|
|
||||||
|
|
||||||
@@ -38,14 +45,16 @@ def register(subparsers):
|
|||||||
|
|
||||||
|
|
||||||
def run(args: argparse.Namespace) -> None:
|
def run(args: argparse.Namespace) -> None:
|
||||||
# Step 1 — documentation
|
# Step 1 - documentation server.
|
||||||
|
# Шаг 1 - сервер документации.
|
||||||
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
|
v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes")
|
||||||
if v is None:
|
if v is None:
|
||||||
return
|
return
|
||||||
if v == "Yes":
|
if v == "Yes":
|
||||||
_doc_setup(args)
|
_doc_setup(args)
|
||||||
|
|
||||||
# Step 2 — build environment
|
# Step 2 - build environment: local ROS2 or Docker.
|
||||||
|
# Шаг 2 - среда сборки: локальный ROS2 или Docker.
|
||||||
v = _ask(
|
v = _ask(
|
||||||
"Step 2 of 3",
|
"Step 2 of 3",
|
||||||
"How do you want to set up the build environment?",
|
"How do you want to set up the build environment?",
|
||||||
@@ -62,7 +71,8 @@ def run(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
_docker_setup(args)
|
_docker_setup(args)
|
||||||
|
|
||||||
# Step 3 — robot parameters
|
# Step 3 - robot parameters in cobot-setting.yaml.
|
||||||
|
# Шаг 3 - параметры робота в cobot-setting.yaml.
|
||||||
v = _ask(
|
v = _ask(
|
||||||
"Step 3 of 3",
|
"Step 3 of 3",
|
||||||
"Configure robot parameters (cobot-setting.yaml)?",
|
"Configure robot parameters (cobot-setting.yaml)?",
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ from cobot.tui import SCREEN_CSS, LogScreen
|
|||||||
_PROJECT_DIR = Path(__file__).parent.parent.parent
|
_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:
|
def _task_update(screen: LogScreen) -> None:
|
||||||
try:
|
try:
|
||||||
# Current branch
|
# Find out which branch we are on so we can fetch and pull the right one.
|
||||||
|
# Определяем на какой ветке мы находимся, чтобы делать fetch и pull нужной ветки.
|
||||||
branch = subprocess.check_output(
|
branch = subprocess.check_output(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||||
cwd=_PROJECT_DIR, text=True,
|
cwd=_PROJECT_DIR, text=True,
|
||||||
@@ -33,7 +38,8 @@ def _task_update(screen: LogScreen) -> None:
|
|||||||
return
|
return
|
||||||
screen.set_progress(30)
|
screen.set_progress(30)
|
||||||
|
|
||||||
# Check how many commits behind
|
# Count how many commits the remote is ahead of us.
|
||||||
|
# Считаем сколько коммитов нас опережает удалённый репозиторий.
|
||||||
behind = subprocess.check_output(
|
behind = subprocess.check_output(
|
||||||
["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
|
["git", "rev-list", f"HEAD..origin/{branch}", "--count"],
|
||||||
cwd=_PROJECT_DIR, text=True,
|
cwd=_PROJECT_DIR, text=True,
|
||||||
@@ -45,7 +51,8 @@ def _task_update(screen: LogScreen) -> None:
|
|||||||
screen.finish(True)
|
screen.finish(True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show incoming commits
|
# Show which commits are coming in so the user knows what changed.
|
||||||
|
# Показываем какие коммиты приходят, чтобы пользователь знал что изменилось.
|
||||||
screen.write(f"\n[bold]{behind} new commit(s):[/bold]")
|
screen.write(f"\n[bold]{behind} new commit(s):[/bold]")
|
||||||
log_lines = subprocess.check_output(
|
log_lines = subprocess.check_output(
|
||||||
["git", "log", f"HEAD..origin/{branch}", "--oneline"],
|
["git", "log", f"HEAD..origin/{branch}", "--oneline"],
|
||||||
@@ -71,6 +78,8 @@ def _task_update(screen: LogScreen) -> None:
|
|||||||
screen.set_progress(80)
|
screen.set_progress(80)
|
||||||
|
|
||||||
# Reinstall (80 → 100 %)
|
# 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.set_progress(80, "Reinstalling cobot CLI...")
|
||||||
screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
|
screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...")
|
||||||
reinstall = subprocess.run(
|
reinstall = subprocess.run(
|
||||||
|
|||||||
+53
-12
@@ -8,6 +8,8 @@ from textual.binding import Binding
|
|||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, Static
|
from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, Static
|
||||||
|
|
||||||
|
# Shared CSS applied to every screen in the app.
|
||||||
|
# Общий CSS, применяемый ко всем экранам приложения.
|
||||||
SCREEN_CSS = """
|
SCREEN_CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
padding: 2 4;
|
padding: 2 4;
|
||||||
@@ -77,6 +79,10 @@ RunScreen #hint {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 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]]):
|
class PickScreen(Screen[Optional[str]]):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("enter", "submit", "Confirm", priority=True),
|
Binding("enter", "submit", "Confirm", priority=True),
|
||||||
@@ -98,6 +104,8 @@ class PickScreen(Screen[Optional[str]]):
|
|||||||
yield Static(self._note, id="note")
|
yield Static(self._note, id="note")
|
||||||
with RadioSet(id="choices"):
|
with RadioSet(id="choices"):
|
||||||
for opt in self._options:
|
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 RadioButton(opt, value=(opt == self._default))
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
@@ -115,9 +123,15 @@ class PickScreen(Screen[Optional[str]]):
|
|||||||
self.dismiss(str(btn.label) if btn else self._default)
|
self.dismiss(str(btn.label) if btn else self._default)
|
||||||
|
|
||||||
def action_abort(self) -> None:
|
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)
|
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]]):
|
class InputScreen(Screen[Optional[str]]):
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("enter", "submit", "Confirm", priority=True),
|
Binding("enter", "submit", "Confirm", priority=True),
|
||||||
@@ -154,9 +168,13 @@ class InputScreen(Screen[Optional[str]]):
|
|||||||
self.app.exit(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]):
|
class LogScreen(Screen[bool]):
|
||||||
"""Streams task output into a scrollable log; press Enter to close when done."""
|
|
||||||
|
|
||||||
BINDINGS = [Binding("enter,escape", "close", "Close", show=False)]
|
BINDINGS = [Binding("enter,escape", "close", "Close", show=False)]
|
||||||
|
|
||||||
def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = False):
|
def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = False):
|
||||||
@@ -179,10 +197,13 @@ class LogScreen(Screen[bool]):
|
|||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.query_one(RichLog).focus()
|
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)
|
self.app.run_worker(lambda: self._run_fn(self), thread=True)
|
||||||
|
|
||||||
def set_progress(self, pct: float, label: str = "") -> None:
|
def set_progress(self, pct: float, label: str = "") -> None:
|
||||||
"""Thread-safe: update the progress bar and optional step label."""
|
# Thread-safe - this is called from the worker thread, not the UI thread.
|
||||||
|
# Потокобезопасно - вызывается из рабочего потока, а не из потока интерфейса.
|
||||||
if self._show_progress:
|
if self._show_progress:
|
||||||
self.app.call_from_thread(self._do_set_progress, pct, label)
|
self.app.call_from_thread(self._do_set_progress, pct, label)
|
||||||
|
|
||||||
@@ -192,14 +213,16 @@ class LogScreen(Screen[bool]):
|
|||||||
self.query_one("#step-label", Static).update(label)
|
self.query_one("#step-label", Static).update(label)
|
||||||
|
|
||||||
def write(self, line: str) -> None:
|
def write(self, line: str) -> None:
|
||||||
"""Thread-safe: append a line to the log."""
|
# Thread-safe - append a line to the log from a worker thread.
|
||||||
|
# Потокобезопасно - добавляет строку в лог из рабочего потока.
|
||||||
self.app.call_from_thread(self._append, line)
|
self.app.call_from_thread(self._append, line)
|
||||||
|
|
||||||
def _append(self, line: str) -> None:
|
def _append(self, line: str) -> None:
|
||||||
self.query_one(RichLog).write(line)
|
self.query_one(RichLog).write(line)
|
||||||
|
|
||||||
def finish(self, success: bool) -> None:
|
def finish(self, success: bool) -> None:
|
||||||
"""Thread-safe: mark task done and prompt the user to close."""
|
# Thread-safe - called by the task when it is done to show the close hint.
|
||||||
|
# Потокобезопасно - вызывается задачей по завершении, чтобы показать подсказку о закрытии.
|
||||||
self.app.call_from_thread(self._do_finish, success)
|
self.app.call_from_thread(self._do_finish, success)
|
||||||
|
|
||||||
def _do_finish(self, success: bool) -> None:
|
def _do_finish(self, success: bool) -> None:
|
||||||
@@ -214,13 +237,17 @@ class LogScreen(Screen[bool]):
|
|||||||
self.query_one("#hint", Static).update(msg)
|
self.query_one("#hint", Static).update(msg)
|
||||||
|
|
||||||
def action_close(self) -> None:
|
def action_close(self) -> None:
|
||||||
|
# Only allow closing after the task has finished, not while it is still running.
|
||||||
|
# Разрешаем закрытие только после завершения задачи, а не во время её работы.
|
||||||
if self._finished:
|
if self._finished:
|
||||||
self.dismiss(self._success)
|
self.dismiss(self._success)
|
||||||
|
|
||||||
|
|
||||||
|
# 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]):
|
class RunScreen(Screen[None]):
|
||||||
"""Streams a long-running process. S/Enter/Escape stops or closes."""
|
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("s", "stop_close", "Stop", show=True, priority=True),
|
Binding("s", "stop_close", "Stop", show=True, priority=True),
|
||||||
Binding("enter", "stop_close", "Close", show=False),
|
Binding("enter", "stop_close", "Close", show=False),
|
||||||
@@ -231,8 +258,8 @@ class RunScreen(Screen[None]):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._title = title
|
self._title = title
|
||||||
self._run_fn = task
|
self._run_fn = task
|
||||||
self._proc = None # set via set_proc()
|
self._proc = None # the subprocess, set via set_proc()
|
||||||
self._kill_fn = None # optional custom kill callable
|
self._kill_fn = None # optional custom kill callable, set via set_kill_fn()
|
||||||
self._finished = False
|
self._finished = False
|
||||||
self._stopped = False
|
self._stopped = False
|
||||||
|
|
||||||
@@ -245,23 +272,33 @@ class RunScreen(Screen[None]):
|
|||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
self.query_one(RichLog).focus()
|
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)
|
self.app.run_worker(lambda: self._run_fn(self), thread=True)
|
||||||
|
|
||||||
def set_proc(self, proc) -> None:
|
def set_proc(self, proc) -> None:
|
||||||
"""Register the running subprocess so Stop can terminate it."""
|
# Register the subprocess so the Stop button knows what to terminate.
|
||||||
|
# Регистрируем subprocess, чтобы кнопка Stop знала что завершать.
|
||||||
self._proc = proc
|
self._proc = proc
|
||||||
|
|
||||||
def set_kill_fn(self, fn: Callable) -> None:
|
def set_kill_fn(self, fn: Callable) -> None:
|
||||||
"""Override the default terminate() with a custom kill function."""
|
# 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
|
self._kill_fn = fn
|
||||||
|
|
||||||
def write(self, line: str) -> None:
|
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)
|
self.app.call_from_thread(self._append, line)
|
||||||
|
|
||||||
def _append(self, line: str) -> None:
|
def _append(self, line: str) -> None:
|
||||||
self.query_one(RichLog).write(line)
|
self.query_one(RichLog).write(line)
|
||||||
|
|
||||||
def finish(self, stopped: bool = False) -> None:
|
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)
|
self.app.call_from_thread(self._do_finish, stopped)
|
||||||
|
|
||||||
def _do_finish(self, stopped: bool) -> None:
|
def _do_finish(self, stopped: bool) -> None:
|
||||||
@@ -274,6 +311,10 @@ class RunScreen(Screen[None]):
|
|||||||
self.query_one("#hint", Static).update(msg)
|
self.query_one("#hint", Static).update(msg)
|
||||||
|
|
||||||
def action_stop_close(self) -> None:
|
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:
|
if self._finished:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
return
|
return
|
||||||
@@ -288,4 +329,4 @@ class RunScreen(Screen[None]):
|
|||||||
self._proc.terminate()
|
self._proc.terminate()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.write("\n[yellow]Stopping process...[/yellow]")
|
self._append("\n[yellow]Stopping process...[/yellow]")
|
||||||
|
|||||||
+70
-14
@@ -1,9 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Stop the script immediately if any command exits with an error.
|
||||||
|
# Останавливаем скрипт сразу, если какая-либо команда завершилась с ошибкой.
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Disable uv user/project config files so the environment is always clean.
|
||||||
|
# Отключаем пользовательские и проектные конфиги uv, чтобы среда всегда была чистой.
|
||||||
export UV_NO_CONFIG=1
|
export UV_NO_CONFIG=1
|
||||||
|
|
||||||
|
# Terminal color codes for nicer output.
|
||||||
|
# Коды цветов для красивого вывода в терминал.
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[0;33m'
|
YELLOW='\033[0;33m'
|
||||||
@@ -11,19 +17,30 @@ CYAN='\033[0;36m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
BOLD='\033[1m'
|
BOLD='\033[1m'
|
||||||
|
|
||||||
|
# Python version that the cobot CLI requires.
|
||||||
|
# Версия Python, которая нужна для работы cobot CLI.
|
||||||
PYTHON_VERSION="3.11"
|
PYTHON_VERSION="3.11"
|
||||||
REPO_URL="https://gitverse.ru/daniel-robotics/lightweight-cobot.git"
|
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}"
|
INSTALL_DIR="${COBOT_INSTALL_DIR:-$HOME/.lwc}"
|
||||||
|
|
||||||
# Определяем интерактивный режим: при запуске через curl | bash stdin не является терминалом
|
# 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
|
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_info() { echo -e "${CYAN}[*]${NC} $1"; }
|
||||||
log_success() { echo -e "${GREEN}[ok]${NC} $1"; }
|
log_success() { echo -e "${GREEN}[ok]${NC} $1"; }
|
||||||
log_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
log_warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
||||||
log_error() { echo -e "${RED}[err]${NC} $1"; exit 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() {
|
run_quiet() {
|
||||||
local _log
|
local _log
|
||||||
_log="$(mktemp /tmp/lwc-cmd.XXXXXX.log)"
|
_log="$(mktemp /tmp/lwc-cmd.XXXXXX.log)"
|
||||||
@@ -45,11 +62,14 @@ print_banner() {
|
|||||||
echo -e "${NC}"
|
echo -e "${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Detect the current OS and package manager so later steps know how to install things.
|
||||||
|
# Определяем текущую ОС и пакетный менеджер, чтобы следующие шаги знали как устанавливать пакеты.
|
||||||
detect_os() {
|
detect_os() {
|
||||||
case "$(uname -s)" in
|
case "$(uname -s)" in
|
||||||
Linux*)
|
Linux*)
|
||||||
OS="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"
|
if command -v apt-get &>/dev/null; then PKG_MANAGER="apt"
|
||||||
elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf"
|
elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf"
|
||||||
elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman"
|
elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman"
|
||||||
@@ -62,7 +82,8 @@ detect_os() {
|
|||||||
log_info "OS: $OS"
|
log_info "OS: $OS"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Устанавливает системные пакеты через найденный пакетный менеджер
|
# Install system packages using whatever package manager was detected above.
|
||||||
|
# Устанавливает системные пакеты через найденный пакетный менеджер.
|
||||||
pkg_install() {
|
pkg_install() {
|
||||||
case "$PKG_MANAGER" in
|
case "$PKG_MANAGER" in
|
||||||
apt) run_quiet sudo apt-get update -qq && run_quiet sudo apt-get install -y --no-install-recommends "$@" ;;
|
apt) run_quiet sudo apt-get update -qq && run_quiet sudo apt-get install -y --no-install-recommends "$@" ;;
|
||||||
@@ -72,6 +93,8 @@ pkg_install() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if git is installed and install it if not.
|
||||||
|
# Проверяем наличие git и устанавливаем его если он отсутствует.
|
||||||
check_git() {
|
check_git() {
|
||||||
log_info "Checking git..."
|
log_info "Checking git..."
|
||||||
if command -v git &>/dev/null; then
|
if command -v git &>/dev/null; then
|
||||||
@@ -87,6 +110,8 @@ check_git() {
|
|||||||
log_success "git $(git --version | awk '{print $3}') installed"
|
log_success "git $(git --version | awk '{print $3}') installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if Docker is installed and install it if not.
|
||||||
|
# Проверяем наличие Docker и устанавливаем его если он отсутствует.
|
||||||
check_docker() {
|
check_docker() {
|
||||||
log_info "Checking Docker..."
|
log_info "Checking Docker..."
|
||||||
if command -v docker &>/dev/null; then
|
if command -v docker &>/dev/null; then
|
||||||
@@ -94,8 +119,10 @@ check_docker() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
log_info "Installing Docker..."
|
log_info "Installing Docker..."
|
||||||
# Скачиваем установщик во временный файл, а не запускаем через pipe —
|
# 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
|
local _installer
|
||||||
_installer="$(mktemp /tmp/lwc-docker.XXXXXX.sh)"
|
_installer="$(mktemp /tmp/lwc-docker.XXXXXX.sh)"
|
||||||
if ! curl -fsSL https://get.docker.com -o "$_installer"; then
|
if ! curl -fsSL https://get.docker.com -o "$_installer"; then
|
||||||
@@ -106,16 +133,20 @@ check_docker() {
|
|||||||
rm -f "$_installer"
|
rm -f "$_installer"
|
||||||
command -v docker &>/dev/null || log_error "Docker not found after installation"
|
command -v docker &>/dev/null || log_error "Docker not found after installation"
|
||||||
log_success "Docker $(docker --version | awk '{print $3}' | tr -d ',') installed"
|
log_success "Docker $(docker --version | awk '{print $3}' | tr -d ',') installed"
|
||||||
# Добавляем пользователя в группу docker, чтобы не требовался sudo
|
# 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
|
if [ "$(id -u)" -ne 0 ] && command -v usermod &>/dev/null; then
|
||||||
sudo usermod -aG docker "$USER"
|
sudo usermod -aG docker "$USER"
|
||||||
log_warn "Added $USER to docker group — re-login to apply"
|
log_warn "Added $USER to docker group — re-login to apply"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Install the uv package manager. We need it to create isolated Python environments.
|
||||||
|
# Устанавливаем пакетный менеджер uv. Он нужен для создания изолированных Python-окружений.
|
||||||
install_uv() {
|
install_uv() {
|
||||||
log_info "Checking uv..."
|
log_info "Checking uv..."
|
||||||
# uv может быть установлен в ~/.local/bin или ~/.cargo/bin, проверяем оба
|
# uv can end up in ~/.local/bin or ~/.cargo/bin depending on how it was installed.
|
||||||
|
# uv может оказаться в ~/.local/bin или ~/.cargo/bin в зависимости от способа установки.
|
||||||
UV_CMD=""
|
UV_CMD=""
|
||||||
for candidate in "uv" "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
for candidate in "uv" "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||||
if command -v "$candidate" &>/dev/null 2>&1; then
|
if command -v "$candidate" &>/dev/null 2>&1; then
|
||||||
@@ -128,7 +159,10 @@ install_uv() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
log_info "Installing uv..."
|
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
|
local _log _installer
|
||||||
_log="$(mktemp /tmp/lwc-uv.XXXXXX.log)"
|
_log="$(mktemp /tmp/lwc-uv.XXXXXX.log)"
|
||||||
_installer="$(mktemp /tmp/lwc-uv-installer.XXXXXX.sh)"
|
_installer="$(mktemp /tmp/lwc-uv-installer.XXXXXX.sh)"
|
||||||
@@ -153,6 +187,8 @@ install_uv() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check that the required Python version is available via uv and install it if not.
|
||||||
|
# Проверяем наличие нужной версии Python через uv и устанавливаем её если она отсутствует.
|
||||||
check_python() {
|
check_python() {
|
||||||
log_info "Checking Python $PYTHON_VERSION..."
|
log_info "Checking Python $PYTHON_VERSION..."
|
||||||
local py_path
|
local py_path
|
||||||
@@ -168,6 +204,14 @@ check_python() {
|
|||||||
log_success "Python $PYTHON_VERSION installed"
|
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() {
|
resolve_install_dir() {
|
||||||
local script_dir
|
local script_dir
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || pwd)"
|
||||||
@@ -196,8 +240,11 @@ resolve_install_dir() {
|
|||||||
|
|
||||||
log_info "Cloning repo into $INSTALL_DIR..."
|
log_info "Cloning repo into $INSTALL_DIR..."
|
||||||
mkdir -p "$(dirname "$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
|
local attempt=1
|
||||||
while [ $attempt -le 5 ]; do
|
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
|
if git clone --depth 1 --branch dev "$REPO_URL" "$INSTALL_DIR" </dev/null; then
|
||||||
log_success "Repo cloned to $INSTALL_DIR"
|
log_success "Repo cloned to $INSTALL_DIR"
|
||||||
return
|
return
|
||||||
@@ -209,15 +256,20 @@ resolve_install_dir() {
|
|||||||
log_error "Failed to clone repo after 5 attempts"
|
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() {
|
install_cobot() {
|
||||||
log_info "Installing cobot CLI..."
|
log_info "Installing cobot CLI..."
|
||||||
cd "$INSTALL_DIR"
|
cd "$INSTALL_DIR"
|
||||||
# uv tool install создаёт изолированное окружение и кладёт бинарник cobot в ~/.local/bin
|
# 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 . \
|
run_quiet "$UV_CMD" tool install --python "$PYTHON_VERSION" --editable . \
|
||||||
|| log_error "Failed to install cobot"
|
|| log_error "Failed to install cobot"
|
||||||
log_success "cobot installed"
|
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() {
|
setup_path() {
|
||||||
local bin_dir="$HOME/.local/bin"
|
local bin_dir="$HOME/.local/bin"
|
||||||
local shell_rc
|
local shell_rc
|
||||||
@@ -226,11 +278,11 @@ setup_path() {
|
|||||||
*/fish) shell_rc="$HOME/.config/fish/config.fish" ;;
|
*/fish) shell_rc="$HOME/.config/fish/config.fish" ;;
|
||||||
*) shell_rc="$HOME/.bashrc" ;;
|
*) shell_rc="$HOME/.bashrc" ;;
|
||||||
esac
|
esac
|
||||||
# Добавляем ~/.local/bin в PATH, если его там ещё нет
|
# Also export into the current session right now so cobot setup works below without a re-login.
|
||||||
|
# Также экспортируем прямо сейчас, чтобы cobot setup заработал ниже без перезахода.
|
||||||
if [[ ":$PATH:" != *":$bin_dir:"* ]]; then
|
if [[ ":$PATH:" != *":$bin_dir:"* ]]; then
|
||||||
echo "" >> "$shell_rc"
|
echo "" >> "$shell_rc"
|
||||||
echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_rc"
|
echo "export PATH=\"$bin_dir:\$PATH\"" >> "$shell_rc"
|
||||||
# Обновляем PATH внутри скрипта — нужно чтобы cobot setup сработал ниже
|
|
||||||
export PATH="$bin_dir:$PATH"
|
export PATH="$bin_dir:$PATH"
|
||||||
fi
|
fi
|
||||||
command -v cobot &>/dev/null && log_success "cobot -> $(command -v cobot)"
|
command -v cobot &>/dev/null && log_success "cobot -> $(command -v cobot)"
|
||||||
@@ -247,8 +299,11 @@ print_success() {
|
|||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Launch the interactive setup wizard right after installation.
|
||||||
|
# Запускаем интерактивный мастер настройки сразу после установки.
|
||||||
run_setup() {
|
run_setup() {
|
||||||
# Явная проверка — PATH мог не подхватиться если uv положил бинарник в нестандартное место
|
# Explicit check because PATH might not include ~/.local/bin yet in this shell session.
|
||||||
|
# Явная проверка, потому что PATH может ещё не включать ~/.local/bin в этой сессии.
|
||||||
if ! command -v cobot &>/dev/null; then
|
if ! command -v cobot &>/dev/null; then
|
||||||
log_warn "cobot not found on PATH, trying full path..."
|
log_warn "cobot not found on PATH, trying full path..."
|
||||||
local cobot_bin="$HOME/.local/bin/cobot"
|
local cobot_bin="$HOME/.local/bin/cobot"
|
||||||
@@ -262,7 +317,8 @@ run_setup() {
|
|||||||
log_info "Running cobot setup..."
|
log_info "Running cobot setup..."
|
||||||
cobot setup
|
cobot setup
|
||||||
|
|
||||||
# curl | bash: дочерний процесс не может обновить терминал родителя
|
# When run via curl | bash the child process cannot update the parent terminal's environment.
|
||||||
|
# При запуске через curl | bash дочерний процесс не может обновить окружение родительского терминала.
|
||||||
if [ "$IS_INTERACTIVE" = false ]; then
|
if [ "$IS_INTERACTIVE" = false ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " To apply PATH changes in this terminal, run:"
|
echo " To apply PATH changes in this terminal, run:"
|
||||||
|
|||||||
Reference in New Issue
Block a user