Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Davies 996dcc3437 Use 2024-11-08 gazebo release 2024-11-08 10:02:10 -04:00
26 changed files with 360 additions and 654 deletions

View File

@ -1,5 +0,0 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: v1.0.2
_src_path: https://git.spirirobotics.com/Spiri/template-docs.git
author_name: Spiri Robotics
project_name: spiri-sdk

2
.env
View File

@ -16,4 +16,4 @@ DRONE_MODEL="spiri_mu"
SIM_DRONE_COUNT=1
GCS_PORT=14550
GCS_PORT=14550

View File

@ -1,52 +0,0 @@
name: Build Docs
on:
push:
env:
REGISTRY: git.spirirobotics.com
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
container: sphinxdoc/sphinx-latexpdf
steps:
- name: Install sphinx-rtd-theme
run: pip install sphinx-rtd-theme myst-parser
- name: Install node so that custom actions work.
run : apt-get update && apt-get --yes install nodejs git
- name: Checkout Repository
uses: actions/checkout@v4
- name: Build Docs
run: make html latexpdf
working-directory: docs # assuming your documentation is in a 'docs' folder
- name: Save PDF Artifacts
run: mv docs/build/latex/*.pdf ${{ github.workspace }}/docs.pdf
- name: Compress HTML
run: tar -czvf docs_html.tar.gz -C docs/build/html .
- name: Upload Docs
uses: actions/upload-artifact@v3
with:
name: docs_html.tar.gz
path: docs_html.tar.gz
- name: Upload PDF
uses: actions/upload-artifact@v3
with:
name: docs.pdf
path: docs.pdf
- name: Deploy
uses: s0/git-publish-subdir-action@develop
env:
REPO: http://git-spirirobotics-com-app:3000/Spiri/spiri-sdk.git/
BRANCH: gh-pages
FOLDER: ./docs/build/html
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@ -1,4 +1,3 @@
docs/build/
.vscode
*.pyc
survey/
*.pyc

View File

@ -36,25 +36,6 @@ ROS1 is considered end of life. It's recomended to use a ROS2 template instead
We're working on it...
## Special docker options
We support the following special docker options to make metadata available inside the SDK
* x-spiri-sdk-doc: ""
This will appear as a comment when creating a new robot
* x-spiri-sdk-default-enabled: true
Set this to false and that docker compose will be commented out by default when creating a new robot
* x-spiri-sdk-default-args: ""
Extra arguments to pass to docker compose. Should accept all docker compose arguments.
Common options would be `--build`. You might also appreciate `--pull ("always"|"missing"|"never")`.
## NVIDIA Container Toolkit
[Source](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
@ -91,6 +72,28 @@ sudo nvidia-ctk runtime configure --runtime=docker --cdi.enabled
sudo systemctl restart docker
```
##### Rootless Mode
To configure the container runtime for Docker running in [Rootless mode](https://docs.docker.com/engine/security/rootless/), follow these steps:
1. Configure the container runtime by using the nvidia-ctk command:
```bash
nvidia-ctk runtime configure --runtime=docker --config=$HOME/.config/docker/daemon.json
```
2. Restart the Rootless Docker daemon
```bash
systemctl --user restart docker
```
3. Configure /etc/nvidia-container-runtime/config.toml by using the sudo nvidia-ctk command:
```bash
sudo nvidia-ctk config --set nvidia-container-cli.no-cgroups --in-place
```
### Sample Workload
After you install and configure the toolkit and install an NVIDIA GPU Driver, you can verify your installation by running a sample workload.

View File

@ -1,3 +1,5 @@
version: "3.8"
services:
gui-tools:
env_file:
@ -7,42 +9,44 @@ services:
environment:
# Display settings
DISPLAY: "${DISPLAY}"
WAYLAND_DISPLAY: "${WAYLAND_DISPLAY}"
# Uncomment below if using X11
# QT_QPA_PLATFORM: "${QT_QPA_PLATFORM:-xcb}"
XDG_RUNTIME_DIR: "${XDG_RUNTIME_DIR}"
ROS_MASTER_URI: "http://ros-master:11311"
RMW_IMPLEMENTATION: rmw_cyclonedds_cpp
SDK_ROOT: ${PWD}
- DISPLAY=${DISPLAY}
- WAYLAND_DISPLAY=${WAYLAND_DISPLAY}
# - QT_QPA_PLATFORM=${QT_QPA_PLATFORM:-xcb} # Default to X11
# If running with Wayland
- XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}
- ROS_MASTER_URI=http://ros-master:11311
# - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
# - NVIDIA_VISIBLE_DEVICES=all
volumes:
# X11 socket
- /tmp/.X11-unix:/tmp/.X11-unix
- ${XAUTHORITY:-~/.Xauthority}:/root/.Xauthority
# Wayland socket
#- ${XDG_RUNTIME_DIR}/wayland-0:${XDG_RUNTIME_DIR}/wayland-0
# Access to GPU devices
# Allow access to the host's GPU
- /dev/dri:/dev/dri
# Code and configuration
#Auto reload on code changes
- ./guiTools/spiri_sdk_guitools/:/app/spiri_sdk_guitools/
# Enable launching the SDK from the SDK
# - ./:/app/sdk
- ./robots:/robots
- /var/run/docker.sock:/var/run/docker.sock
devices:
# Provide access to GPU devices (supports non-NVIDIA GPUs)
# Provide access to GPU devices
- /dev/dri:/dev/dri
network_mode: host
ports:
- 8923:8923
ipc: host
privileged: true # Required for GPU access
#user: "${UID}:${GID}"
privileged: true # Allow privileged access if necessary (e.g., for GPU access)
# restart: unless-stopped
# command: /bin/bash -c "source /opt/ros/foxy/setup.bash && rvis2" # Replace with the actual command to run Gazebo Ignition
deploy:
resources:
reservations:
devices:
- driver: cdi
device_ids:
- nvidia.com/gpu=all
# deploy:
# resources:
# reservations:
# devices:
# - driver: cdi # Optional for advanced resource scheduling
# device_ids:
# - "all" # Use "all" for all available GPUs

View File

@ -1,20 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@ -1,11 +0,0 @@
If you have a correctly configured sphinx environment you can build this project
using `make html latexpdf`.
You can also use nektos/act to build this project in the same way our build does.
```bash
cd ../ #Make sure you're in the project root, you should have a hidden folder
# named ./.github/workflows available.
act --artifact-server-path ./doc-build
```
Your compiled doc project will now be in the ./doc-build folder.

View File

@ -1,35 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

View File

@ -1,34 +0,0 @@
.wy-side-nav-search {
background: #FFFFFF !important;
}
.wy-nav-side {
background-color: #FFFFFF !important;
}
/* Add borders and box-shadow */
.wy-side-nav {
border: 1px solid #899CA3 !important;
}
.logo {
width: 100px !important;
}
.wy-menu-vertical a {
color: #899CA3 !important; /* Change to your desired color */
}
.document-title {
color: #000 !important;
font-size: 24px !important;
text-transform: uppercase !important;
}
.icon-home {
font-weight: bold !important;
text-transform: uppercase !important;
color: #000 !important;
}

