Move GUI toolkit from tkinter to nicegui for easier prototyping #7

Merged
traverseda merged 12 commits from feature/webui into master 2024-11-07 13:13:32 -04:00
6 changed files with 174 additions and 26 deletions
Showing only changes of commit eb8077f8eb - Show all commits

View File

@ -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 \

62
guiTools/poetry.lock generated
View File

@ -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"

View File

@ -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"]

View File

@ -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)

View File

@ -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(
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"spiri-sdk-{sys_id}",
f"robot-sim-{self.robot_type}-{sysid}",
"-f",
compose_file.as_posix(),
"up",
_out=outputLogger("docker_stack", sys_id),
_err=sys.stderr,
_bg=True,
]
command = " ".join(args)
logger.info(f"Starting drone stack with command: {command}")
docker_stack = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.processes.append(docker_stack)

View File

@ -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,