From eb8077f8eb69c7b89742b58352dcfa5824272183 Mon Sep 17 00:00:00 2001 From: Alex Davies Date: Wed, 6 Nov 2024 12:58:14 -0400 Subject: [PATCH] Update UI --- guiTools/Dockerfile | 1 + guiTools/poetry.lock | 62 ++++++++++++++++++++- guiTools/pyproject.toml | 2 + guiTools/spiri_sdk_guitools/launcher.py | 63 ++++++++++++++++++--- guiTools/spiri_sdk_guitools/sim_drone.py | 70 +++++++++++++++++++----- sim_drone.py | 2 +- 6 files changed, 174 insertions(+), 26 deletions(-) diff --git a/guiTools/Dockerfile b/guiTools/Dockerfile index 580441a..a27c41a 100644 --- a/guiTools/Dockerfile +++ b/guiTools/Dockerfile @@ -4,6 +4,7 @@ RUN apt-get update RUN apt-get -y install qterminal mesa-utils \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ + docker-compose \ python3.12-venv \ python3-pip \ gstreamer1.0-libav \ diff --git a/guiTools/poetry.lock b/guiTools/poetry.lock index 9f1f9e8..e6cd85c 100644 --- a/guiTools/poetry.lock +++ b/guiTools/poetry.lock @@ -369,6 +369,28 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "docker" +version = "7.1.0" +description = "A Python library for the Docker Engine API." +optional = false +python-versions = ">=3.8" +files = [ + {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, + {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, +] + +[package.dependencies] +pywin32 = {version = ">=304", markers = "sys_platform == \"win32\""} +requests = ">=2.26.0" +urllib3 = ">=1.26.0" + +[package.extras] +dev = ["coverage (==7.2.7)", "pytest (==7.4.2)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.1.0)", "ruff (==0.1.8)"] +docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] +ssh = ["paramiko (>=2.4.3)"] +websockets = ["websocket-client (>=1.3.0)"] + [[package]] name = "docutils" version = "0.21.2" @@ -1483,6 +1505,33 @@ pyside2 = ["PySide2", "QtPy"] pyside6 = ["PySide6", "QtPy"] qt = ["PyQt5", "QtPy", "pyqtwebengine"] +[[package]] +name = "pywin32" +version = "308" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +files = [ + {file = "pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e"}, + {file = "pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e"}, + {file = "pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c"}, + {file = "pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a"}, + {file = "pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b"}, + {file = "pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6"}, + {file = "pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897"}, + {file = "pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47"}, + {file = "pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091"}, + {file = "pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed"}, + {file = "pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4"}, + {file = "pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd"}, + {file = "pywin32-308-cp37-cp37m-win32.whl", hash = "sha256:1f696ab352a2ddd63bd07430080dd598e6369152ea13a25ebcdd2f503a38f1ff"}, + {file = "pywin32-308-cp37-cp37m-win_amd64.whl", hash = "sha256:13dcb914ed4347019fbec6697a01a0aec61019c1046c2b905410d197856326a6"}, + {file = "pywin32-308-cp38-cp38-win32.whl", hash = "sha256:5794e764ebcabf4ff08c555b31bd348c9025929371763b2183172ff4708152f0"}, + {file = "pywin32-308-cp38-cp38-win_amd64.whl", hash = "sha256:3b92622e29d651c6b783e368ba7d6722b1634b8e70bd376fd7610fe1992e19de"}, + {file = "pywin32-308-cp39-cp39-win32.whl", hash = "sha256:7873ca4dc60ab3287919881a7d4f88baee4a6e639aa6962de25a98ba6b193341"}, + {file = "pywin32-308-cp39-cp39-win_amd64.whl", hash = "sha256:71b3322d949b4cc20776436a9c9ba0eeedcbc9c650daa536df63f0ff111bb920"}, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1583,6 +1632,17 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "sh" +version = "2.1.0" +description = "Python subprocess replacement" +optional = false +python-versions = "<4.0,>=3.8.1" +files = [ + {file = "sh-2.1.0-py3-none-any.whl", hash = "sha256:bf5e44178dd96a542126c2774e9b7ab1d89bfe0e2ef84d92e6d0ed7358d63d01"}, + {file = "sh-2.1.0.tar.gz", hash = "sha256:7e27301c574bec8ca5bf6f211851357526455ee97cd27a7c4c6cc5e2375399cb"}, +] + [[package]] name = "simple-websocket" version = "1.1.0" @@ -2064,4 +2124,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "941bf4750c3b8a3045cfd01bb1ab4af7c1e8fd2d5fa54cd85d8ac3a0c8837238" +content-hash = "1735aa5be80786db18fc032b6442a3cc1f9dcdbf0187d207080401e87de421ce" diff --git a/guiTools/pyproject.toml b/guiTools/pyproject.toml index e16ab0a..94f0d63 100644 --- a/guiTools/pyproject.toml +++ b/guiTools/pyproject.toml @@ -10,6 +10,8 @@ python = "^3.11" nicegui = "^2.5.0" pywebview = "^5.3.2" loguru = "^0.7.2" +sh = "^2.1.0" +docker = "^7.1.0" [build-system] requires = ["poetry-core"] diff --git a/guiTools/spiri_sdk_guitools/launcher.py b/guiTools/spiri_sdk_guitools/launcher.py index 59502f4..d1d447d 100644 --- a/guiTools/spiri_sdk_guitools/launcher.py +++ b/guiTools/spiri_sdk_guitools/launcher.py @@ -1,6 +1,10 @@ from nicegui import ui, binding, app import subprocess from collections import defaultdict +import docker +import time + +docker_client = docker.from_env() # Dictionary of applications: key is the button text, value is the command to execute applications = { @@ -25,6 +29,31 @@ ui.label("Spiri Robotics SDK").style('font-size: 40px; margin-bottom: 10px;').cl robots = [] from spiri_sdk_guitools.sim_drone import Robot +@ui.refreshable +def show_robots(): + + with ui.element().classes('w-full'): + for robot in robots: + @ui.refreshable + def container_status(robot): + ui.label("Containers") + for container in robot.containers(): + ui.label(f"{container.name} {container.status}") + with ui.card().style('margin: 10px;').classes('w-full'): + ui.label(f"{robot.robot_type}") + ui.label(f"""Sysid: {robot.sysid}""") + ui.button("Start", on_click=robot.start) + ui.button("Stop", on_click=robot.stop) + def delete_robot(): + robot.stop() + robots.remove(robot) + show_robots.refresh() + ui.button("Delete", on_click=delete_robot) + with ui.expansion("Details").style('margin: 10px;').classes('w-full'): + container_status(robot) + ui.timer(1, container_status.refresh) + logwidget = ui.expansion("Logs").style('margin: 10px;').classes('w-full') + @ui.page('/') def main(): @@ -32,28 +61,44 @@ def main(): tab_tools = ui.tab('Tools') tab_robots = ui.tab('Robots') # two = ui.tab('Two') - with ui.tab_panels(tabs, value=tab_tools).classes(): + with ui.tab_panels(tabs, value=tab_tools).classes("w-full"): with ui.tab_panel(tab_tools): # Create and place buttons dynamically based on the dictionary with ui.grid(columns=3): for app_name, command in applications.items(): ui.button(app_name, on_click=lambda cmd=command: launch_app(cmd)).style('width: 150px; height: 50px; margin: 5px;') with ui.tab_panel(tab_robots): - with ui.grid(columns=3): - for robot in robots: - with ui.row(): - ui.label(f"Robot {robot.sys_id}") - ui.button(f"Start Robot {robot.sys_id}", on_click=lambda: robot.start()).style('width: 150px; height: 50px; margin: 5px;') + #Add a new robot with ui.element(): ui.label("Add new robot") newRobotParams = defaultdict(binding.BindableProperty) - ui.number(value=0, label="SysID").bind_value(newRobotParams, 'sysid') - ui.input(value="./sdk/docker-compose.yml", label="Compose files (comma seperated)").bind_value(newRobotParams, 'compose_files') + ui.number(value=1, label="SysID", min=1, max=254, + ).bind_value(newRobotParams, 'sysid') + ui.textarea(value="./sdk/docker-compose.yml", label="Compose files (comma or newline seperated)").bind_value(newRobotParams, 'compose_files') def new_robot(): robot = Robot(**newRobotParams) + # robot.start() robots.append(robot) - ui.label(str(robots)) + newRobotParams['sysid'] += 1 + show_robots.refresh() ui.button("Add", on_click=new_robot) + with ui.element(): + ui.label("Debug") + def cleanup_all_containers(): + #Find all containers that start with spiri-sdk + containers = docker_client.containers.list(all=True) + removal_count = 0 + for container in containers: + if container.name.startswith("robot-sim-"): + container.remove(force=True) + removal_count += 1 + ui.label(f"Removed {removal_count} containers") + ui.button("Cleanup all containers", on_click=cleanup_all_containers) + + + ui.separator() + ui.label("Current robots") + robotwidget = show_robots() # Start the NiceGUI application ui.run(title="Spiri SDK Launcher", port=8923, dark=None) diff --git a/guiTools/spiri_sdk_guitools/sim_drone.py b/guiTools/spiri_sdk_guitools/sim_drone.py index e2d615e..1d12fdd 100644 --- a/guiTools/spiri_sdk_guitools/sim_drone.py +++ b/guiTools/spiri_sdk_guitools/sim_drone.py @@ -2,6 +2,13 @@ from loguru import logger from pathlib import Path import contextlib from typing import List +import os +import sh +import subprocess +from nicegui import ui + +import docker +docker_client = docker.from_env() @contextlib.contextmanager def modified_environ(*remove, **update): @@ -34,18 +41,38 @@ def modified_environ(*remove, **update): [env.pop(k) for k in remove_after] + class Robot: - def __init__(self, sysid: int, compose_files: List[Path]): + robot_type = "spiri-mu" + def __init__(self, sysid: int, compose_files: List[Path] | str ): if sysid > 255 or sysid < 0: raise ValueError("sysid must be between 0 and 255") + self.sysid = int(sysid) + if isinstance(compose_files, str): + compose_files = [ Path(file) for file in compose_files.replace("\n",",").split(",") ] + self.compose_files = compose_files self.processes = [] + def stop(self): + #Signal all processes to stop + for process in self.processes: + process.terminate() + process.kill() + self.processes = [] + for container in self.containers(): + container.stop() + container.remove(force=True) + + def containers(self): + return docker_client.containers.list(all=True, filters={"name": f"robot-sim-{self.robot_type}-{self.sysid}"}) + def start(self): - """Starts the simulated drone with a given sys_id, + """Starts the simulated drone with a given sysid, each drone must have it's own unique ID. """ instance = self.sysid-1 - with logger.contextualize(syd_id=sys_id): + sysid = self.sysid + with logger.contextualize(syd_id=sysid): env = os.environ with modified_environ( SERIAL0_PORT=str(int(env["SERIAL0_PORT"]) + 10 * instance), @@ -54,17 +81,30 @@ class Robot: FDM_PORT_IN=str(int(env["FDM_PORT_IN"]) + 10 * instance), SITL_PORT=str(int(env["SITL_PORT"]) + 10 * instance), INSTANCE=str(instance), - DRONE_SYS_ID=str(self.sys_id), + DRONE_SYS_ID=str(self.sysid), ): logger.info("Starting drone stack, this may take some time") - docker_stack = sh.docker.compose( - "--profile", - "uav-sim", - "-p", - f"spiri-sdk-{sys_id}", - "up", - _out=outputLogger("docker_stack", sys_id), - _err=sys.stderr, - _bg=True, - ) - self.processes.append(docker_stack) + for compose_file in self.compose_files: + if not isinstance(compose_file, Path): + compose_file = Path(compose_file) + if not compose_file.exists(): + raise FileNotFoundError(f"File {compose_file} does not exist") + ui.label(f"Starting drone stack {compose_file}") + args = [ + "docker-compose", + "--profile", + "uav-sim", + "-p", + f"robot-sim-{self.robot_type}-{sysid}", + "-f", + compose_file.as_posix(), + "up", + ] + command = " ".join(args) + + logger.info(f"Starting drone stack with command: {command}") + docker_stack = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) diff --git a/sim_drone.py b/sim_drone.py index 14d40f4..45f8945 100644 --- a/sim_drone.py +++ b/sim_drone.py @@ -109,7 +109,7 @@ def start(instance: int = 0, sys_id: int = 1): "--profile", "uav-sim", "-p", - f"spiri-sdk-{sys_id}", + f"robot-sim-{sys_id}", "up", _out=outputLogger("docker_stack", sys_id), _err=sys.stderr,