View File

@ -1,70 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
import sphinx_rtd_theme
project = "spiri-sdk"
copyright = "2024, Spiri Robotics"
author = "Spiri Robotics"
html_logo = "logos/SPIRI_STLockup_Mixed_RGB.png" # For HTML output
html_logo_width = '200px'
latex_logo = "logos/SPIRI_STLockup_Mixed_RGB.png"
latex_logo_width = '5cm'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [
"myst_parser",
"sphinx.ext.duration",
"sphinx.ext.doctest",
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
]
numfig = True
todo_include_todos = True
todo_emit_warnings = True
todo_link_only = True
templates_path = ["_templates"]
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "sphinx_rtd_theme"
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
html_static_path = ['_static']
html_css_files = [
'custom.css',
]
html_theme_options = {
'collapse_navigation': True,
'sticky_navigation': True,
'navigation_depth': 4, #could be set to -1 if we want unlimited depth
'includehidden': True,
'titles_only': False
}
latex_engine = "xelatex"
# Configure LaTeX options for PDF generation
latex_show_urls = 'footnote'
# Enable syntax highlighting for PDFs
pygments_style = 'sphinx'

View File

@ -1,21 +0,0 @@
.. spiri-sdk documentation master file, created by
sphinx-quickstart on Wed Feb 14 11:51:45 2024.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to spiri-sdk's documentation!
============================================
.. toctree::
:maxdepth: 2
:caption: Contents:
.. include:: ../../README.md
:parser: myst_parser.sphinx_
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,6 +1,7 @@
FROM osrf/ros:jazzy-desktop-full
RUN apt-get update && apt-get -y install qterminal mesa-utils \
RUN apt-get update
RUN apt-get -y install qterminal mesa-utils \
libgstreamer1.0-dev \
libgstreamer-plugins-base1.0-dev \
docker-compose \
@ -10,13 +11,12 @@ RUN apt-get update && apt-get -y install qterminal mesa-utils \
gstreamer1.0-gl \
gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \
ros-${ROS_DISTRO}-rmw-cyclonedds-cpp
gstreamer1.0-plugins-ugly
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
COPY --from=git.spirirobotics.com/spiri/gazebo-resources:main /worlds /ardupilot_gazebo/worlds
COPY --from=git.spirirobotics.com/spiri/gazebo-resources:2024-11-08 /plugins /ardupilot_gazebo/plugins
COPY --from=git.spirirobotics.com/spiri/gazebo-resources:2024-11-08 /models /ardupilot_gazebo/models
COPY --from=git.spirirobotics.com/spiri/gazebo-resources:2024-11-08 /worlds /ardupilot_gazebo/worlds
ENV GZ_SIM_SYSTEM_PLUGIN_PATH=/ardupilot_gazebo/plugins
ENV GZ_SIM_RESOURCE_PATH=/ardupilot_gazebo/models:/ardupilot_gazebo/worlds

113
guiTools/poetry.lock generated
View File

