From bbfec1787677ba25becc85a7e2360ec25d4c2405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=B8=D0=BB=20=D0=93=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=D1=8C?= Date: Wed, 20 May 2026 17:05:01 +0300 Subject: [PATCH] Add local setup command and enhance docker setup wizard --- cobot/AMENT_IGNORE | 0 cobot/cli.py | 5 +- cobot/commands/docker_setup.py | 12 +- cobot/commands/local_setup.py | 255 +++++++++++++++++++++++++++++++++ cobot/commands/setup.py | 35 ++++- cobot/tui.py | 10 +- 6 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 cobot/AMENT_IGNORE create mode 100644 cobot/commands/local_setup.py diff --git a/cobot/AMENT_IGNORE b/cobot/AMENT_IGNORE new file mode 100644 index 0000000..e69de29 diff --git a/cobot/cli.py b/cobot/cli.py index 7660993..961f947 100644 --- a/cobot/cli.py +++ b/cobot/cli.py @@ -3,6 +3,7 @@ import sys from cobot.commands import docker_setup as cmd_docker_setup from cobot.commands import doc_setup as cmd_doc_setup +from cobot.commands import local_setup as cmd_local_setup from cobot.commands import robot_setup as cmd_robot_setup from cobot.commands import setup as cmd_setup @@ -10,7 +11,8 @@ from cobot.commands import setup as cmd_setup # Add new commands here when introducing other categories. _GROUPS = [ ("Setup", [ - ("setup", "run doc-setup + docker-setup + robot-setup in one go"), + ("setup", "first-time setup: docs, build environment, robot config"), + ("local-setup", "install ROS2 Jazzy natively and build the project with colcon"), ("docker-setup", "build or pull Docker images for KUKA iiwa7"), ("doc-setup", "deploy or stop the MkDocs documentation server"), ("robot-setup", "configure cobot-setting.yaml interactively"), @@ -68,6 +70,7 @@ def main(): def _register_commands(subparsers): cmd_setup.register(subparsers) + cmd_local_setup.register(subparsers) cmd_docker_setup.register(subparsers) cmd_doc_setup.register(subparsers) cmd_robot_setup.register(subparsers) diff --git a/cobot/commands/docker_setup.py b/cobot/commands/docker_setup.py index 1f6c77b..246088e 100644 --- a/cobot/commands/docker_setup.py +++ b/cobot/commands/docker_setup.py @@ -84,7 +84,15 @@ class _Wizard(App[Optional[_Config]]): return self._state["source"] = "build" if v == "Build locally" else "pull" self.push_screen( - PickScreen("Step 3 of 5", "What to install:", ["Controller only", "Controller and Webots"], "Controller only"), + PickScreen( + "Step 3 of 5", + "What to install:", + [ + "Controller only — ros-core, ros-base, ros-iiwa7", + "Controller with Webots — ros-core, ros-base, ros-iiwa7-webots", + ], + "Controller only — ros-core, ros-base, ros-iiwa7", + ), self._got_variant, ) @@ -92,7 +100,7 @@ class _Wizard(App[Optional[_Config]]): if v is None: self.exit(None) return - self._state["variant"] = "webots" if v == "Controller and Webots" else "controller" + self._state["variant"] = "webots" if v.startswith("Controller with Webots") else "controller" self.push_screen( PickScreen("Step 4 of 5", "Build type:", ["release", "dev"], "release"), self._got_build_type, diff --git a/cobot/commands/local_setup.py b/cobot/commands/local_setup.py new file mode 100644 index 0000000..8110ce4 --- /dev/null +++ b/cobot/commands/local_setup.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.request +from pathlib import Path +from typing import List, Optional + +from rich.console import Console +from textual.app import App + +from cobot.tui import SCREEN_CSS, PickScreen +from cobot.commands.docker_setup import run as _docker_setup + +_console = Console() +_PROJECT_DIR = Path(__file__).parent.parent.parent + +_ROS_KEYRING = Path("/usr/share/keyrings/ros-archive-keyring.gpg") +_ROS_SOURCES = Path("/etc/apt/sources.list.d/ros2.list") +_ROS_KEY_URL = "https://raw.githubusercontent.com/ros/rosdistro/master/ros.key" + + +def _detect_ubuntu_2404() -> bool: + os_release = Path("/etc/os-release") + if not os_release.exists(): + return False + info: dict[str, str] = {} + for line in os_release.read_text().splitlines(): + if "=" in line: + k, _, v = line.partition("=") + info[k.strip()] = v.strip().strip('"') + return info.get("ID") == "ubuntu" and info.get("VERSION_ID") == "24.04" + + +def _detect_ros2_jazzy() -> bool: + return Path("/opt/ros/jazzy").is_dir() + + +def _run(cmd: List[str]) -> None: + result = subprocess.run(cmd) + if result.returncode != 0: + raise RuntimeError(f"Command failed: {' '.join(cmd)}") + + +def _check_output(cmd: List[str]) -> str: + return subprocess.check_output(cmd, text=True).strip() + + +def _step(msg: str) -> None: + _console.print(f"[cyan][*][/cyan] {msg}") + + +def _ok(msg: str) -> None: + _console.print(f"[green][ok][/green] {msg}") + + +def _apt_install(*packages: str) -> None: + _run(["sudo", "apt-get", "install", "-y", "--no-install-recommends", *packages]) + + +def _setup_locale() -> None: + _step("Checking locale...") + result = subprocess.run(["locale"], capture_output=True, text=True) + if "UTF-8" in result.stdout: + _ok("UTF-8 locale active") + return + _run(["sudo", "apt-get", "update", "-qq"]) + _apt_install("locales") + _run(["sudo", "locale-gen", "en_US.UTF-8"]) + _run(["sudo", "update-locale", "LC_ALL=en_US.UTF-8", "LANG=en_US.UTF-8"]) + _ok("Locale configured") + + +def _add_ros2_repo() -> None: + _step("Adding ROS2 apt repository...") + _run(["sudo", "apt-get", "update", "-qq"]) + _apt_install("software-properties-common", "curl") + _run(["sudo", "add-apt-repository", "-y", "universe"]) + + if not _ROS_KEYRING.exists(): + _step("Downloading ROS2 signing key...") + with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp: + tmp_path = tmp.name + try: + urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path) + _run(["sudo", "cp", tmp_path, str(_ROS_KEYRING)]) + finally: + os.unlink(tmp_path) + + if not _ROS_SOURCES.exists(): + arch = _check_output(["dpkg", "--print-architecture"]) + codename = _check_output( + ["bash", "-c", ". /etc/os-release && echo $UBUNTU_CODENAME"] + ) + sources_line = ( + f"deb [arch={arch} signed-by={_ROS_KEYRING}] " + f"http://packages.ros.org/ros2/ubuntu {codename} main\n" + ) + proc = subprocess.run( + ["sudo", "tee", str(_ROS_SOURCES)], + input=sources_line, + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError(f"Failed to write {_ROS_SOURCES}") + + _run(["sudo", "apt-get", "update", "-qq"]) + _ok("ROS2 repository ready") + + +def _install_ros2_jazzy() -> None: + _step("Installing ros-jazzy-ros-base + ros-dev-tools...") + _apt_install("ros-jazzy-ros-base", "ros-dev-tools") + _ok("ROS2 Jazzy installed") + + +def _install_colcon() -> None: + if shutil.which("colcon"): + _ok("colcon already available") + return + _step("Installing colcon...") + _apt_install("python3-colcon-common-extensions") + _ok("colcon installed") + + +def _setup_shell_rc() -> None: + shell_name = Path(os.environ.get("SHELL", "/bin/bash")).name + rc = Path.home() / (".zshrc" if shell_name == "zsh" else ".bashrc") + source_line = "source /opt/ros/jazzy/setup.bash" + if rc.exists() and source_line in rc.read_text(): + _ok(f"ROS2 setup already in {rc.name}") + return + with rc.open("a") as f: + f.write(f"\n# ROS2 Jazzy\n{source_line}\n") + _ok(f"Added ROS2 setup to ~/{rc.name}") + + +def _install_jazzy() -> None: + _console.print("\n[bold]Installing ROS2 Jazzy...[/bold]\n") + try: + _setup_locale() + _add_ros2_repo() + _install_ros2_jazzy() + _install_colcon() + _setup_shell_rc() + except (subprocess.CalledProcessError, RuntimeError) as exc: + _console.print(f"\n[red]Installation failed:[/red] {exc}") + sys.exit(1) + _console.print( + "\n[green]ROS2 Jazzy installed.[/green] " + "Restart the terminal, then run [bold]cobot local-setup[/bold] again to build." + ) + + +def _build_project() -> None: + if not shutil.which("colcon"): + _console.print("[red]Error:[/red] colcon not found. Source ROS2 first:") + _console.print(" [bold]source /opt/ros/jazzy/setup.bash[/bold]") + sys.exit(1) + + _console.print("\n[bold]Building project with colcon...[/bold]\n") + result = subprocess.run( + ["colcon", "build", "--symlink-install"], + cwd=_PROJECT_DIR, + ) + if result.returncode != 0: + _console.print("\n[red]Build failed.[/red]") + sys.exit(result.returncode) + _console.print("\n[green]Build complete.[/green]") + _console.print(" Activate workspace: [bold]source install/setup.bash[/bold]") + + +class _AskInstallJazzy(App[Optional[str]]): + CSS = SCREEN_CSS + + def on_mount(self) -> None: + self.push_screen( + PickScreen( + "ROS2 not found", + "ROS2 Jazzy is not installed. Install it now?", + ["Yes, install ROS2 Jazzy", "No, skip"], + "Yes, install ROS2 Jazzy", + ), + self.exit, + ) + + +class _AskDockerSetup(App[Optional[str]]): + CSS = SCREEN_CSS + + def on_mount(self) -> None: + self.push_screen( + PickScreen( + "Unsupported OS", + "Ubuntu 24.04 not detected. Build a Docker image for development?", + ["Yes, run docker-setup", "No, exit"], + "Yes, run docker-setup", + ), + self.exit, + ) + + +def _flow_ubuntu_without_ros() -> None: + _console.print( + "\n[yellow]Warning:[/yellow] ROS2 Jazzy is not installed " + "(/opt/ros/jazzy not found).\n" + ) + answer = _AskInstallJazzy().run() + if answer is None or answer.startswith("No"): + _console.print("[yellow]Skipped ROS2 installation.[/yellow]") + return + _install_jazzy() + + +def _flow_not_ubuntu(args: argparse.Namespace) -> None: + _console.print( + "\n[yellow]Warning:[/yellow] Ubuntu 24.04 not detected. " + "Native build is not supported on this OS.\n" + ) + answer = _AskDockerSetup().run() + if answer is None or answer.startswith("No"): + _console.print("[yellow]Exiting without changes.[/yellow]") + return + + _docker_setup(args) + + +def register(subparsers: argparse._SubParsersAction) -> None: + p = subparsers.add_parser( + "local-setup", + help="Build the project locally (requires Ubuntu 24.04 and ROS2 Jazzy)", + ) + p.set_defaults(func=run) + + +def run(args: argparse.Namespace) -> None: + _console.print("[bold]Checking environment...[/bold]") + + if not _detect_ubuntu_2404(): + _flow_not_ubuntu(args) + return + + _console.print("[green] Ubuntu 24.04[/green] ✓") + + if not _detect_ros2_jazzy(): + _flow_ubuntu_without_ros() + return + + _console.print("[green] ROS2 Jazzy[/green] ✓\n") + _build_project() diff --git a/cobot/commands/setup.py b/cobot/commands/setup.py index a9e0f8d..c21013b 100644 --- a/cobot/commands/setup.py +++ b/cobot/commands/setup.py @@ -1,8 +1,31 @@ import argparse +from typing import Optional + +from textual.app import App from cobot.commands.doc_setup import run as _doc_setup from cobot.commands.docker_setup import run as _docker_setup +from cobot.commands.local_setup import run as _local_setup from cobot.commands.robot_setup import run as _robot_setup +from cobot.tui import SCREEN_CSS, PickScreen + + +class _AskSetupMode(App[Optional[str]]): + CSS = SCREEN_CSS + + def on_mount(self) -> None: + self.push_screen( + PickScreen( + "Step 1 of 1", + "How do you want to set up the project?", + [ + "local-setup — install ROS2 Jazzy on this machine and build with colcon", + "docker-setup — build a Docker image with ROS2 Jazzy pre-installed", + ], + "local-setup — install ROS2 Jazzy on this machine and build with colcon", + ), + self.exit, + ) def register(subparsers): @@ -11,6 +34,16 @@ def register(subparsers): def run(args: argparse.Namespace) -> None: + _doc_setup(args) - _docker_setup(args) + + choice = _AskSetupMode().run() + if choice is None: + return + + if choice.startswith("local"): + _local_setup(args) + else: + _docker_setup(args) + _robot_setup(args) diff --git a/cobot/tui.py b/cobot/tui.py index 865079d..f86c3ab 100644 --- a/cobot/tui.py +++ b/cobot/tui.py @@ -67,8 +67,14 @@ class PickScreen(Screen[Optional[str]]): self.query_one(RadioSet).focus() def action_submit(self) -> None: - btn = self.query_one("#choices", RadioSet).pressed_button - self.dismiss(str(btn.label) if btn else self._default) + radio_set = self.query_one("#choices", RadioSet) + buttons = list(radio_set.query(RadioButton)) + idx = getattr(radio_set, "_selected", None) + if idx is not None and 0 <= idx < len(buttons): + self.dismiss(str(buttons[idx].label)) + else: + btn = radio_set.pressed_button + self.dismiss(str(btn.label) if btn else self._default) def action_abort(self) -> None: self.app.exit(None)