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 \ RUN apt-get -y install qterminal mesa-utils \
libgstreamer1.0-dev \ libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \ libgstreamer-plugins-base1.0-dev \
docker-compose \
python3.12-venv \ python3.12-venv \
python3-pip \ python3-pip \
gstreamer1.0-libav \ 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"}, {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]] [[package]]
name = "docutils" name = "docutils"
version = "0.21.2" version = "0.21.2"
@ -1483,6 +1505,33 @@ pyside2 = ["PySide2", "QtPy"]
pyside6 = ["PySide6", "QtPy"] pyside6 = ["PySide6", "QtPy"]
qt = ["PyQt5", "QtPy", "pyqtwebengine"] 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]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.2" version = "6.0.2"
@ -1583,6 +1632,17 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 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]] [[package]]
name = "simple-websocket" name = "simple-websocket"
version = "1.1.0" version = "1.1.0"
@ -2064,4 +2124,4 @@ propcache = ">=0.2.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "941bf4750c3b8a3045cfd01bb1ab4af7c1e8fd2d5fa54cd85d8ac3a0c8837238" content-hash = "1735aa5be80786db18fc032b6442a3cc1f9dcdbf0187d207080401e87de421ce"

View File

@ -10,6 +10,8 @@ python = "^3.11"
nicegui = "^2.5.0" nicegui = "^2.5.0"
pywebview = "^5.3.2" pywebview = "^5.3.2"
loguru = "^0.7.2" loguru = "^0.7.2"
sh = "^2.1.0"
docker = "^7.1.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@ -1,6 +1,10 @@
from nicegui import ui, binding, app from nicegui import ui, binding, app
import subprocess import subprocess
from collections import defaultdict 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 # Dictionary of applications: key is the button text, value is the command to execute
applications = { applications = {
@ -25,6 +29,31 @@ ui.label("Spiri Robotics SDK").style('font-size: 40px; margin-bottom: 10px;').cl
robots = [] robots = []
from spiri_sdk_guitools.sim_drone import Robot 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('/') @ui.page('/')
def main(): def main():
@ -32,28 +61,44 @@ def main():
tab_tools = ui.tab('Tools') tab_tools = ui.tab('Tools')
tab_robots = ui.tab('Robots') tab_robots = ui.tab('Robots')
# two = ui.tab('Two') # 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): with ui.tab_panel(tab_tools):
# Create and place buttons dynamically based on the dictionary # Create and place buttons dynamically based on the dictionary
with ui.grid(columns=3): with ui.grid(columns=3):
for app_name, command in applications.items(): 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;') 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.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 #Add a new robot
with ui.element(): with ui.element():
ui.label("Add new robot") ui.label("Add new robot")
newRobotParams = defaultdict(binding.BindableProperty) newRobotParams = defaultdict(binding.BindableProperty)
ui.number(value=0, label="SysID").bind_value(newRobotParams, 'sysid') ui.number(value=1, label="SysID", min=1, max=254,
ui.input(value="./sdk/docker-compose.yml", label="Compose files (comma seperated)").bind_value(newRobotParams, 'compose_files') ).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(): def new_robot():
robot = Robot(**newRobotParams) robot = Robot(**newRobotParams)
# robot.start()
robots.append(robot) robots.append(robot)
ui.label(str(robots)) newRobotParams['sysid'] += 1
show_robots.refresh()
ui.button("Add", on_click=new_robot) 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 # Start the NiceGUI application
ui.run(title="Spiri SDK Launcher", port=8923, dark=None) ui.run(title="Spiri SDK Launcher", port=8923, dark=None)

View File

@ -2,6 +2,13 @@ from loguru import logger
from pathlib import Path from pathlib import Path
import contextlib import contextlib
from typing import List from typing import List
import os
import sh
import subprocess
from nicegui import ui
import docker
docker_client = docker.from_env()
@contextlib.contextmanager @contextlib.contextmanager
def modified_environ(*remove, **update): def modified_environ(*remove, **update):
@ -34,18 +41,38 @@ def modified_environ(*remove, **update):
[env.pop(k) for k in remove_after] [env.pop(k) for k in remove_after]
class Robot: 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: if sysid > 255 or sysid < 0:
raise ValueError("sysid must be between 0 and 255") 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 = [] 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): 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. each drone must have it's own unique ID.
""" """
instance = self.sysid-1 instance = self.sysid-1
with logger.contextualize(syd_id=sys_id): sysid = self.sysid
with logger.contextualize(syd_id=sysid):
env = os.environ env = os.environ
with modified_environ( with modified_environ(
SERIAL0_PORT=str(int(env["SERIAL0_PORT"]) + 10 * instance), 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), FDM_PORT_IN=str(int(env["FDM_PORT_IN"]) + 10 * instance),
SITL_PORT=str(int(env["SITL_PORT"]) + 10 * instance), SITL_PORT=str(int(env["SITL_PORT"]) + 10 * instance),
INSTANCE=str(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") logger.info("Starting drone stack, this may take some time")
docker_stack = sh.docker.compose( for compose_file in self.compose_files:
"--profile", if not isinstance(compose_file, Path):
"uav-sim", compose_file = Path(compose_file)
"-p", if not compose_file.exists():
f"spiri-sdk-{sys_id}", raise FileNotFoundError(f"File {compose_file} does not exist")
"up", ui.label(f"Starting drone stack {compose_file}")
_out=outputLogger("docker_stack", sys_id), args = [
_err=sys.stderr, "docker-compose",
_bg=True, "--profile",
) "uav-sim",
self.processes.append(docker_stack) "-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,
)

View File

@ -109,7 +109,7 @@ def start(instance: int = 0, sys_id: int = 1):
"--profile", "--profile",
"uav-sim", "uav-sim",
"-p", "-p",
f"spiri-sdk-{sys_id}", f"robot-sim-{sys_id}",
"up", "up",
_out=outputLogger("docker_stack", sys_id), _out=outputLogger("docker_stack", sys_id),
_err=sys.stderr, _err=sys.stderr,