diff --git a/.env.example b/.env.example deleted file mode 100644 index fa8d723..0000000 --- a/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Local overrides for install.sh (copy to `.env`; `.env` is gitignored). - -# When true, install.sh uses `url_ssh` from config/repos.json (default: `url_https`). -# Requires SSH keys configured for the host. -DEV=true diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml index 28f8d8f..ab8acce 100644 --- a/.github/workflows/install-and-launch.yml +++ b/.github/workflows/install-and-launch.yml @@ -8,12 +8,14 @@ # - The launch smoke test uses `./launch_lucy.sh --headless `, which runs a # single command inside the container (no control panel, no auto Gazebo/RViz). -name: Install and launch +name: Install, Launch & Release on: workflow_dispatch: push: branches: [master, dev] + tags: + - 'v*' # Run on version tags like v1.0, v2.3.4 pull_request: branches: [master, dev] @@ -70,3 +72,33 @@ jobs: # Single command in the container -> bypasses the control-panel / Gazebo / RViz auto-launch. - name: Launch smoke test (headless, no TTY) run: ./launch_lucy.sh --headless ros2 doctor --report + + build-and-release-windows-exe: + name: Build and Release Windows Executable + # Only run this job when a new tag is pushed + if: startsWith(github.ref, 'refs/tags/') + runs-on: windows-latest + needs: install-and-launch + permissions: + contents: write # Required to create a release and upload assets + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install PyInstaller + run: pip install pyinstaller + + - name: Build the executable + run: pyinstaller --onefile --name Lucy windows/Lucy.py + + - name: Create Release and Upload Asset + uses: softprops/action-gh-release@v2 + with: + files: dist/Lucy.exe + # This creates a draft release. Remove `draft: true` to publish it automatically. + draft: true diff --git a/.gitignore b/.gitignore index c7ca731..6f48fec 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ # IDE / OS .idea/ .vscode/ +.vs/ .DS_Store *~ diff --git a/Dockerfile.humble b/Dockerfile.humble index c3edf86..9aa7d23 100644 --- a/Dockerfile.humble +++ b/Dockerfile.humble @@ -77,6 +77,10 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm install -g yarn \ && rm -rf /var/lib/apt/lists/* +# Setup bashrc with alias for the TUI launcher +RUN echo "alias launcher='python3 /workspace/launcher.py'" >> ~/.bashrc +RUN echo "alias exit=''" >> ~/.bashrc # Disable 'exit' to force launcher usage + WORKDIR /workspace ENTRYPOINT ["/bin/bash"] CMD ["-c", "source /opt/ros/humble/setup.bash && exec /bin/bash"] diff --git a/Lucy.py b/Lucy.py new file mode 100644 index 0000000..5630949 --- /dev/null +++ b/Lucy.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import curses +import os +import subprocess +import sys + +MIN_TERM_HEIGHT = 15 +MIN_TERM_WIDTH = 65 + +def get_dev_mode(): + if not os.path.exists(".env"): + return False + with open(".env", "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + +def set_dev_mode(is_enabled): + lines = [] + dev_found = False + if os.path.exists(".env"): + with open(".env", "r") as f: + lines = f.readlines() + + with open(".env", "w") as f: + for line in lines: + if line.strip().startswith("DEV="): + f.write(f"DEV={str(is_enabled).lower()}\n") + dev_found = True + else: + f.write(line) + if not dev_found: + f.write(f"DEV={str(is_enabled).lower()}\n") + +def run_command(command, interactive=False): + """Runs a command. + + If interactive is True, runs natively in the terminal. + """ + print(f"--- Running: {' '.join(command)} ---") + try: + if interactive: + # Inherit standard IO to maintain terminal size and TTY functionality + return subprocess.run(command).returncode + else: + # Popen is fine for non-interactive scripts like install/build + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + while True: + output = process.stdout.readline() + if output == '' and process.poll() is not None: + break + if output: + print(output.strip()) + return process.poll() + + except FileNotFoundError: + print(f"Error: Command '{command[0]}' not found. Make sure it's in your PATH and executable.") + return -1 + except Exception as e: + print(f"An error occurred: {e}") + return -1 + +def main_tui(stdscr): + """The main curses TUI function. Returns the command to run.""" + h, w = stdscr.getmaxyx() + if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: + return "TerminalTooSmall" + + curses.curs_set(0) + stdscr.nodelay(0) + stdscr.timeout(-1) + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_CYAN, -1) + + is_dev_mode = get_dev_mode() + current_idx = 0 + options = ["Launch", "---", "Install/Update", "Rebuild", "Exit", "---", "Developer Mode"] + + while True: + stdscr.clear() + h, w = stdscr.getmaxyx() + title = "Lucy Workspace Manager" + stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) + + for i, option in enumerate(options): + if option == "---": + stdscr.addstr(2 + i, 4, "----------------------") + continue + + prefix = "> " if current_idx == i else " " + + if option == "Developer Mode": + checkbox = "[x]" if is_dev_mode else "[ ]" + stdscr.addstr(2 + i, 4, f"{prefix}{checkbox} {option}") + else: + stdscr.addstr(2 + i, 4, f"{prefix}{option}") + + stdscr.addstr(h - 2, 2, "Enter/Space: Select/Toggle | Up/Down: Navigate", curses.A_DIM) + stdscr.refresh() + + key = stdscr.getch() + + if key == curses.KEY_UP: + current_idx = (current_idx - 1 + len(options)) % len(options) + if options[current_idx] == "---": + current_idx = (current_idx - 1 + len(options)) % len(options) + elif key == curses.KEY_DOWN: + current_idx = (current_idx + 1) % len(options) + if options[current_idx] == "---": + current_idx = (current_idx + 1) % len(options) + elif key in [ord(' '), ord('\n')]: + selected_option = options[current_idx] + + if selected_option == "Developer Mode": + is_dev_mode = not is_dev_mode + set_dev_mode(is_dev_mode) + elif selected_option == "Install/Update": + return {"cmd": ["./install.sh"], "interactive": False, "name": "Install"} + elif selected_option == "Rebuild": + return {"cmd": ["./install.sh", "--build-only"], "interactive": False, "name": "Rebuild"} + elif selected_option == "Launch": + return {"cmd": ["./launch_lucy.sh"], "interactive": True, "name": "Launch"} + elif selected_option == "Exit": + return None + +if __name__ == "__main__": + # This initial check is done before curses.wrapper to provide a clean error message + # without the screen flicker of initializing and de-initializing curses. + def check_initial_size(): + stdscr = curses.initscr() + h, w = stdscr.getmaxyx() + curses.endwin() + return h >= MIN_TERM_HEIGHT and w >= MIN_TERM_WIDTH + + if not check_initial_size(): + print("Error: Terminal window is too small.", file=sys.stderr) + print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) + sys.exit(1) + + while True: + task = None + try: + task = curses.wrapper(main_tui) + except KeyboardInterrupt: + print("\nExiting.") + sys.exit(0) + except curses.error as e: + print(f"A terminal error occurred: {e}", file=sys.stderr) + print("This might be due to resizing the window. Please restart.", file=sys.stderr) + sys.exit(1) + + if isinstance(task, str) and task == "TerminalTooSmall": + # This case is handled by the pre-check, but as a fallback. + print("Error: Terminal window is too small.", file=sys.stderr) + print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) + sys.exit(1) + + if not task: + # User selected Exit + break + + rc = run_command(task["cmd"], interactive=task.get("interactive", False)) + + if task.get("interactive", False): + print(f"--- Session finished with exit code {rc} ---") + break + + task_name = task.get("name") + if task_name in ["Install", "Rebuild"] and rc == 0: + print(f"\n--- Task '{task_name}' finished successfully. ---") + print("Press Enter to return to the menu.") + input() + else: + print(f"\n--- Task '{task_name}' finished with exit code {rc} ---") + print("Press Enter to exit.") + input() + break + + sys.exit(0) diff --git a/README.md b/README.md index 32860ee..9324b53 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,63 @@ Workspace bringup for the Lucy / InMoov humanoid. Everything (ROS 2 Humble, Gazebo, RViz, the web control panel) runs inside a single Docker container — you only need **Docker**, **Git** and **Python 3** on the host (plus **`xhost`** on Linux for GUI forwarding). +## Requirements + +- Python3 +- Docker + ## Install +> For UNIX based system: ```bash chmod +x install.sh launch_lucy.sh -./install.sh # Linux, Intel Mac, Windows WSL, x86_64 VMs -./install.sh --arm # Apple Silicon (M1 / M2 / M3) under Docker Desktop ``` -`install.sh` clones the sub-repositories listed in `config/repos.json` into `src/`, builds the Docker image, and compiles the workspace inside the container. +The installation is handled by the `Lucy.py` script. Only use the `./install.sh` script as a fallback. -## Launch +## Launch & Manage -```bash -./launch_lucy.sh -``` +We provide a Python-based Text User Interface (TUI) to easily manage the workspace. It handles installing, rebuilding, and launching the environment. + +From the repository root, run the manager for your platform: + +| OS | Command | +| :--- |:-------------------------------------------------------------------| +| **Linux / macOS** | `python3 Lucy.py` | +| **Windows** | `python windows/Lucy.py` (See [Windows README](windows/README.md)) | + +> The windows installation require the installation of a 3rd party software, as a Windows X Server is needed. + +### Developer mode + +The manager includes a **Developer Mode** toggle. When ON: +- repositories are pulled using SSH instead of HTTP +- The core & control panel aren't launch automatically + +This setting is stored in a .env file + +### Using the Workspace + +Selecting **Launch** from the manager starts the workspace, running everything inside a single **tmux** session within the Docker container. + +You will immediately see the **Lucy Control Center** TUI: +- Use **Up/Down Arrows** to navigate. +- Press **Space** to toggle a package or tool on/off. +- Press **Enter** to apply your changes. (New tools open in their own background windows). +- Press **X** to stop all processes and exit the Docker container entirely. + +### Managing Tmux Windows + +Because all tools (like the console or the control panel) run in background windows, you need to know a few basic `tmux` commands to navigate between them: -Starts the **control panel** in the background and runs `lucy_bringup` with Gazebo and RViz inside the container (GUI / X11 forwarded automatically when available). +- **`Ctrl+B` then `W`**: Opens a menu of all running windows. Use the arrows to select one and press Enter to switch to it. +- **`Ctrl+B` then `N`**: Go to the next window. +- **`Ctrl+B` then `P`**: Go to the previous window. +- **`Ctrl+B` then `D`**: Detach from the session (keeps the container running in the background). Open the control panel at **http://localhost:5000/**. ## More - [`docs/developer_lucy_packages.md`](docs/developer_lucy_packages.md) — developer guide: per-repo docs, all `install.sh` / `launch_lucy.sh` flags, dev mode, ports, environment overrides, packages overview. +- [`docs/launcher_packages.md`](docs/launcher_packages.md) — launcher guide: how to add new packages to the launcher UI and understand the configuration fields. diff --git a/config/launcher_config.json b/config/launcher_config.json new file mode 100644 index 0000000..e070d4a --- /dev/null +++ b/config/launcher_config.json @@ -0,0 +1,88 @@ +{ + "packages": [ + { + "id": "core", + "name": "Core (Lucy Bringup)", + "description": "Base robot software stack", + "type": "core", + "dependencies": [], + "conflicts": [], + "command": "ros2 launch lucy_bringup lucy.launch.py", + "default_on": false + }, + { + "id": "gazebo", + "name": "... with Simulator", + "description": "(Gazebo)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": ["real"], + "command": "gazebo:=true", + "default_on": false + }, + { + "id": "rviz", + "name": "... with Visualizer", + "description": "(RViz)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": [], + "command": "rviz:=true", + "default_on": false + }, + { + "id": "real", + "name": "... with Real Hardware", + "description": "(Connect to physical robot)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": ["gazebo"], + "command": "real:=true", + "default_on": false + }, + { + "id": "control_panel", + "name": "Control Panel", + "description": "Web-based UI (http://localhost:5000/)", + "type": "interface", + "dependencies": [], + "conflicts": [], + "command": { + "start": "tmux new-window -d -n control_panel 'cd /workspace/src/lucy_control_panel && yarn dev'", + "stop": "tmux kill-window -t lucy_ws:control_panel 2>/dev/null || true", + "is_running": "tmux list-windows -F '#{window_name}' | grep -q '^control_panel$'" + }, + "default_on": false + }, + { + "id": "lucy_cli", + "name": "Lucy CLI", + "description": "Command Line Interface", + "type": "interface", + "dependencies": ["core"], + "conflicts": [], + "command": "ros2 run lucy_cli tui", + "default_on": false + }, + { + "id": "console", + "name": "Open Console", + "description": "Opens a new interactive terminal window", + "type": "tool", + "dependencies": [], + "conflicts": [], + "command": "echo \"To navigate, press Ctrl+B then W\" && bash -i", + "default_on": false + }, + { + "id": "rqt", + "name": "rqt GUI", + "description": "Main ROS 2 GUI tool", + "type": "tool", + "dependencies": ["core"], + "conflicts": [], + "command": "rqt", + "default_on": false + } + ] +} diff --git a/docs/launcher_packages.md b/docs/launcher_packages.md new file mode 100644 index 0000000..8c3e561 --- /dev/null +++ b/docs/launcher_packages.md @@ -0,0 +1,45 @@ +### Adding a New Package to the Launcher + +The launcher's configuration is entirely driven by the `launcher_config.json` file. To add a new package, tool, or modifier, you just need to add a new JSON object to the `packages` list in this file. + +For example, if you wanted to add an `rqt_graph` tool, you would append this to the `packages` array: + +```json +{ + "id": "rqt_graph", + "name": "ROS Qt Graph", + "description": "Visualizes the ROS 2 computation graph", + "type": "tool", + "dependencies": ["core"], + "conflicts": [], + "command": "ros2 run rqt_graph rqt_graph", + "default_on": false +} +``` + +### Configuration Fields Explained + +Every package entry in `config/launcher_config.json` uses the following fields to dictate how it behaves in the launcher: + +| Field | Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | A unique identifier for the package. Used internally for window names, resolving dependencies, and tracking the state. Must be unique. | +| `name` | `string` | The display name shown in the launcher's terminal UI. | +| `description` | `string` | A short description shown alongside the package name. | +| `type` | `string` | The classification of the package. It defines how the launcher handles it. Must be one of:
- `"core"`: The primary process. Usually only one exists. Selecting it clears and launches a base command.
- `"modifier"`: Arguments appended to the `"core"` command when active (e.g. `gazebo:=true`).
- `"interface"`: A user interface process launched in its own dedicated tmux window (e.g. CLI tools or background web services).
- `"tool"`: A utility process launched in its own dedicated tmux window (e.g. ROS standard tools or terminal sessions). | +| `dependencies` | `array` of `strings` | A list of `id`s that must be enabled before this package can be toggled on. The launcher will block you and show a warning if dependencies are missing. | +| `conflicts` | `array` of `strings` | A list of `id`s that cannot run alongside this package. Toggling this package on will automatically toggle the conflicting packages off. | +| `command` | `string` or `object` | The shell command to execute.
- **For `"core"`**: The base command (e.g. `ros2 launch ...`).
- **For `"modifier"`**: The argument string appended to the core command.
- **For `"interface"` / `"tool"`**: A simple string executed in a new tmux window, or a complex object containing `"start"`, `"stop"`, and `"is_running"` shell commands for custom background handling (like the web control panel). | +| `default_on` | `boolean` | If set to `true`, the package will be selected by default when the launcher boots up (currently unused as the launcher loads an empty initial state, but available for future functionality). | + +### Under the Hood (`launcher.py` and `launch_lucy.sh`) + +When you run `./launch_lucy.sh`: + +1. It builds and enters the Docker container. +2. It drops you into a **tmux** session named `lucy_ws`. +3. It automatically runs `launcher.py` (the TUI) in the main window. + +When you apply changes in `launcher.py`: +- **Core + Modifiers:** The script takes the core command, appends all active modifier commands, and spins up a dedicated `core` tmux window. +- **Interfaces / Tools:** The script spins up a new tmux window named after the package's `id` and executes its command. Alternatively, if a complex command object is provided, it executes the explicit `start` and `stop` shell commands in the background. diff --git a/install.sh b/install.sh index 40eb1ea..56e5bae 100755 --- a/install.sh +++ b/install.sh @@ -147,7 +147,7 @@ docker_workspace_install() { read -r -d '' inner_cmd <<'EOS' || true source /opt/ros/humble/setup.bash \ && cd /workspace \ - && rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common micro_ros_agent" \ + && rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common" \ && rm -rf build/camera_ros install/camera_ros \ && colcon build --symlink-install \ && if [ -f src/lucy_control_panel/package.json ]; then \ @@ -166,7 +166,7 @@ EOS if [ "$MODE" = "build-only" ]; then check_cmd docker docker_workspace_install - echo "Build complete. Run ./launch_lucy.sh to start the stack." + echo "Build complete. use `Launch` in `Lucy.py`" exit 0 fi @@ -233,4 +233,4 @@ done < <(parse_repos) # ---------------------------------------------------------------------------- docker_workspace_install -echo "Install complete. Run ./launch_lucy.sh to start the stack." +echo "Install complete. Run `Launch` in `Lucy.py`" diff --git a/launch_lucy.sh b/launch_lucy.sh index 532c386..0010136 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -120,46 +120,41 @@ SOURCE_WORKSPACE="cd $WORKSPACE && source install/setup.bash" LAUNCH_GAZEBO_RVIZ_BRIDGE_CP="ros2 launch lucy_bringup lucy.launch.py gazebo:=true rviz:=true" LAUNCH_RVIZ_BRIDGE_CP="ros2 launch lucy_bringup lucy.launch.py rviz:=true" -# Preamble run inside the container: source ROS + overlay, then start the Vite -# control panel in the background. An EXIT/INT/TERM trap stops Vite when the -# foreground command (bash -i in dev mode, or `ros2 launch` in normal mode) ends. +# Preamble run inside the container: source ROS + overlay. read -r -d '' CONTAINER_PREAMBLE <<'EOS' || true set -e source /opt/ros/humble/setup.bash cd /workspace if [[ ! -f install/setup.bash ]]; then - echo "Workspace not built. Run ./install.sh (or ./install.sh --build-only) on the host first." >&2 + echo "Workspace not built. Run Install/Update via Lucy.py" >&2 exit 1 fi source install/setup.bash +EOS -cleanup_lucy_bg() { - [[ -n "${CP_PID:-}" ]] && kill "$CP_PID" 2>/dev/null || true -} -trap cleanup_lucy_bg EXIT INT TERM - -CP_PID= -if [[ -f src/lucy_control_panel/package.json ]]; then - cd src/lucy_control_panel - if command -v yarn >/dev/null 2>&1; then - yarn dev > /tmp/lucy-control-panel-vite.log 2>&1 & - CP_PID=$! - elif command -v npm >/dev/null 2>&1; then - npm run dev > /tmp/lucy-control-panel-vite.log 2>&1 & - CP_PID=$! - else - echo "Control panel: yarn/npm missing in image; rebuild Docker image." >&2 - fi - cd /workspace - if [[ -n "${CP_PID:-}" ]]; then - disown "$CP_PID" 2>/dev/null || true - echo "Control panel (Vite) in background (PID $CP_PID). Host UI: http://localhost:${LUCY_CP_PUBLISHED_HOST_PORT}/ — log: tail -f /tmp/lucy-control-panel-vite.log" +# In DEV mode, attach to a tmux session. Exiting the last tmux window will exit the container. +read -r -d '' TMUX_SCRIPT <<'EOS' || true +if [ -z "$TMUX" ]; then + # Start tmux server and create session if it doesn't exist + tmux start-server + if ! tmux has-session -t lucy_ws 2>/dev/null; then + tmux new-session -d -s lucy_ws -n 'Lucy Workspace' fi + + # Send the command + tmux send-keys -t lucy_ws "launcher" C-m + + # Attach to session. + # When the last window is closed, the server exits, the script ends, and the container stops. + tmux attach-session -t lucy_ws +else + # Already inside tmux, do nothing special. + bash -i fi EOS INTERACTIVE_CONTAINER_SCRIPT="${CONTAINER_PREAMBLE} -bash -i +${TMUX_SCRIPT} " NORMAL_CONTAINER_SCRIPT="${CONTAINER_PREAMBLE} @@ -171,23 +166,9 @@ ${LAUNCH_GAZEBO_RVIZ_BRIDGE_CP} # ---------------------------------------------------------------------------- if [ $# -eq 0 ]; then - if [ "$DEV_MODE" = 1 ]; then - echo "DEV mode: interactive Humble shell (workspace already built by ./install.sh). Mount: $WORKSPACE" - echo " Control panel: http://localhost:${PORT_CONTROL_PANEL}/ — log: tail -f /tmp/lucy-control-panel-vite.log" - echo " Rosbridge on host: port ${PORT_ROSBRIDGE}" - echo "" - echo " Typical launches:" - echo " • Gazebo + RViz + Control Panel -> $LAUNCH_GAZEBO_RVIZ_BRIDGE_CP" - echo " • RViz + Control Panel -> $LAUNCH_RVIZ_BRIDGE_CP" - CONTAINER_SCRIPT="$INTERACTIVE_CONTAINER_SCRIPT" - else - echo "Starting Lucy stack: Control Panel + RViz + Gazebo (set DEV=true for an interactive shell)." - echo " Control panel: http://localhost:${PORT_CONTROL_PANEL}/ — log: tail -f /tmp/lucy-control-panel-vite.log" - echo " Rosbridge on host: port ${PORT_ROSBRIDGE}" - echo " Launching: $LAUNCH_GAZEBO_RVIZ_BRIDGE_CP" - CONTAINER_SCRIPT="$NORMAL_CONTAINER_SCRIPT" - fi + CONTAINER_SCRIPT="$INTERACTIVE_CONTAINER_SCRIPT" docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" --rm \ + --name lucy_dev \ "${DOCKER_PORT_ARGS[@]}" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "${X11_ARGS[@]}" \ @@ -196,6 +177,7 @@ if [ $# -eq 0 ]; then "$IMAGE_NAME" -c "$CONTAINER_SCRIPT" else docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" --rm \ + --name lucy_dev \ "${DOCKER_PORT_ARGS[@]}" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "${X11_ARGS[@]}" \ diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..d76fcbb --- /dev/null +++ b/launcher.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 + +import curses +import os +import sys +import subprocess +import time +import json + +CONFIG_FILE = "/workspace/config/launcher_config.json" +STATE_FILE = "/tmp/launcher_state.json" +MIN_TERM_HEIGHT = 22 +MIN_TERM_WIDTH = 65 + +def get_dev_mode(): + env_path = "/workspace/.env" + if not os.path.exists(env_path): + return False + with open(env_path, "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + +def is_in_docker(): + return os.path.exists('/.dockerenv') + +def is_in_tmux(): + return 'TMUX' in os.environ + +def load_config(): + if not os.path.exists(CONFIG_FILE): + raise FileNotFoundError(f"Configuration file not found at {CONFIG_FILE}") + with open(CONFIG_FILE, 'r') as f: + return json.load(f) + +def load_state(): + if not os.path.exists(STATE_FILE): + return {"modifiers": []} + with open(STATE_FILE, 'r') as f: + try: + return json.load(f) + except json.JSONDecodeError: + return {"modifiers": []} + +def save_state(state_data): + with open(STATE_FILE, 'w') as f: + json.dump(state_data, f) + +def run_shell_command(cmd, capture_output=False): + if capture_output: + return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode == 0 + else: + subprocess.run(cmd, shell=True) + +class Package: + def __init__(self, data, running_modifiers): + self.id = data['id'] + self.name = data['name'] + self.description = data.get('description', '') + self.type = data['type'] + self.dependencies = data.get('dependencies', []) + self.conflicts = data.get('conflicts', []) + self.command = data.get('command', '') + self.lifecycle_hooks = data.get('lifecycle_hooks', {}) + self.selected = data.get('default_on', False) + + # New property to store the actual running state + self.is_running = False + self.update_running_status(running_modifiers) + + # Initialize selected state to match running state initially + if self.is_running: + self.selected = True + + def update_running_status(self, running_modifiers): + if self.is_complex_command(): + self.is_running = run_shell_command(self.command['is_running'], capture_output=True) + elif self.type == 'modifier': + self.is_running = self.id in running_modifiers + elif self.type == 'core': + self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) + if not self.is_running: + save_state({"modifiers": []}) + elif self.type in ['tool', 'interface']: + self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) + + def is_complex_command(self): + return isinstance(self.command, dict) + +class LauncherState: + def __init__(self, config_data): + running_state = load_state() + self.packages = [Package(p, running_state['modifiers']) for p in config_data['packages']] + self.package_map = {p.id: p for p in self.packages} + + def get_by_id(self, pkg_id): + return self.package_map.get(pkg_id) + + def toggle(self, pkg_id): + pkg = self.get_by_id(pkg_id) + if not pkg: return None + if not pkg.selected: + missing_deps = [dep for dep in pkg.dependencies if not self.get_by_id(dep).selected] + if missing_deps: + return f"Needs: {', '.join(missing_deps)}" + for conflict_id in pkg.conflicts: + conflict_pkg = self.get_by_id(conflict_id) + if conflict_pkg and conflict_pkg.selected: + conflict_pkg.selected = False + pkg.selected = True + else: + for other_pkg in self.packages: + if pkg_id in other_pkg.dependencies and other_pkg.selected: + other_pkg.selected = False + pkg.selected = False + return None + +def draw_too_small_message(stdscr): + h, w = stdscr.getmaxyx() + stdscr.clear() + message = "Please increase terminal size" + message2 = f"({MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} required)" + stdscr.addstr(h // 2 - 1, max(0, (w - len(message)) // 2), message, curses.A_BOLD) + stdscr.addstr(h // 2, max(0, (w - len(message2)) // 2), message2, curses.A_DIM) + stdscr.refresh() + +def draw_tui(stdscr, state, current_idx, error_msg, status_msg): + h, w = stdscr.getmaxyx() + if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: + draw_too_small_message(stdscr) + return None + + stdscr.clear() + title = "Lucy Control Center" + stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) + stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit Docker", curses.A_BOLD) + + if status_msg: + stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD) + elif error_msg: + stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) + + cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier']] + interfaces = [p for p in state.packages if p.type == 'interface'] + tools = [p for p in state.packages if p.type == 'tool'] + display_list = cores_and_mods + interfaces + tools + + row = 2 + stdscr.addstr(row, 2, "Primary Launch Targets", curses.A_BOLD | curses.color_pair(1)) + row += 2 + for i, p in enumerate(cores_and_mods): + prefix = "> " if current_idx == i else " " + checkbox = "[x]" if p.selected else "[ ]" + can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies) + attr = curses.A_NORMAL if can_enable else curses.A_DIM + if p.type == 'core': attr |= curses.A_BOLD + indent = " " if p.type == 'modifier' else "" + stdscr.addstr(row + i, 4, f"{prefix}{indent}{checkbox} {p.name}", attr) + + row += len(cores_and_mods) + 1 + stdscr.addstr(row, 2, "Interfaces", curses.A_BOLD | curses.color_pair(3)) + row += 1 + for i, p in enumerate(interfaces): + list_idx = i + len(cores_and_mods) + prefix = "> " if current_idx == list_idx else " " + checkbox = "[x]" if p.selected else "[ ]" + stdscr.addstr(row + i, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) + + row += len(interfaces) + 1 + stdscr.addstr(row, 2, "Tools", curses.A_BOLD | curses.color_pair(3)) + row += 1 + for i, p in enumerate(tools): + list_idx = i + len(cores_and_mods) + len(interfaces) + prefix = "> " if current_idx == list_idx else " " + checkbox = "[x]" if p.selected else "[ ]" + stdscr.addstr(row + i, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) + + stdscr.refresh() + return display_list + +def apply_changes(state): + last_launched_window = None + core_pkg = state.get_by_id('core') + + # Check if core modifiers have changed + modifiers_changed = False + if core_pkg and core_pkg.selected: + selected_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.selected) + running_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.is_running) + if selected_modifier_ids != running_modifier_ids: + modifiers_changed = True + + # Force core restart if it's selected but modifiers changed + if modifiers_changed and core_pkg and core_pkg.selected: + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + # Update state to reflect it's stopped so it gets restarted + core_pkg.is_running = False + for mod in state.packages: + if mod.type == 'modifier': + if mod.is_running and 'stop' in mod.lifecycle_hooks: + run_shell_command(mod.lifecycle_hooks['stop']) + mod.is_running = False + + # First Pass: Stop processes that should be turned off (or were forced off) + for pkg in state.packages: + if not pkg.selected and pkg.is_running: + if pkg.is_complex_command(): + run_shell_command(pkg.command['stop']) + elif pkg.type == 'modifier' and 'stop' in pkg.lifecycle_hooks: + run_shell_command(pkg.lifecycle_hooks['stop']) + elif pkg.type == 'core': + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + elif pkg.type in ['tool', 'interface']: + run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") + pkg.is_running = False # Update local state + + # Second Pass: Start processes that should be turned on + for pkg in state.packages: + if pkg.selected and not pkg.is_running: + if pkg.is_complex_command(): + run_shell_command(pkg.command['start']) + elif pkg.type == 'core': + base_cmd = pkg.command + selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] + modifier_args = [p.command for p in selected_modifiers] + modifier_ids = [p.id for p in selected_modifiers] + full_cmd = f"{base_cmd} {' '.join(modifier_args)}" + run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}; echo \"--- Process finished, press any key to close ---\"; read'") + save_state({"modifiers": modifier_ids}) + elif pkg.type in ['tool', 'interface']: + run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") + last_launched_window = pkg.id + pkg.is_running = True # Update local state + + if last_launched_window: + run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}") + +def main(stdscr): + curses.curs_set(0) + stdscr.nodelay(0) + stdscr.timeout(-1) + curses.start_color() + curses.use_default_colors() + + if curses.has_colors(): + curses.init_pair(1, curses.COLOR_YELLOW, -1) + curses.init_pair(2, curses.COLOR_RED, -1) + curses.init_pair(3, curses.COLOR_CYAN, -1) + + state = LauncherState(load_config()) + current_idx = 0 + error_msg = None + status_msg = None + + if not get_dev_mode(): + core_pkg = state.get_by_id('core') + cp_pkg = state.get_by_id('control_panel') + should_apply_defaults = False + if core_pkg and not core_pkg.selected: + core_pkg.selected = True + should_apply_defaults = True + if cp_pkg and not cp_pkg.selected: + cp_pkg.selected = True + should_apply_defaults = True + if should_apply_defaults: + apply_changes(state) + status_msg = "Starting default services for production mode..." + state = LauncherState(load_config()) + + while True: + try: + display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg) + error_msg = None + status_msg = None + + if display_list is None: + # If display_list is None, it means the screen is too small. + # We switch to non-blocking getch to poll for resize events + stdscr.nodelay(1) + stdscr.timeout(100) + key = stdscr.getch() + if key != curses.KEY_RESIZE: + time.sleep(0.1) + continue + else: + # Normal operation, wait for input indefinitely + stdscr.nodelay(0) + stdscr.timeout(-1) + key = stdscr.getch() + + if key == curses.KEY_RESIZE: + continue + + if key == curses.KEY_UP: + current_idx = (current_idx - 1) % len(display_list) + elif key == curses.KEY_DOWN: + current_idx = (current_idx + 1) % len(display_list) + elif key == ord(' '): + pkg_to_toggle = display_list[current_idx] + error_msg = state.toggle(pkg_to_toggle.id) + elif key == ord('\n'): + apply_changes(state) + status_msg = "Configuration Applied!" + state = LauncherState(load_config()) + elif key in [ord('x'), ord('X')]: + h, w = stdscr.getmaxyx() + stdscr.addstr(h - 2, 2, "Stop all processes and exit Docker? (y/n)", curses.A_BOLD | curses.color_pair(2)) + stdscr.refresh() + confirm_key = stdscr.getch() + if confirm_key in [ord('y'), ord('Y')]: + return "ExitWorkspace", state + elif key in [ord('q'), ord('Q'), 27]: + return "Quit", None + + except curses.error: + # This will catch errors from addstr if the window is resized + # between the size check and the drawing. + time.sleep(0.1) + continue + +if __name__ == "__main__": + if not is_in_docker() or not is_in_tmux(): + print("Error: This script must be run inside the 'lucy_ws' tmux session within the Docker container.", file=sys.stderr) + sys.exit(1) + + status, state = None, None + try: + status, state = curses.wrapper(main) + except Exception as e: + # Clean up curses on any exception + curses.endwin() + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) + + if status == "ExitWorkspace": + print("\nStopping all processes and exiting workspace...") + if state: + for pkg in state.packages: + if pkg.is_complex_command(): + run_shell_command(pkg.command['stop']) + elif 'stop' in pkg.lifecycle_hooks: + run_shell_command(pkg.lifecycle_hooks['stop']) + if os.path.exists(STATE_FILE): + os.remove(STATE_FILE) + print("Terminating tmux session...") + time.sleep(0.5) + run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") + else: + pass diff --git a/windows/Lucy.py b/windows/Lucy.py new file mode 100644 index 0000000..3ac6da0 --- /dev/null +++ b/windows/Lucy.py @@ -0,0 +1,327 @@ +# This script provides a native Windows TUI for managing the Lucy workspace. +# It replicates the logic of the .sh scripts by calling git and docker directly. +# This script is designed to be compiled into a standalone .exe file. +# +# PREREQUISITES for running from source: +# 1. Python 3 +# 2. Git for Windows (must be in your PATH) +# 3. Docker Desktop for Windows (must be running) +# + +import os +import subprocess +import sys +import json + +# --- Platform Check --- +if sys.platform != "win32": + print("Error: This script is designed for Windows only.", file=sys.stderr) + sys.exit(1) + +# --- Configuration --- +# When running as a PyInstaller executable, the script is extracted to a temp folder. +# We need to determine the project root relative to the executable's location. +if getattr(sys, 'frozen', False): + # Running as a compiled executable + PROJECT_ROOT = os.path.dirname(sys.executable) +else: + # Running as a .py script + PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +ENV_FILE = os.path.join(PROJECT_ROOT, ".env") +REPOS_FILE = os.path.join(PROJECT_ROOT, "config", "repos.json") +DOCKERFILE = os.path.join(PROJECT_ROOT, "Dockerfile.humble") +IMAGE_NAME = "lucy_ros2:humble" +WORKSPACE_DIR_HOST = PROJECT_ROOT +WORKSPACE_DIR_CONTAINER = "/workspace" + +# --- Helper Functions --- + +def run_command(command, check=True, interactive=False): + """Runs a command, streaming its output if not interactive.""" + print(f"--- Running: {' '.join(command)} ---") + try: + if interactive: + # For interactive commands, run directly and attach to the terminal. + return subprocess.run(command, check=check).returncode + else: + # For non-interactive commands, capture and stream output. + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in iter(process.stdout.readline, ''): + print(line.strip()) + process.wait() + if check and process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, command) + return process.returncode + except FileNotFoundError: + print(f"Error: Command '{command[0]}' not found. Is it in your PATH?") + return -1 + except subprocess.CalledProcessError as e: + print(f"Command failed with exit code {e.returncode}") + return e.returncode + +def get_dev_mode(): + if not os.path.exists(ENV_FILE): + return False + with open(ENV_FILE, "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + +def set_dev_mode(is_enabled): + lines = [] + dev_found = False + if os.path.exists(ENV_FILE): + with open(ENV_FILE, "r") as f: + lines = f.readlines() + with open(ENV_FILE, "w") as f: + for line in lines: + if line.strip().startswith("DEV="): + f.write(f"DEV={str(is_enabled).lower()}\n") + dev_found = True + else: + f.write(line) + if not dev_found: + f.write(f"DEV={str(is_enabled).lower()}\n") + +def _format_volume_mapping(host_path, container_path): + """ + Return a Docker -v mapping string without extra quotes. + Normalize host path to an absolute path and use forward slashes to avoid + passing literal quote characters into the docker CLI. + """ + host_abs = os.path.abspath(host_path) + # Use forward slashes to reduce issues with escaping backslashes; + # Docker Desktop accepts Windows-style paths with forward slashes. + host_normalized = host_abs.replace('\\', '/') + return f"{host_normalized}:{container_path}" + +# --- Core Logic Functions --- + +def clone_or_update_repos(): + """Clones or updates repositories based on repos.json.""" + is_dev = get_dev_mode() + print(f"Developer mode is {'ON' if is_dev else 'OFF'}.") + + with open(REPOS_FILE, 'r') as f: + repos = json.load(f)['repos'] + + src_dir = os.path.join(PROJECT_ROOT, 'src') + os.makedirs(src_dir, exist_ok=True) + + for repo in repos: + repo_name = repo['name'] + repo_path = os.path.join(src_dir, repo_name) + url_key = 'url_ssh' if is_dev else 'url_https' + repo_url = repo[url_key] + branch = repo['branch'] + + if os.path.exists(os.path.join(repo_path, '.git')): + print(f"Updating repo: {repo_name}") + run_command(['git', '-C', repo_path, 'fetch']) + run_command(['git', '-C', repo_path, 'checkout', branch]) + run_command(['git', '-C', repo_path, 'pull']) + else: + print(f"Cloning repo: {repo_name}") + run_command(['git', 'clone', '-b', branch, repo_url, repo_path]) + +def build_docker_image(): + """Builds the main Docker image.""" + print("Building Docker image...") + run_command(['docker', 'build', '-t', IMAGE_NAME, '-f', DOCKERFILE, '.'], check=True) + +def build_workspace(): + """Runs the colcon build process inside the container.""" + print("Building workspace inside the container...") + inner_cmd = ( + 'source /opt/ros/humble/setup.bash && ' + 'cd /workspace && ' + 'rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common" && ' + 'colcon build --symlink-install && ' + 'if [ -f src/lucy_control_panel/package.json ]; then ' + '(cd src/lucy_control_panel && yarn install); ' + 'fi' + ) + volume_mapping = _format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER) + # Do NOT include an extra 'bash' argument; the image sets ENTRYPOINT to /bin/bash. + # Provide '-c' so the entrypoint receives the command string correctly. + docker_cmd = [ + 'docker', 'run', '--rm', + '-v', volume_mapping, + IMAGE_NAME, + '-c', inner_cmd + ] + run_command(docker_cmd) + + +def _docker_gui_args(): + """Return Docker args for optional GUI/X11 forwarding.""" + gui_display = os.environ.get('DOCKER_GUI_DISPLAY', os.environ.get('DISPLAY', '')).strip() + if sys.platform == 'win32' and not gui_display: + # Docker Desktop can reach the Windows X server at host.docker.internal. + gui_display = 'host.docker.internal:0' + + if not gui_display: + return [] + + args = ['-e', f'DISPLAY={gui_display}', '-e', 'QT_X11_NO_MITSHM=1'] + + if os.environ.get('DOCKER_GUI_USE_HOST_NETWORK'): + if sys.platform == 'win32': + print("WARNING: DOCKER_GUI_USE_HOST_NETWORK is not supported on Windows; using DISPLAY only.") + return args + return ['--network=host', '-e', 'DISPLAY=:0', '-e', 'QT_X11_NO_MITSHM=1'] + + if sys.platform == 'win32': + if 'host.docker.internal' in gui_display: + args.extend(['--add-host', 'host.docker.internal:host-gateway']) + return args + + return args + ['-v', '/tmp/.X11-unix:/tmp/.X11-unix:rw'] + + +def _parse_display_host_port(display_value): + if display_value.startswith(':'): + return 'localhost', 6000 + int(display_value[1:].split('.')[0]) + host, _, display_str = display_value.rpartition(':') + if not host: + host = 'localhost' + try: + display_num = int(display_str) + except ValueError: + display_num = 0 + return host, 6000 + display_num + + +def _docker_gui_diagnostics(gui_display, gui_args): + print("--- GUI diagnostics ---") + print(f"DISPLAY value used inside the container: {gui_display}") + host, port = _parse_display_host_port(gui_display) + print(f"Checking TCP connectivity to X server at {host}:{port}...") + + python_check = ( + "import os, socket, sys\n" + "display = os.environ.get('DISPLAY', '')\n" + "print('container DISPLAY=' + display)\n" + f"host = '{host}'\n" + f"port = {port}\n" + "try:\n" + " s = socket.create_connection((host, port), timeout=3)\n" + " print('OK: connected to', host, port)\n" + " s.close()\n" + "except Exception as e:\n" + " print('FAIL: could not connect to', host, port, e)\n" + " sys.exit(1)\n" + ) + + docker_cmd = ['docker', 'run', '--rm'] + gui_args + [IMAGE_NAME, '-c', f'python3 -c "{python_check}"'] + run_command(docker_cmd, check=False) + + +def launch_workspace(): + """Launches the main tmux session in the container.""" + print("Launching workspace...") + + container_script = ( + "source /opt/ros/humble/setup.bash && " + "cd /workspace && source install/setup.bash && " + "tmux start-server && " + "if ! tmux has-session -t lucy_ws 2>/dev/null; then " + "tmux new-session -d -s lucy_ws -n 'Lucy Workspace' 'python3 /workspace/launcher.py'; " + "fi && " + "tmux attach-session -t lucy_ws" + ) + + volume_mapping = _format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER) + gui_args = _docker_gui_args() + display_value = os.environ.get('DOCKER_GUI_DISPLAY', os.environ.get('DISPLAY', '')) + if sys.platform == 'win32' and not display_value: + display_value = 'host.docker.internal:0' + + if gui_args: + print(f"Enabling GUI forwarding with DISPLAY={display_value}") + _docker_gui_diagnostics(display_value, gui_args) + else: + print("No DISPLAY configured; running without GUI.") + + # Remove the extra 'bash' token; pass '-c' so the ENTRYPOINT (/bin/bash) runs the script. + docker_cmd = [ + 'docker', 'run', '-it', '--rm', + '--name', 'lucy_dev_win', + '-p', '9090:9090', + '-p', '5000:5000', + '-v', volume_mapping, + ] + gui_args + [ + IMAGE_NAME, + '-c', container_script + ] + + run_command(docker_cmd, interactive=True) + + +# --- Main TUI --- + +def main(): + while True: + is_dev_mode = get_dev_mode() + dev_status = "ON" if is_dev_mode else "OFF" + + print("\n--- Lucy Workspace Manager (Native Windows) ---") + print("1. Install (Full)") + print("2. Rebuild (Workspace only)") + print("3. Launch") + print("---------------------------------------------") + print(f"4. Toggle Developer Mode (Currently: {dev_status})") + print("5. Exit") + + try: + choice = input("\nEnter your choice (1-5): ").strip() + except KeyboardInterrupt: + break + + if choice == '1': + launch_workspace() + + elif choice == '2': + try: + clone_or_update_repos() + build_docker_image() + build_workspace() + print("--- Full install complete! ---") + input("\nPress Enter to continue...") + except Exception as e: + print(f"Install failed: {e}") + input("\nPress Enter to exit...") + + elif choice == '3': + try: + build_workspace() + print("--- Workspace rebuild complete! ---") + input("\nPress Enter to continue...") + except Exception as e: + print(f"Rebuild failed: {e}") + input("\nPress Enter to exit...") + + elif choice == '4': + set_dev_mode(not is_dev_mode) + print(f"Developer mode set to: {'ON' if not is_dev_mode else 'OFF'}") + input("\nPress Enter to continue...") + + elif choice == '5': + break + + else: + print("Invalid choice, please try again.") + input("\nPress Enter to continue...") + +if __name__ == "__main__": + # This needs to be at the top level for PyInstaller to see it. + os.chdir(PROJECT_ROOT) + try: + main() + except KeyboardInterrupt: + print("\nExiting.") + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..63bb868 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,39 @@ +# Windows Native TUI + +This directory contains a Windows-native version of the main TUI (`Lucy.py`). It provides the same functionality as the Linux/macOS script but is designed to be run directly on Windows. + +## How it Works + +The script is a standalone Python application that calls `git.exe` and `docker.exe` directly. It does not have any external dependencies and can be run in a standard Windows Command Prompt or PowerShell. + +It can also be compiled into a single `.exe` file using a tool like PyInstaller. + +## Prerequisites + +1. **Python 3**: Must be installed and in your system's PATH. +2. **Git for Windows**: Must be installed and in your system's PATH. +3. **Docker Desktop**: Must be installed and running. +4. **Windows X server**: Required for GUI apps such as `rqt` inside the Docker container. + - We recommend [VcXsrv](https://github.com/marchaesen/vcxsrv/releases). + - Start VcXsrv on display `0`, allow TCP connections, and disable access control if needed. + - Make sure Windows Firewall allows port `6000`. + +> If you intend to solely use the control panel visualizer alongside commande lines tools, you can skip the installation of a third-party Windows X Server. + +## Usage + +From the project root, run the script using Python: + +```bash +python windows/Lucy.py +``` + +You will be presented with a simple numbered menu to manage the workspace. + +## Terminal choice + +To run the project, you will need to have access to a terminal, you have multiple choices: + +- Default "command" application, will require `windows/Lucy.py` +- WSL, you will use the default `Lucy.py`. Be sure to enable WSL support in docker if you are using docker desktop +- Git bash, you will use the default `Lucy.py`. \ No newline at end of file