diff --git a/guiTools/poetry.lock b/guiTools/poetry.lock index e6cd85c..e1a440d 100644 --- a/guiTools/poetry.lock +++ b/guiTools/poetry.lock @@ -1,5 +1,24 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "aiodocker" +version = "0.23.0" +description = "A simple Docker HTTP API wrapper written with asyncio and aiohttp." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "aiodocker-0.23.0-py3-none-any.whl", hash = "sha256:8c7ff2fc9e557898ae77bc9c1af8916f269285f230aedf1abbb81436054baed4"}, + {file = "aiodocker-0.23.0.tar.gz", hash = "sha256:45ede291063c7d1c24e78a766013c25e85b354a3bdcca68fe2bca64348e4dee2"}, +] + +[package.dependencies] +aiohttp = ">=3.8" + +[package.extras] +ci = ["aiohttp (==3.10.5)", "async-timeout (==4.0.3)", "multidict (==6.0.5)", "yarl (==1.11.1)"] +dev = ["async-timeout (==4.0.3)", "codecov (==2.1.13)", "mypy (==1.11.2)", "packaging (==24.1)", "pre-commit (>=3.5.0)", "pytest (==8.3.2)", "pytest-asyncio (==0.24.0)", "pytest-cov (==5.0.0)", "pytest-sugar (==1.0.0)", "ruff (==0.6.3)", "ruff-lsp (==0.0.54)", "towncrier (==24.8.0)"] +doc = ["alabaster (==1.0.0)", "sphinx (==8.0.2)", "sphinx-autodoc-typehints (==2.4.4)", "sphinxcontrib-asyncio (==0.3.0)"] + [[package]] name = "aiofiles" version = "24.1.0" @@ -158,6 +177,21 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "ansi2html" +version = "1.9.2" +description = "Convert text with ANSI color codes to HTML or to LaTeX" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ansi2html-1.9.2-py3-none-any.whl", hash = "sha256:dccb75aa95fb018e5d299be2b45f802952377abfdce0504c17a6ee6ef0a420c5"}, + {file = "ansi2html-1.9.2.tar.gz", hash = "sha256:3453bf87535d37b827b05245faaa756dbab4ec3d69925e352b6319c3c955c0a5"}, +] + +[package.extras] +docs = ["mkdocs", "mkdocs-material", "mkdocs-material-extensions", "mkdocstrings", "mkdocstrings-python", "pymdown-extensions"] +test = ["pytest", "pytest-cov"] + [[package]] name = "anyio" version = "4.6.2.post1" @@ -2124,4 +2158,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "1735aa5be80786db18fc032b6442a3cc1f9dcdbf0187d207080401e87de421ce" +content-hash = "fb89def5cebe48bacf2ecf52d3a52988c2ca964a523857d34ea037ff287120c6" diff --git a/guiTools/pyproject.toml b/guiTools/pyproject.toml index 94f0d63..ad579cd 100644 --- a/guiTools/pyproject.toml +++ b/guiTools/pyproject.toml @@ -12,6 +12,8 @@ pywebview = "^5.3.2" loguru = "^0.7.2" sh = "^2.1.0" docker = "^7.1.0" +aiodocker = "^0.23.0" +ansi2html = "^1.9.2" [build-system] requires = ["poetry-core"] diff --git a/guiTools/spiri_sdk_guitools/launcher.py b/guiTools/spiri_sdk_guitools/launcher.py index d1d447d..4ec9b4a 100644 --- a/guiTools/spiri_sdk_guitools/launcher.py +++ b/guiTools/spiri_sdk_guitools/launcher.py @@ -1,10 +1,12 @@ -from nicegui import ui, binding, app +from nicegui import ui, binding, app, run import subprocess from collections import defaultdict import docker import time docker_client = docker.from_env() +from ansi2html import Ansi2HTMLConverter +conv = Ansi2HTMLConverter() # Dictionary of applications: key is the button text, value is the command to execute applications = { @@ -28,35 +30,11 @@ 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') +import aiodocker +import asyncio @ui.page('/') -def main(): - +async def main(): with ui.tabs().classes('w-full') as tabs: tab_tools = ui.tab('Tools') tab_robots = ui.tab('Robots') @@ -68,21 +46,7 @@ def main(): 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): - - #Add a new robot - with ui.element(): - ui.label("Add new robot") - newRobotParams = defaultdict(binding.BindableProperty) - 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) - newRobotParams['sysid'] += 1 - show_robots.refresh() - ui.button("Add", on_click=new_robot) + new_robot_widget = ui.element() with ui.element(): ui.label("Debug") def cleanup_all_containers(): @@ -96,9 +60,23 @@ def main(): 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() + robots_widget = ui.element() + + #Add a new robot + with new_robot_widget: + ui.label("Add new robot") + newRobotParams = defaultdict(binding.BindableProperty) + 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') + async def new_robot(): + robot = Robot(**newRobotParams) + asyncio.tasks.create_task(robot.ui(robots_widget)) + + newRobotParams['sysid'] += 1 + ui.button("Add", on_click=new_robot) + # 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 1d12fdd..4a03dbe 100644 --- a/guiTools/spiri_sdk_guitools/sim_drone.py +++ b/guiTools/spiri_sdk_guitools/sim_drone.py @@ -5,9 +5,11 @@ from typing import List import os import sh import subprocess -from nicegui import ui +from nicegui import ui, run import docker +import aiodocker +import asyncio docker_client = docker.from_env() @contextlib.contextmanager @@ -40,7 +42,15 @@ def modified_environ(*remove, **update): env.update(update_after) [env.pop(k) for k in remove_after] +robots = set() +async def container_logs(container, element): + adocker = aiodocker.Docker() + with element: + acontainer = await adocker.containers.get(container.id) + async for log in acontainer.log(stdout=True, stderr=True, follow=True): + ui.label(log) + # ui.html(conv.convert(log) class Robot: robot_type = "spiri-mu" @@ -52,6 +62,49 @@ class Robot: compose_files = [ Path(file) for file in compose_files.replace("\n",",").split(",") ] self.compose_files = compose_files self.processes = [] + robots.add(self) + + async def async_stop(self): + return await run.io_bound(self.stop) + + async def ui(self, element): + adocker = aiodocker.Docker() + with element: + robot_ui = ui.element() + with robot_ui: + ui.label(f"{self.robot_type}") + ui.label(f"""Sysid: {self.sysid}""") + ui.button("Start", on_click=self.start) + ui.button("Stop", on_click=self.async_stop) + async def delete_robot(): + await self.async_stop() + robots.remove(self) + element.remove(robot_ui) + ui.button("Delete", on_click=delete_robot) + docker_elements = {} + container_status = {} + while True: + #Poll for data that changes + for container in self.containers(): + try: + health = container.attrs['State']['Health']['Status'] + except KeyError: + health = "Unknown" + + container_status[container] = f"{container.name} {container.status} {health}" + if container not in docker_elements: + docker_elements[container] = ui.element() + with docker_elements[container]: + ui.label().bind_text(container_status, container) + logelement = ui.expansion("Logs").style('margin: 10px;').classes('w-full') + asyncio.create_task(container_logs(container, logelement)) + #Check for containers that have been removed + removed = set(docker_elements.keys()) - set(self.containers()) + for container in removed: + robot_ui.remove(docker_elements[container]) + docker_elements.pop(container) + await asyncio.sleep(1) + def stop(self): #Signal all processes to stop @@ -60,7 +113,7 @@ class Robot: process.kill() self.processes = [] for container in self.containers(): - container.stop() + # container.stop() container.remove(force=True) def containers(self):