diff --git a/docker-compose.yml b/docker-compose.yml index 37f6d3d..b070297 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,10 @@ services: # Allow access to the host's GPU - /dev/dri:/dev/dri #Auto reload on code changes - - ./guiTools/spiri_sdk_guitools/:/app/spiri_sdk_guitools + - ./guiTools/spiri_sdk_guitools/:/app/spiri_sdk_guitools/ + # Enable launching the SDK from the SDK + - ./:/app/sdk + - /var/run/docker.sock:/var/run/docker.sock devices: # Provide access to GPU devices - /dev/dri:/dev/dri diff --git a/guiTools/Dockerfile b/guiTools/Dockerfile index 90ecd04..580441a 100644 --- a/guiTools/Dockerfile +++ b/guiTools/Dockerfile @@ -4,15 +4,14 @@ RUN apt-get update RUN apt-get -y install qterminal mesa-utils \ libgstreamer1.0-dev \ libgstreamer-plugins-base1.0-dev \ + python3.12-venv \ + python3-pip \ gstreamer1.0-libav \ gstreamer1.0-gl \ gstreamer1.0-plugins-good \ gstreamer1.0-plugins-bad \ gstreamer1.0-plugins-ugly -#Install poetry -RUN curl -sSL https://install.python-poetry.org | python3 - -ENV PATH="/root/.local/bin:$PATH" COPY --from=git.spirirobotics.com/spiri/gazebo-resources:main /plugins /ardupilot_gazebo/plugins COPY --from=git.spirirobotics.com/spiri/gazebo-resources:main /models /ardupilot_gazebo/models @@ -26,14 +25,21 @@ RUN chmod +x /spawn_drones.sh WORKDIR /app -COPY ./pyproject.toml /app/pyproject.toml -COPY ./poetry.lock /app/poetry.lock +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +RUN pip3 install poetry + +COPY ./pyproject.toml ./poetry.lock ./README.md ./ +COPY ./spiri_sdk_guitools ./spiri_sdk_guitools + +RUN poetry env use python3 +RUN poetry config virtualenvs.create false && poetry install --no-dev --no-interaction --no-ansi -RUN poetry install --no-root -COPY ./spiri_sdk_guitools /app/spiri_sdk_guitools CMD poetry run python3 spiri_sdk_guitools/launcher.py + diff --git a/guiTools/poetry.lock b/guiTools/poetry.lock index 5714b21..9f1f9e8 100644 --- a/guiTools/poetry.lock +++ b/guiTools/poetry.lock @@ -666,6 +666,24 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "loguru" +version = "0.7.2" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5" +files = [ + {file = "loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb"}, + {file = "loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"] + [[package]] name = "markdown2" version = "2.5.1" @@ -1919,6 +1937,20 @@ files = [ {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, ] +[[package]] +name = "win32-setctime" +version = "1.1.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + [[package]] name = "wsproto" version = "1.2.0" @@ -2032,4 +2064,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "cd634ccba7b1059ce9cf2e92503f3ccce24693405aa53f78509482f944c55d5b" +content-hash = "941bf4750c3b8a3045cfd01bb1ab4af7c1e8fd2d5fa54cd85d8ac3a0c8837238" diff --git a/guiTools/pyproject.toml b/guiTools/pyproject.toml index e606a8e..e16ab0a 100644 --- a/guiTools/pyproject.toml +++ b/guiTools/pyproject.toml @@ -1,16 +1,17 @@ [tool.poetry] -name = "spiri-sdk-guitools" +name = "spiri_sdk_guitools" version = "0.1.0" description = "" -authors = ["Alex Davies "] +authors = ["Spiri Robotics"] readme = "README.md" [tool.poetry.dependencies] python = "^3.11" nicegui = "^2.5.0" pywebview = "^5.3.2" - +loguru = "^0.7.2" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + diff --git a/guiTools/spiri_sdk_guitools/__init__.py b/guiTools/spiri_sdk_guitools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guiTools/spiri_sdk_guitools/launcher.py b/guiTools/spiri_sdk_guitools/launcher.py index d2200e9..59502f4 100644 --- a/guiTools/spiri_sdk_guitools/launcher.py +++ b/guiTools/spiri_sdk_guitools/launcher.py @@ -1,5 +1,6 @@ -from nicegui import ui +from nicegui import ui, binding, app import subprocess +from collections import defaultdict # Dictionary of applications: key is the button text, value is the command to execute applications = { @@ -21,19 +22,38 @@ def launch_app(command): # Create the NiceGUI interface ui.label("Spiri Robotics SDK").style('font-size: 40px; margin-bottom: 10px;').classes('w-full text-center') +robots = [] +from spiri_sdk_guitools.sim_drone import Robot -with ui.tabs().classes('w-full') as tabs: - 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_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.card().tight(): - ui.label(f"Robots SysID") +@ui.page('/') +def main(): + + with ui.tabs().classes('w-full') as tabs: + 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_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') + def new_robot(): + robot = Robot(**newRobotParams) + robots.append(robot) + ui.label(str(robots)) + 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 new file mode 100644 index 0000000..e2d615e --- /dev/null +++ b/guiTools/spiri_sdk_guitools/sim_drone.py @@ -0,0 +1,70 @@ +from loguru import logger +from pathlib import Path +import contextlib +from typing import List + +@contextlib.contextmanager +def modified_environ(*remove, **update): + """ + Temporarily updates the ``os.environ`` dictionary in-place. + + The ``os.environ`` dictionary is updated in-place so that the modification + is sure to work in all situations. + + :param remove: Environment variables to remove. + :param update: Dictionary of environment variables and values to add/update. + """ + env = os.environ + update = update or {} + remove = remove or [] + + # List of environment variables being updated or removed. + stomped = (set(update.keys()) | set(remove)) & set(env.keys()) + # Environment variables and values to restore on exit. + update_after = {k: env[k] for k in stomped} + # Environment variables and values to remove on exit. + remove_after = frozenset(k for k in update if k not in env) + + try: + env.update(update) + [env.pop(k, None) for k in remove] + yield + finally: + env.update(update_after) + [env.pop(k) for k in remove_after] + + +class Robot: + def __init__(self, sysid: int, compose_files: List[Path]): + if sysid > 255 or sysid < 0: + raise ValueError("sysid must be between 0 and 255") + self.processes = [] + + def start(self): + """Starts the simulated drone with a given sys_id, + each drone must have it's own unique ID. + """ + instance = self.sysid-1 + with logger.contextualize(syd_id=sys_id): + env = os.environ + with modified_environ( + SERIAL0_PORT=str(int(env["SERIAL0_PORT"]) + 10 * instance), + MAVROS2_PORT=str(int(env["MAVROS2_PORT"]) + 10 * instance), + MAVROS1_PORT=str(int(env["MAVROS1_PORT"]) + 10 * instance), + 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), + ): + 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)