@ -1,19 +1,5 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aioconsole"
version = "0.8.1"
description = "Asynchronous console and interfaces for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "aioconsole-0.8.1-py3-none-any.whl", hash = "sha256:e1023685cde35dde909fbf00631ffb2ed1c67fe0b7058ebb0892afbde5f213e5"},
{file = "aioconsole-0.8.1.tar.gz", hash = "sha256:0535ce743ba468fb21a1ba43c9563032c779534d4ecd923a46dbd350ad91d234"},
]
[package.extras]
dev = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-repeat", "uvloop"]
[[package]]
name = "aiodocker"
version = "0.23.0"
@ -166,30 +152,6 @@ yarl = ">=1.12.0,<2.0"
[package.extras]
speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
[[package]]
name = "aiomonitor"
version = "0.7.1"
description = "Adds monitor and Python REPL capabilities for asyncio applications"
optional = false
python-versions = ">=3.8"
files = [
{file = "aiomonitor-0.7.1-py3-none-any.whl", hash = "sha256:10f50418ef8e60cd4b57efb3d2b984f62e01b3a7272772c6916e54f26877fd09"},
{file = "aiomonitor-0.7.1.tar.gz", hash = "sha256:beb1f14429bc4a3135bbac32381d242fe2019d74fcf9c86d3f4bd7405dc562e4"},
]
[package.dependencies]
aioconsole = ">=0.7.0"
aiohttp = ">=3.8.5"
attrs = ">=20"
click = ">=8.0"
janus = ">=1.0"
jinja2 = ">=3.1.2"
prompt-toolkit = ">=3.0"
telnetlib3 = ">=2.0.4"
terminaltables = "*"
trafaret = ">=2.1.1"
typing-extensions = ">=4.1"
[[package]]
name = "aiosignal"
version = "1.3.1"
@ -728,17 +690,6 @@ files = [
{file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"},
]
[[package]]
name = "janus"
version = "1.1.0"
description = "Mixed sync-async queue to interoperate between asyncio tasks and classic threads"
optional = false
python-versions = ">=3.9"
files = [
{file = "janus-1.1.0-py3-none-any.whl", hash = "sha256:9a3daf0f1a16abda1a7c976e28dc1f6caf3b8d1de9b8c93b2ea84de424de7705"},
{file = "janus-1.1.0.tar.gz", hash = "sha256:0634df8b2b31f8afda4311abcf7fea912686fef717d13769eeaa01ae08d2b84c"},
]
[[package]]
name = "jinja2"
version = "3.1.4"
@ -1144,20 +1095,6 @@ files = [
{file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"},
]
[[package]]
name = "prompt-toolkit"
version = "3.0.48"
description = "Library for building powerful interactive command lines in Python"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"},
{file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"},
]
[package.dependencies]
wcwidth = "*"
[[package]]
name = "propcache"
version = "0.2.0"
@ -1835,43 +1772,6 @@ anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]]
name = "telnetlib3"
version = "2.0.4"
description = "Python 3 asyncio Telnet server and client Protocol library"
optional = false
python-versions = ">=3.7"
files = [
{file = "telnetlib3-2.0.4-py2.py3-none-any.whl", hash = "sha256:b3c0f984a7fb1b6ee16e6fdaa410c56389b0dc492174a99c6661b1ba4c9d457d"},
{file = "telnetlib3-2.0.4.tar.gz", hash = "sha256:dbcbc16456a0e03a62431be7cfefff00515ab2f4ce2afbaf0d3a0e51a98c948d"},
]
[[package]]
name = "terminaltables"
version = "3.1.10"
description = "Generate simple tables in terminals from a nested list of strings."
optional = false
python-versions = ">=2.6"
files = [
{file = "terminaltables-3.1.10-py2.py3-none-any.whl", hash = "sha256:e4fdc4179c9e4aab5f674d80f09d76fa436b96fdc698a8505e0a36bf0804a874"},
{file = "terminaltables-3.1.10.tar.gz", hash = "sha256:ba6eca5cb5ba02bba4c9f4f985af80c54ec3dccf94cfcd190154386255e47543"},
]
[[package]]
name = "trafaret"
version = "2.1.1"
description = "Validation and parsing library"
optional = false
python-versions = "*"
files = [
{file = "trafaret-2.1.1-py3-none-any.whl", hash = "sha256:1966f432586797aed663edd54cbc201fd7ba59eed1638f1a7a33f17977b3a569"},
{file = "trafaret-2.1.1.tar.gz", hash = "sha256:d9d00800318fbd343fdfb3353e947b2ebb5557159c844696c5ac24846f76d41c"},
]
[package.extras]
objectid = ["pymongo (>=2.4.1)"]
rfc3339 = ["python-dateutil (>=1.5)"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
@ -2085,17 +1985,6 @@ files = [
[package.dependencies]
anyio = ">=3.0.0"
[[package]]
name = "wcwidth"
version = "0.2.13"
description = "Measures the displayed width of unicode strings in a terminal"
optional = false
python-versions = "*"
files = [
{file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
]
[[package]]
name = "websockets"
version = "13.1"
@ -2318,4 +2207,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "bf8122985963391df3c60a13513784f7533309ec22095a82cac34034b6dae399"
content-hash = "7607ff77762b700b8f487fd9b5ef6c07e875eccd429feace40047276d2f84280"

View File

@ -14,7 +14,6 @@ sh = "^2.1.0"
docker = "^7.1.0"
aiodocker = "^0.23.0"
numpy = "^2.1.3"
aiomonitor = "^0.7.1"
[build-system]
requires = ["poetry-core"]

32
guiTools/spawn_drones.sh Normal file
View File

@ -0,0 +1,32 @@
#!/bin/bash
set -e #PR #6
source /opt/ros/$ROS_DISTRO/setup.bash
if [[ -z $SIM_DRONE_COUNT ]]
then
echo "SIM_DRONE_COUNT environment variable is not set."
exit 1
fi
gz sim -v -r $WORLD_FILE_NAME &
while true
do
topics=$(gz topic -l)
if [[ $topics == *"/world/$WORLD_NAME"* ]]
then
break
fi
sleep 1
done
cd /ardupilot_gazebo/models/$DRONE_MODEL
for (( j=0; j<$SIM_DRONE_COUNT; j++ ));
do
xacro -v gstreamer_udp_port:=$(($GSTREAMER_UDP_PORT + ($j * 10))) fdm_port_in:=$(($FDM_PORT_IN + ($j * 10))) model.xacro.sdf -o model.sdf
#! string is better than using -file option. File is not up to date in the next iteration.
value=$(</ardupilot_gazebo/models/$DRONE_MODEL/model.sdf)
gz_drone_name=spiri-$(($j + 1))
#? Maybe read from text file for formation of drones? x y z r p y
ros2 run ros_gz_sim create -world $WORLD_NAME -string "$value" -name $gz_drone_name -x $j -y 0 -z 0.195
done
exec "$@"

View File

@ -3,7 +3,6 @@ import subprocess
from collections import defaultdict
import docker
import time
from loguru import logger
docker_client = docker.from_env()
@ -13,6 +12,7 @@ applications = {
"rqt": ["rqt"],
"rviz2": ["rviz2"],
"Gazebo": ["/gz_entrypoint.sh"],
"Gazebo Standalone": "gz sim -v4".split(),
# Add more applications here if needed
}
@ -27,7 +27,8 @@ def launch_app(command):
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_types
from spiri_sdk_guitools.sim_drone import Robot
import aiodocker
import asyncio
@ui.page('/')
@ -63,20 +64,17 @@ async def main():
#Add a new robot
with new_robot_widget:
for name, robot in robot_types.items():
ui.label(f"Add new {name}").classes("text-3xl")
await robot.launch_widget(robots_widget)
ui.label("Add new robot").classes("text-3xl")
newRobotParams = defaultdict(binding.BindableProperty)
ui.number(value=1, label="SysID", min=1, max=254,
).bind_value(newRobotParams, 'sysid')
ui.textarea(value="/robots/spiri-mu/core/docker-compose.yaml", 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))
import aiomonitor
async def amonitor():
#
logger.info("Starting aiomonitor")
loop = asyncio.get_running_loop()
run_forever = loop.create_future()
with aiomonitor.start_monitor(loop):
await run_forever
app.on_startup(amonitor)
newRobotParams['sysid'] += 1
ui.button("Add", on_click=new_robot)
# Start the NiceGUI application
ui.run(title="Spiri SDK Launcher", port=8923, dark=None)

View File

@ -5,15 +5,12 @@ from typing import List
import os
import sh
import subprocess
from nicegui import ui, run, app, binding
import yaml
from nicegui import ui, run, app
import docker
import aiodocker
import asyncio
from spiri_sdk_guitools.video_button import EnableStreamingButton
from collections import defaultdict
import importlib.util
docker_client = docker.from_env()
@ -22,15 +19,12 @@ from rclpy.node import Node
import rclpy
import threading
async def ros_loop():
def ros_main() -> None:
rclpy.init()
node = rclpy.create_node('async_subscriber')
while rclpy.ok():
rclpy.spin_once(node, timeout_sec=0)
await asyncio.sleep(0.1)
app.on_startup(ros_loop())
app.on_startup(lambda: threading.Thread(target=ros_main).start())
@contextlib.contextmanager
@ -78,30 +72,8 @@ async def container_logs(container, element):
# ui.html(conv.convert(bytes(log,'utf-8').decode('utf-8', 'xmlcharrefreplace'), full=False))
robot_types = {}
class Robot:
def __init_subclass__(self):
#Register sub-classes as plugins
robot_types[self.robot_type] = self
for file in Path("/robots").glob("**/robot_plugins.py"):
logger.info(f"Loading plugin {file}")
spec = importlib.util.spec_from_file_location("robot_plugins", file)
plugin = importlib.util.module_from_spec(spec)
spec.loader.exec_module(plugin)
logger.info(f"Loaded plugin {file}")
async def topic_subscriber(element, topic_name):
while True:
with element:
element.clear()
ui.label(topic_name)
await asyncio.sleep(1)
class Spirimu(Robot):
robot_type = "spiri_mu"
robot_type = "spiri-mu"
def __init__(self, sysid: int, compose_files: List[Path] | str):
if sysid > 255 or sysid < 0:
@ -115,130 +87,51 @@ class Spirimu(Robot):
self.processes = []
self.video_button = None
robots.add(self)
#Ros doesn't like dashes in node names
self.robot_name = f"{self.robot_type}_{self.sysid}".replace("-","_")
# self.wold_name = os.environ.get("WORLD_NAME", "citadel_hill")
@classmethod
async def launch_widget(cls, robots_widget):
newRobotParams = defaultdict(binding.BindableProperty)
ui.number(value=1, label="SysID", min=1, max=254,
).bind_value(newRobotParams, 'sysid')
default_robot_compose = ""
for compose_file in Path("/robots").glob("**/docker-compose.yaml"):
specialArgs = ""
try:
data = yaml.safe_load(compose_file.read_text())
except yaml.YAMLError:
default_robot_compose += f"#{compose_file} not valid \n"
continue
specialArgs = data.get("x-spiri-sdk-default-args", "")
enabled = str(data.get("x-spiri-sdk-default-enabled", True))
enabled = enabled.lower() in ["true", "yes", "1"]
docstring = data.get("x-spiri-sdk-doc", "")
if docstring:
for line in docstring.split("\n"):
if line:
default_robot_compose += f"# {line}\n"
if not enabled:
default_robot_compose += "##"
default_robot_compose += f"{compose_file} {specialArgs}\n"
ui.label("Compose files").classes("text-xl")
ui.codemirror(value=default_robot_compose, language="bash", theme="basicDark").bind_value(newRobotParams, 'compose_files')
async def new_robot():
compose_files = []
#Split on comma or newline, and remove comments
for line in newRobotParams['compose_files'].split('\n'):
line = line.split('#')[0].strip()
if line:
compose_files.append(line)
current_robot = newRobotParams.copy()
current_robot['compose_files'] = compose_files
robot = cls(**current_robot)
asyncio.tasks.create_task(robot.ui(robots_widget))
newRobotParams['sysid'] += 1
ui.button("Add", on_click=new_robot)
async def ui_containers(self, element):
docker_elements = {}
container_status = {}
with element:
while True:
with logger.catch():
# Poll for data that changes
for container in self.containers():
try:
health = container.attrs["State"]["Health"]["Status"]
except KeyError:
health = "Unknown"
# 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().classes("w-full")
with docker_elements[container]:
ui.label().bind_text(container_status, container).classes(
"text-2xl"
)
#Show the command the container is running
# ui.label(container.attrs["Config"]["Cmd"])
cmd_widget = ui.codemirror(" ".join(container.attrs["Config"]["Cmd"]), language="bash",theme="basicDark").classes('h-auto max-h-32')
cmd_widget.enabled = False
with ui.expansion("Env Variables").classes("w-full outline outline-1").style("margin: 10px;"):
env_widget = ui.codemirror("\n".join(container.attrs["Config"]["Env"]), language="bash",theme="basicDark")
env_widget.enabled = False
logelement = (
ui.expansion("Logs")
.style("margin: 10px;")
.classes("w-full outline outline-1")
)
asyncio.create_task(container_logs(container, logelement))
with ui.expansion("Full details").classes("w-full outline outline-1").style("margin: 10px;"):
details_widget = ui.codemirror(yaml.dump(container.attrs), language="yaml",theme="basicDark")
details_widget.enabled = False
# Check for containers that have been removed
removed = set(docker_elements.keys()) - set(self.containers())
for container in removed:
self.robot_ui.remove(docker_elements[container])
docker_elements.pop(container)
await asyncio.sleep(1)
container_status[container] = (
f"{container.name} {container.status} {health}"
)
if container not in docker_elements:
docker_elements[container] = ui.element().classes("w-full")
with docker_elements[container]:
ui.label().bind_text(container_status, container).classes(
"text-lg"
)
logelement = (
ui.expansion("Logs")
.style("margin: 10px;")
.classes("w-full outline outline-1")
)
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:
self.robot_ui.remove(docker_elements[container])
docker_elements.pop(container)
await asyncio.sleep(1)
async def ui_ros(self, element):
with element:
node_dummy = Node("_ros2cli_dummy_to_show_topic_list")
scroll_area = ui.scroll_area()
def refresh_topics():
with scroll_area:
with scroll_area:
while True:
scroll_area.clear()
#Filter for topics that start with self.robot_name
for topic in node_dummy.get_topic_names_and_types():
if self.robot_name in topic[0]:
ui.label(topic[0])
# expander = ui.expansion(topic[0]).classes('w-full')
# topic_subscriber(expander, topic[0])
ui.button("Refresh topics", on_click=refresh_topics).classes("m-2")
async def ui_actions(self, element):
pass
with element:
node_dummy = Node("_ros2cli_dummy_to_show_service_list")
scroll_area = ui.scroll_area()
def refresh_topics():
with scroll_area:
scroll_area.clear()
#Filter for topics that start with self.robot_name
for topic in node_dummy.get_action_names_and_types():
if self.robot_name in topic[0]:
ui.label(topic[0])
ui.button("Refresh topics", on_click=refresh_topics).classes("m-2")
ui.label(topic)
await asyncio.sleep(10)
async def ui(self, element):
adocker = aiodocker.Docker()
@ -250,7 +143,7 @@ class Spirimu(Robot):
ui.label(f"""Sysid: {self.sysid}""")
ui.button("Start", on_click=self.async_start).classes("m-2")
ui.button("Stop", on_click=self.async_stop).classes("m-2")
self.video_button = EnableStreamingButton(robot_name=self.robot_name).classes(
self.video_button = EnableStreamingButton(sysid=self.sysid).classes(
"m-2"
)
@ -263,14 +156,12 @@ class Spirimu(Robot):
with ui.tabs() as tabs:
tab_containers = ui.tab("Containers")
tab_ros = ui.tab("ROS Topics")
# tab_actions = ui.tab("Actions")
with ui.tab_panels(tabs, value=tab_containers):
tab = ui.tab_panel(tab_containers).classes("w-full")
asyncio.create_task(self.ui_containers(tab))
with ui.tab_panels(tabs, value=tab_ros):
tab = ui.tab_panel(tab_ros).classes("w-full")
asyncio.create_task(self.ui_ros(tab))
# tab = ui.tab_panel(tab_actions).classes("w-full")
# asyncio.create_task(self.ui_actions(tab))
async def async_stop(self):
return await run.io_bound(self.stop)
@ -281,7 +172,7 @@ class Spirimu(Robot):
if isinstance(self.video_button, EnableStreamingButton):
self.video_button.stop_video()
# Delete gazebo model
self.delete_gz_model()
self.delete_gz_model(sysid=self.sysid)
# Signal all processes to stop
for process in self.processes:
process.terminate()
@ -293,7 +184,7 @@ class Spirimu(Robot):
def containers(self):
return docker_client.containers.list(
all=True, filters={"name": f"robot-sim-{self.robot_name}"}
all=True, filters={"name": f"robot-sim-{self.robot_type}-{self.sysid}"}
)
async def async_start(self):
@ -316,47 +207,35 @@ class Spirimu(Robot):
SITL_PORT=str(int(env["SITL_PORT"]) + 10 * instance),
INSTANCE=str(instance),
DRONE_SYS_ID=str(self.sysid),
ROBOT_NAME=self.robot_name,
WORLD_NAME=env["WORLD_NAME"],
):
self.spawn_gz_model()
self.spawn_gz_model(self.sysid)
logger.info("Starting drone stack, this may take some time")
for compose_file in self.compose_files:
arguments = compose_file.split(" ")
arguments = [arg.strip() for arg in arguments]
compose_file = arguments[0]
arguments = arguments[1:]
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")
#Get the folder the compose file is in
compose_folder = compose_file.parent
args = [
"docker-compose",
"--profile",
"uav-sim",
"-p",
f"robot-sim-{self.robot_name}-{compose_folder.name}",
f"robot-sim-{self.robot_type}-{sysid}",
"-f",
compose_file.as_posix(),
"up",
*arguments,
]
command = " ".join(args)
logger.info(f"Starting drone stack with command: {command}")
docker_stack = subprocess.Popen(
args,
# stdout=subprocess.PIPE,
# stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
logger.info(f"Started drone stack with PID: {docker_stack.pid}")
@logger.catch
def spawn_gz_model(self):
sysid = self.sysid
@staticmethod
def spawn_gz_model(sysid):
logger.info("")
env = os.environ
GSTREAMER_UDP_PORT = env["GSTREAMER_UDP_PORT"]
@ -373,7 +252,7 @@ class Spirimu(Robot):
]
# This path is breaking if this drone_model folder doesnt exist!
# TODO: fix this model path for minimal code maintenance
ROS2_CMD = f"ros2 run ros_gz_sim create -world {WORLD_NAME} -file /ardupilot_gazebo/models/{DRONE_MODEL}/model.sdf -name {self.robot_name} -x {sysid - 1} -y 0 -z 0.195"
ROS2_CMD = f"ros2 run ros_gz_sim create -world {WORLD_NAME} -file /ardupilot_gazebo/models/{DRONE_MODEL}/model.sdf -name spiri-{sysid} -x {sysid - 1} -y 0 -z 0.195"
xacro_proc = subprocess.Popen(
XACRO_CMD,
@ -386,18 +265,17 @@ class Spirimu(Robot):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = ros2_gz_create_proc.communicate(timeout=3)
out, err = ros2_gz_create_proc.communicate(timeout=15)
ros2_gz_create_proc.kill()
return
@logger.catch
def delete_gz_model(self):
sysid = self.sysid
@staticmethod
def delete_gz_model(sysid):
env = os.environ
WORLD_NAME = env["WORLD_NAME"]
# http://osrf-distributions.s3.amazonaws.com/gazebo/api/7.1.0/classgazebo_1_1physics_1_1Entity.html
ENTITY_TYPE_MODEL = 0x00000002
REQUEST_ARG = f"name: '{self.robot_name}' type: {ENTITY_TYPE_MODEL}"
REQUEST_ARG = f"name: 'spiri-{sysid}' type: {ENTITY_TYPE_MODEL}"
GZ_SERVICE_CMD = [
"gz",
"service",
@ -417,5 +295,5 @@ class Spirimu(Robot):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = remove_entity_proc.communicate(timeout=3)
out, err = remove_entity_proc.communicate(timeout=15)
remove_entity_proc.kill()

View File

@ -4,13 +4,13 @@ from loguru import logger
import os
GZ_TOPIC_INFO = ["gz", "topic", "-i", "-t"]
ENABLE_STREAMING_TOPIC = "/world/{world_name}/model/{robot_name}/link/pitch_link/sensor/camera/image/enable_streaming"
ENABLE_STREAMING_TOPIC = "/world/{world_name}/model/spiri-{sysid}/link/pitch_link/sensor/camera/image/enable_streaming"
class EnableStreamingButton(ui.element):
def __init__(self, robot_name, state: bool = False) -> None:
def __init__(self, sysid, state: bool = False) -> None:
super().__init__()
self.robot_name = robot_name
self.sysid = sysid
self._state = state
self.button = None
with self.classes():
@ -24,7 +24,7 @@ class EnableStreamingButton(ui.element):
async def on_click(self) -> None:
spinner = ui.spinner(size="lg")
# So we don't block UI
result = await run.cpu_bound(self.enable_streaming, self.robot_name, not self._state)
result = await run.cpu_bound(self.enable_streaming, self.sysid, not self._state)
if result:
ui.notify("Success", type="positive]")
self.set_state(state=not self._state)
@ -43,17 +43,17 @@ class EnableStreamingButton(ui.element):
def stop_video(self):
if self._state != False:
self.enable_streaming(robot_name=self.robot_name, is_streaming=False)
self.enable_streaming(sysid=self.sysid, is_streaming=False)
self.set_state(state=False)
@staticmethod
def enable_streaming(robot_name: str, is_streaming: bool) -> bool:
def enable_streaming(sysid: int, is_streaming: bool) -> bool:
world_name = os.environ["WORLD_NAME"]
# Check if this topic has any subscribers i.e. model is up
gz_topic_list_proc = subprocess.Popen(
GZ_TOPIC_INFO
+ [ENABLE_STREAMING_TOPIC.format(world_name=world_name, robot_name=robot_name)],
+ [ENABLE_STREAMING_TOPIC.format(world_name=world_name, sysid=sysid)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
@ -72,7 +72,7 @@ class EnableStreamingButton(ui.element):
"gz",
"topic",
"-t",
ENABLE_STREAMING_TOPIC.format(world_name=world_name, robot_name=robot_name),
ENABLE_STREAMING_TOPIC.format(world_name=world_name, sysid=sysid),
"-m",
"gz.msgs.Boolean",
"-p",

View File

@ -1,14 +0,0 @@
x-spiri-sdk-doc: Capture camera images for mapping
x-spiri-sdk-default-enabled: false
services:
camera-capture:
ipc: host
network_mode: host
restart: unless-stopped
image: restreamio/gstreamer:2024-11-14T15-53-57Z-prod
volumes:
- ${SDK_ROOT}/survey/${ROBOT_NAME}:/survey/
environment:
- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
command: sh -c "rm -f /survey/*; gst-launch-1.0 -vc udpsrc port=$GSTREAMER_UDP_PORT close-socket=false auto-multicast=true ! application/x-rtp, payload=96 ! rtph264depay ! decodebin3 ! videoconvert ! videorate ! video/x-raw,framerate=1/2 ! jpegenc ! multifilesink location=/survey/img_%05d.jpg throttle-time=1"

19
robots/spiri-mu/core/.env Normal file
View File

@ -0,0 +1,19 @@
DRONE_SYS_ID=1
INSTANCE=0
SERIAL0_PORT=5760
SITL_PORT=5501
MAVROS2_PORT=14560
MAVROS1_PORT=14561
FDM_PORT_IN=9002
GSTREAMER_UDP_PORT=5600
ROS_MASTER_URI="http://0.0.0.0:11311"
ARDUPILOT_VEHICLE="-v copter -f gazebo-mu --model=JSON -L CitadelHill"
WORLD_FILE_NAME="citadel_hill_world.sdf"
WORLD_NAME="citadel_hill"
DRONE_MODEL="spiri_mu"
SIM_DRONE_COUNT=1
GCS_PORT=14550

View File

@ -1,8 +1,7 @@
x-spiri-sdk-doc: |
Runs the core services for a simulated Spiri Mu
services:
ardupilot:
env_file:
- .env
image: git.spirirobotics.com/spiri/ardupilot:spiri-master
command:
- /bin/bash
@ -15,13 +14,14 @@ services:
network_mode: host
mavproxy:
env_file:
- .env
image: git.spirirobotics.com/spiri/services-mavproxy:main
command: >
mavproxy.py --non-interactive
--master tcp:127.0.0.1:$SERIAL0_PORT
--out udpout:0.0.0.0:$MAVROS2_PORT
--out udpout:0.0.0.0:$MAVROS1_PORT
--out udpout:0.0.0.0:18761
--sitl 127.0.0.1:$SITL_PORT
--out udp:0.0.0.0:$GCS_PORT
ipc: host
@ -29,10 +29,10 @@ services:
restart: always
mavros2:
env_file:
- .env
image: git.spirirobotics.com/spiri/services-ros2-mavros:main
environment:
- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
command: ros2 launch mavros apm.launch fcu_url:="udp://0.0.0.0:$MAVROS2_PORT@:14555" namespace:="$ROBOT_NAME" tgt_system:="$DRONE_SYS_ID"
command: ros2 launch mavros apm.launch fcu_url:="udp://0.0.0.0:$MAVROS2_PORT@:14555" namespace:="spiri$DRONE_SYS_ID" tgt_system:="$DRONE_SYS_ID"
ipc: host
network_mode: host
restart: always
@ -54,6 +54,8 @@ services:
mavros:
#This service bridges our mavlink-based robot-coprosessor into ROS
#In this example it connects to a simulated coprocessor.
env_file:
- .env
image: git.spirirobotics.com/spiri/services-ros1-mavros:master
command: rosrun mavros mavros_node __name:=spiri$DRONE_SYS_ID _fcu_url:="udp://0.0.0.0:$MAVROS1_PORT@:14559" _target_system_id:="$DRONE_SYS_ID"
profiles:
@ -72,6 +74,8 @@ services:
hard: 524288
ros-master:
env_file:
- .env
image: git.spirirobotics.com/spiri/services-ros1-core:main
command: stdbuf -o L roscore
profiles:

View File

@ -1,7 +0,0 @@
FROM git.spirirobotics.com/spiri/services-ros2-mavros:main
RUN apt-get update && apt-get --yes install ros-${ROS_DISTRO}-ros-gz-bridge \
ros-${ROS_DISTRO}-ros-gz-image \
ros-${ROS_DISTRO}-compressed-image-transport \
ros-${ROS_DISTRO}-rmw-cyclonedds-cpp

View File

@ -1,17 +0,0 @@
x-spiri-sdk-doc: |
Start a virtual camera that can be included in ros
x-spiri-sdk-default-args: --build
services:
front-gimbal:
ipc: host
network_mode: host
# image: git.spirirobotics.com/spiri/services-ros2-mavros:main
#Build the iamge, give it a name, don't try to pull the image
build: ./
image: spirisdk-virtual_camera
pull_policy: never
environment:
- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
command: ros2 run ros_gz_image image_bridge /world/${WORLD_NAME}/model/${ROBOT_NAME}/link/pitch_link/sensor/camera/image

167
sim_drone.py Normal file
View File

@ -0,0 +1,167 @@
#!/bin/env python3
import typer
import os
import sys
import contextlib
from dotenv import load_dotenv
from typing import List
from loguru import logger
import sh
import atexit
load_dotenv()
logger.remove()
logger.add(
sys.stdout, format="<green>{time}</green> <level>{level}</level> {extra} {message}"
)
# px4Path = os.environ.get("SPIRI_SIM_PX4_PATH", "/opt/spiri-sdk/PX4-Autopilot/")
# logger.info(f"SPIRI_SIM_PX4_PATH={px4Path}")
app = typer.Typer()
# This is a list of processes that we need to .kill and .wait for on exit
processes = []
class outputLogger:
"""
Logs command output to loguru
"""
def __init__(self, name, instance):
self.name = name
self.instance = instance
def __call__(self, message):
with logger.contextualize(cmd=self.name, instance=self.instance):
if message.endswith("\n"):
message = message[:-1]
# ToDo, this doesn't work because the output is coloured
if message.startswith("INFO"):
message = message.lstrip("INFO")
logger.info(message)
elif message.startswith("WARN"):
message = message.lstrip("WARN")
logger.warning(message)
elif message.startswith("ERROR"):
message = message.lstrip("ERROR")
logger.error(message)
elif message.startswith("DEBUG"):
message = message.lstrip("DEBUG")
logger.debug(message)
else:
logger.info(message)
@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]
# @app.command()
def start(instance: int = 0, sys_id: int = 1):
"""Starts the simulated drone with a given sys_id,
each drone must have it's own unique ID.
"""
if sys_id < 1 or sys_id > 254:
logger.error("sys_id must be between 1 and 254")
raise typer.Exit(code=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(sys_id),
):
logger.info("Starting drone stack, this may take some time")
docker_stack = sh.docker.compose(
"--profile",
"uav-sim",
"-p",
f"robot-sim-{sys_id}",
"up",
_out=outputLogger("docker_stack", sys_id),
_err=sys.stderr,
_bg=True,
)
processes.append(docker_stack)
@app.command()
def start_group():
env = os.environ
sim_drone_count = int(env["SIM_DRONE_COUNT"])
start_ros_master()
"""Start a group of robots"""
for i in range(sim_drone_count):
logger.info(f"start robot {i}")
start(instance=i, sys_id=i + 1)
# if i == 0:
# wait_for_gazebo()
def start_ros_master():
docker_stack = sh.docker.compose(
"--profile",
"ros-master",
"up",
_out=outputLogger("docker_stack", "ros-master"),
_err=sys.stderr,
_bg=True,
)
processes.append(docker_stack)
def cleanup():
# Wait for all subprocesses to exit
logger.info("Waiting for commands to exit")
try:
if processes:
print(processes)
for waitable in processes:
waitable.kill()
waitable.wait()
except Exception as e:
print(e)
atexit.register(cleanup)
if __name__ == "__main__":
try:
app()
except KeyboardInterrupt:
logger.info("KeyboardInterrupt caught, exiting...")
cleanup()
sys.exit(0)