diff --git a/cobot/commands/local_setup.py b/cobot/commands/local_setup.py index 5a20a84..6c1e0e1 100644 --- a/cobot/commands/local_setup.py +++ b/cobot/commands/local_setup.py @@ -4,9 +4,6 @@ import argparse import os import shutil import subprocess -import tempfile -import threading -import urllib.request from pathlib import Path from typing import Callable, List, Optional @@ -23,42 +20,13 @@ _PROJECT_DIR = Path(__file__).parent.parent.parent # Название дистрибутива ROS2, который устанавливает этот скрипт. _DISTRO = "jazzy" -# Path where apt expects the ROS2 GPG signing key to be stored. -# Путь, по которому apt ожидает найти GPG-ключ подписи ROS2. -_ROS_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg") - -# Path to the apt sources file that points to the ROS2 package repository. -# Путь к файлу источников apt, указывающему на репозиторий пакетов ROS2. -_ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list") - -# Path created by "rosdep init" to mark that rosdep has already been initialized. -# Путь, создаваемый "rosdep init" для отметки того, что rosdep уже инициализирован. -_ROSDEP_SOURCES = Path("/etc/ros/rosdep/sources.list.d/20-default.list") - -# Webots simulator version and the direct .deb download URL for amd64. -# Версия симулятора Webots и прямая ссылка для скачивания .deb для amd64. +# Webots simulator version targeted by this installer. +# Версия симулятора Webots, устанавливаемая этим скриптом. _WEBOTS_VERSION = "2025a" -_WEBOTS_DEB_URL = ( - f"https://github.com/cyberbotics/webots/releases/download/" - f"R{_WEBOTS_VERSION}/webots_{_WEBOTS_VERSION}_amd64.deb" -) -# Environment variables passed to apt to suppress interactive prompts (e.g. "restart services?"). -# Переменные окружения для apt, подавляющие интерактивные запросы (например, "перезапустить сервисы?"). -_APT_ENV = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"} - -# Extra apt options: short per-connection and per-transfer timeouts plus retry count. -# Forces IPv4 because many VMs have broken IPv6 routing that causes silent hangs. -# Дополнительные опции apt: короткие таймауты на соединение и передачу данных, плюс число повторов. -# Принудительно используем IPv4, так как в VM часто сломана маршрутизация IPv6, что вызывает зависания. -_APT_OPTS = [ - "-o", "Acquire::http::ConnectTimeout=15", - "-o", "Acquire::https::ConnectTimeout=15", - "-o", "Acquire::http::Timeout=30", - "-o", "Acquire::https::Timeout=30", - "-o", "Acquire::Retries=2", - "-o", "Acquire::ForceIPv4=true", -] +# Directory that contains the shell scripts used by this command. +# Директория с shell-скриптами, используемыми этой командой. +_SCRIPTS_DIR = _PROJECT_DIR / "scripts" # Type alias for the callable used to write a line to the TUI log screen. # Псевдоним типа для функции записи строки в лог TUI. @@ -165,354 +133,58 @@ def _run_logged( raise RuntimeError(f"Command failed: {cmd[0]}") -def _run_apt_with_progress( - cmd: List[str], - write: Write, - on_progress: Callable[[float], None], - env: dict | None = None, - register_proc: Callable | None = None, -) -> None: - """Run an apt command, stream its stdout to the log, and report download progress. - - Uses APT::Status-Fd to receive machine-readable progress lines on a private - pipe. A background thread reads the pipe and calls on_progress(0-100) for - each percentage update. When a new URI starts downloading it is printed to - the log so the user can see what is being fetched. - - Запускает команду apt, передаёт stdout в лог и показывает прогресс скачивания. - Использует APT::Status-Fd для получения машинночитаемых строк прогресса через - приватный канал. Фоновый поток читает канал и вызывает on_progress(0-100) при - каждом обновлении процента. При начале скачивания нового файла его URI - выводится в лог, чтобы пользователь видел что именно загружается. - """ - r_fd, w_fd = os.pipe() - try: - proc = subprocess.Popen( - cmd + ["-o", f"APT::Status-Fd={w_fd}"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=env or os.environ, - pass_fds=(w_fd,), - ) - finally: - # Close the write end in the parent so the reader thread gets EOF when apt exits. - # Закрываем пишущий конец в родителе, чтобы читающий поток получил EOF при выходе apt. - os.close(w_fd) - - if register_proc: - register_proc(proc) - - def _read_status() -> None: - """Parse APT::Status-Fd lines and forward percentage and URI info. - - Status-Fd line format: dlstatus:index:pct:message - or for package installs: pmstatus:pkg:pct:message - - Разбирает строки APT::Status-Fd и передаёт процент и URI. - Формат строки: dlstatus:index:pct:message - или для установки пакетов: pmstatus:pkg:pct:message - """ - last_uri: str = "" - fetched = 0 - with os.fdopen(r_fd, "r") as f: - for line in f: - parts = line.strip().split(":", 3) - if len(parts) < 3: - continue - kind, _, pct_str = parts[0], parts[1], parts[2] - msg = parts[3] if len(parts) == 4 else "" - try: - on_progress(float(pct_str)) - except ValueError: - continue - if kind == "dlstatus" and msg: - # Print each new URI once as it starts downloading. - # Выводим каждый новый URI один раз при начале загрузки. - uri = msg.split()[0] - if uri != last_uri: - last_uri = uri - fetched += 1 - write(f"[dim][{fetched}] {msg}[/dim]") - - t = threading.Thread(target=_read_status, daemon=True) - t.start() - for line in proc.stdout: - s = line.rstrip() - if s: - write(s) - proc.wait() - t.join() - - if proc.returncode not in (0, -9): - raise RuntimeError(f"Command failed: {cmd[0]}") - - -# Installation steps - each step maps to one visible phase in the log screen -# Шаги установки - каждый шаг соответствует одной видимой фазе в экране лога -def _step_prereqs( - write: Write, - on_progress: Callable[[float], None], - register_proc: Callable | None = None, -) -> None: - """Step 1 - Refresh apt cache and install packages required for the ROS2 setup. - - Installs: software-properties-common, curl, gnupg2, lsb-release, build-essential. - Also enables the Ubuntu universe repository which some ROS2 dependencies live in. - - Шаг 1 - Обновляет кеш apt и устанавливает пакеты, необходимые для настройки ROS2. - Устанавливает: software-properties-common, curl, gnupg2, lsb-release, build-essential. - Также включает репозиторий Ubuntu universe, в котором находятся некоторые зависимости ROS2. - """ - write("[bold]Step 1 / 5 - Prerequisites[/bold]") - write("[cyan][*][/cyan] Updating package lists...") - _run_apt_with_progress( - ["sudo", "apt-get", "update"] + _APT_OPTS, - write, on_progress, _APT_ENV, register_proc, - ) - write("[cyan][*][/cyan] Installing prerequisites...") - _run_apt_with_progress( - [ - "sudo", "apt-get", "install", "-y", "--no-install-recommends", - "software-properties-common", "curl", "gnupg2", - "lsb-release", "build-essential", - ] + _APT_OPTS, - write, on_progress, _APT_ENV, register_proc, - ) - write("[cyan][*][/cyan] Adding universe repository...") - # --no-update prevents add-apt-repository from running its own apt-get update, - # which would ignore our timeout options and could hang indefinitely. - # --no-update запрещает add-apt-repository запускать собственный apt-get update, - # который игнорирует наши таймауты и может зависнуть. - _run_logged(["sudo", "add-apt-repository", "-y", "--no-update", "universe"], write, - register_proc=register_proc) - write("[green][ok][/green] Prerequisites ready") - - -def _step_ros2_repo( - write: Write, - on_progress: Callable[[float], None], - register_proc: Callable | None = None, -) -> None: - """Step 2 - Download the ROS2 signing key and register the ROS2 apt repository. - - Removes any previous key and sources file first so re-runs always start clean. - Downloads the key from the official ros/rosdistro GitHub repository, writes it - to the system keyring, then creates /etc/apt/sources.list.d/ros2.list and - refreshes only that source to avoid updating all Ubuntu mirrors. - - Шаг 2 - Скачивает ключ подписи ROS2 и регистрирует репозиторий apt ROS2. - Сначала удаляет предыдущие ключ и файл источников, чтобы повторные запуски - всегда начинались с чистого состояния. Скачивает ключ с официального GitHub - репозитория ros/rosdistro, записывает его в системный кейринг, затем создаёт - /etc/apt/sources.list.d/ros2.list и обновляет только этот источник. - """ - write("\n[bold]Step 2 / 5 - ROS2 repository[/bold]") - - # Remove previous key and sources file to avoid stale config on re-runs. - # Удаляем предыдущие ключ и файл источников для чистого состояния при повторных запусках. - for path in (_ROS_KEYRING, _ROS_SOURCES): - if path.exists(): - subprocess.run(["sudo", "rm", "-f", str(path)], check=False) - write(f"[dim]Removed {path}[/dim]") - - write("[cyan][*][/cyan] Downloading ROS2 signing key...") - key_url = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key" - with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp: - tmp_path = tmp.name - try: - with urllib.request.urlopen(key_url, timeout=30) as resp: - Path(tmp_path).write_bytes(resp.read()) - subprocess.run( - ["sudo", "install", "-m", "644", tmp_path, str(_ROS_KEYRING)], - check=True, - ) - finally: - Path(tmp_path).unlink(missing_ok=True) - write("[green][ok][/green] Signing key installed") - - # Detect machine architecture and Ubuntu codename to build the sources.list line. - # Определяем архитектуру машины и кодовое имя Ubuntu для строки sources.list. - arch = subprocess.check_output(["dpkg", "--print-architecture"], text=True).strip() - codename = subprocess.check_output( - ["bash", "-c", ". /etc/os-release && echo ${UBUNTU_CODENAME:-${VERSION_CODENAME}}"], - text=True, - ).strip() - sources_line = ( - f"deb [arch={arch} signed-by={_ROS_KEYRING}] " - f"http://packages.ros.org/ros2/ubuntu {codename} main\n" - ) - result = subprocess.run( - ["sudo", "tee", str(_ROS_SOURCES)], - input=sources_line, capture_output=True, text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"Failed to write {_ROS_SOURCES}") - write(f"[dim]{_ROS_SOURCES}[/dim]") - - # Update only the ROS2 source - avoids downloading all Ubuntu mirror metadata. - # Обновляем только источник ROS2 - избегаем скачивания метаданных всех зеркал Ubuntu. - write("[cyan][*][/cyan] Updating ROS2 package list...") - _run_apt_with_progress( - [ - "sudo", "apt-get", "update", - "-o", f"Dir::Etc::sourcelist={_ROS_SOURCES}", - "-o", "Dir::Etc::sourceparts=-", - "-o", "APT::Get::List-Cleanup=0", - ] + _APT_OPTS, - write, on_progress, _APT_ENV, register_proc, - ) - write("[green][ok][/green] ROS2 repository ready") - - -def _step_install_ros2( - write: Write, - on_progress: Callable[[float], None], - pkg: str, - register_proc: Callable | None = None, -) -> None: - """Step 3 - Install the chosen ROS2 Jazzy package via apt. - - pkg is either "desktop" (full install with GUI tools) or "ros-base" - (minimal headless install). The full package name becomes ros-jazzy-{pkg}. - - Шаг 3 - Устанавливает выбранный пакет ROS2 Jazzy через apt. - pkg - это либо "desktop" (полная установка с GUI), либо "ros-base" - (минимальная установка без GUI). Полное имя пакета: ros-jazzy-{pkg}. - """ - write(f"\n[bold]Step 3 / 5 - ros-{_DISTRO}-{pkg}[/bold]") - write(f"[cyan][*][/cyan] Installing ros-{_DISTRO}-{pkg}...") - _run_apt_with_progress( - ["sudo", "apt-get", "install", "-y", f"ros-{_DISTRO}-{pkg}"] + _APT_OPTS, - write, on_progress, _APT_ENV, register_proc, - ) - write(f"[green][ok][/green] ros-{_DISTRO}-{pkg} installed") - - -def _step_dev_tools( - write: Write, - on_progress: Callable[[float], None], - register_proc: Callable | None = None, -) -> None: - """Step 4 - Install colcon, rosdep, vcstool and initialize rosdep. - - Installs the Python packages needed to build and manage ROS2 workspaces. - Runs "rosdep init" only if it has not been run before, then always runs - "rosdep update" to fetch the latest package index. - - Шаг 4 - Устанавливает colcon, rosdep, vcstool и инициализирует rosdep. - Устанавливает Python-пакеты, необходимые для сборки и управления рабочими - пространствами ROS2. Запускает "rosdep init" только если он ещё не запускался, - затем всегда запускает "rosdep update" для получения свежего индекса пакетов. - """ - write("\n[bold]Step 4 / 5 - Dev tools[/bold]") - write("[cyan][*][/cyan] Installing colcon, rosdep, vcstool...") - _run_apt_with_progress( - [ - "sudo", "apt-get", "install", "-y", - "python3-argcomplete", - "python3-colcon-clean", - "python3-colcon-common-extensions", - "python3-rosdep", - "python3-vcstool", - ] + _APT_OPTS, - write, on_progress, _APT_ENV, register_proc, - ) - if not _ROSDEP_SOURCES.exists(): - write("[cyan][*][/cyan] Initializing rosdep...") - _run_logged(["sudo", "rosdep", "init"], write, register_proc=register_proc) - else: - write("[green][ok][/green] rosdep already initialized") - write("[cyan][*][/cyan] Updating rosdep...") - _run_logged(["rosdep", "update", "--rosdistro", _DISTRO], write, - register_proc=register_proc) - write("[green][ok][/green] Dev tools ready") - - -def _step_shell_setup(write: Write) -> None: - """Step 5 - Append ROS2 environment setup lines to the user shell rc file. - - Adds "source /opt/ros/jazzy/setup.bash" so ROS2 commands are available in - every new terminal. Also adds a commented-out ROS_AUTOMATIC_DISCOVERY_RANGE - line as a reminder for multi-machine setups. Both lines are added only once. - - Шаг 5 - Добавляет строки настройки окружения ROS2 в rc-файл оболочки пользователя. - Добавляет "source /opt/ros/jazzy/setup.bash" чтобы команды ROS2 были доступны - в каждом новом терминале. Также добавляет закомментированную строку - ROS_AUTOMATIC_DISCOVERY_RANGE как напоминание для многомашинных настроек. - Обе строки добавляются только один раз. - """ - write("\n[bold]Step 5 / 5 - Shell configuration[/bold]") - shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name - rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc") - rc_text = rc.read_text() if rc.exists() else "" - - source_line = f"source /opt/ros/{_DISTRO}/setup.bash" - discovery_comment = "# export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST" - - additions = [] - if source_line not in rc_text: - additions.append(source_line) - if discovery_comment not in rc_text: - additions.append(discovery_comment) - - if additions: - with rc.open("a") as f: - f.write(f"\n# ROS2 {_DISTRO}\n") - for line in additions: - f.write(line + "\n") - write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}") - else: - write(f"[green][ok][/green] ROS2 setup already in ~/{rc.name}") - - # Tasks - long-running functions executed inside a LogScreen background thread # Задачи - долгие функции, выполняемые в фоновом потоке внутри LogScreen +def _run_script(script: Path, screen: LogScreen) -> None: + """Run a shell script, stream its output to the TUI log, and parse + PROGRESS::