├── .gitattributes
├── src
└── flockwave
│ ├── server
│ ├── ext
│ │ ├── socketio
│ │ │ ├── vendor
│ │ │ │ ├── __init__.py
│ │ │ │ ├── engineio_v3
│ │ │ │ │ ├── async_drivers
│ │ │ │ │ │ └── __init__.py
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ └── exceptions.py
│ │ │ │ ├── engineio_v4
│ │ │ │ │ ├── async_drivers
│ │ │ │ │ │ └── __init__.py
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── exceptions.py
│ │ │ │ │ ├── json.py
│ │ │ │ │ ├── payload.py
│ │ │ │ │ ├── static_files.py
│ │ │ │ │ ├── trio_queue.py
│ │ │ │ │ └── packet.py
│ │ │ │ ├── socketio_v4
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── exceptions.py
│ │ │ │ │ └── trio_manager.py
│ │ │ │ └── socketio_v5
│ │ │ │ │ ├── __init__.py
│ │ │ │ │ ├── msgpack_packet.py
│ │ │ │ │ ├── exceptions.py
│ │ │ │ │ └── trio_manager.py
│ │ │ └── __init__.py
│ │ ├── __init__.py
│ │ ├── beacon
│ │ │ └── __init__.py
│ │ ├── debug
│ │ │ ├── __init__.py
│ │ │ └── extension.py
│ │ ├── webui
│ │ │ ├── __init__.py
│ │ │ ├── static
│ │ │ │ └── js
│ │ │ │ │ └── main.js
│ │ │ └── templates
│ │ │ │ ├── _restart_banner.html.j2
│ │ │ │ ├── tasks.html.j2
│ │ │ │ ├── _tags.html.j2
│ │ │ │ ├── _navbar.html.j2
│ │ │ │ ├── threads.html.j2
│ │ │ │ ├── version_info.html.j2
│ │ │ │ ├── extensions.html.j2
│ │ │ │ └── _layout.html.j2
│ │ ├── lps
│ │ │ ├── __init__.py
│ │ │ └── examples.py
│ │ ├── show
│ │ │ ├── __init__.py
│ │ │ ├── types.py
│ │ │ └── metadata.py
│ │ ├── missions
│ │ │ ├── __init__.py
│ │ │ ├── types.py
│ │ │ └── examples.py
│ │ ├── frontend
│ │ │ ├── __init__.py
│ │ │ ├── templates
│ │ │ │ └── index.html.j2
│ │ │ └── static
│ │ │ │ └── style.css
│ │ ├── http_server
│ │ │ └── __init__.py
│ │ ├── ssdp
│ │ │ ├── __init__.py
│ │ │ ├── types.py
│ │ │ └── registry.py
│ │ ├── virtual_uavs
│ │ │ ├── fw_upload.py
│ │ │ └── __init__.py
│ │ ├── mavlink
│ │ │ ├── autopilots
│ │ │ │ ├── __init__.py
│ │ │ │ ├── registry.py
│ │ │ │ └── unknown.py
│ │ │ ├── __init__.py
│ │ │ ├── rssi.py
│ │ │ ├── fw_upload.py
│ │ │ ├── errors.py
│ │ │ ├── led_lights.py
│ │ │ └── comm.py
│ │ ├── motion_capture
│ │ │ ├── types.py
│ │ │ └── frame.py
│ │ ├── rtk
│ │ │ ├── types.py
│ │ │ ├── __init__.py
│ │ │ ├── enums.py
│ │ │ └── registry.py
│ │ ├── crazyflie
│ │ │ ├── __init__.py
│ │ │ ├── types.py
│ │ │ ├── led_lights.py
│ │ │ └── math.py
│ │ ├── hotplug.py
│ │ ├── auto_shutdown.py
│ │ ├── virtual_clocks.py
│ │ ├── insomnia.py
│ │ ├── system_clock.py
│ │ ├── magnetic_field.py
│ │ ├── weather.py
│ │ └── virtual_connections.py
│ ├── version.py
│ ├── __init__.py
│ ├── logger.py
│ ├── __main__.py
│ ├── tasks
│ │ ├── __init__.py
│ │ └── waiting.py
│ ├── middleware
│ │ ├── __init__.py
│ │ ├── types.py
│ │ └── logging.py
│ ├── registries
│ │ ├── errors.py
│ │ └── __init__.py
│ ├── utils
│ │ ├── data_structures.py
│ │ ├── packaging.py
│ │ ├── validation.py
│ │ ├── networking.py
│ │ ├── __init__.py
│ │ └── formatting.py
│ ├── command_handlers
│ │ ├── __init__.py
│ │ ├── version.py
│ │ ├── test.py
│ │ ├── calibration.py
│ │ └── color.py
│ ├── types.py
│ ├── show
│ │ ├── lights.py
│ │ ├── __init__.py
│ │ └── safety.py
│ ├── model
│ │ ├── world.py
│ │ ├── weather.py
│ │ ├── errors.py
│ │ ├── user.py
│ │ ├── constants.py
│ │ ├── channel.py
│ │ ├── __init__.py
│ │ ├── connection.py
│ │ ├── attitude.py
│ │ ├── transport.py
│ │ ├── battery.py
│ │ ├── mixins.py
│ │ ├── flight_area.py
│ │ ├── gps.py
│ │ ├── error_set.py
│ │ └── authentication.py
│ └── errors.py
│ ├── proxy
│ ├── version.py
│ ├── logger.py
│ ├── config.py
│ ├── __init__.py
│ └── launcher.py
│ └── gateway
│ ├── version.py
│ ├── logger.py
│ ├── errors.py
│ ├── __init__.py
│ ├── launcher.py
│ ├── config.py
│ └── asgi_app.py
├── etc
├── deployment
│ ├── docker
│ │ ├── pip.conf
│ │ └── amd64
│ │ │ ├── entrypoint.sh
│ │ │ └── Dockerfile
│ └── rpi
│ │ ├── ufw.conf
│ │ ├── tty1-override.conf
│ │ ├── skybrush-console-frontend.json
│ │ ├── collmot-init.service
│ │ ├── skybrush.json
│ │ ├── network.cfg
│ │ └── run-tasks-at-boot
├── conf
│ ├── skybrush-indoor.jsonc
│ ├── skybrush-indoor-dual.jsonc
│ ├── skybrush-virtual.jsonc
│ ├── skybrush-outdoor.jsonc
│ ├── skybrush-outdoor-dual-ip.jsonc
│ └── skybrush-outdoor-dual-udp.jsonc
└── scripts
│ └── docker.sh
├── test
├── test_crazyflie_trajectory
│ ├── figure8.json.gz
│ └── show_5cf_demo.json.gz
├── test_ext_mavlink_autopilots_ardupilot
│ └── param.pck
├── test_utils_formatting.py
├── test_model.py
├── test_preflight_check.py
├── test_model_error_set.py
├── test_ext_mavlink_autopilots_ardupilot.py
├── test_show_utils.py
└── test_mission.py
├── .dockerignore
├── DEPENDENCIES.md
├── .gitignore
├── .pre-commit-config.yaml
├── .github
└── dependabot.yml
├── tbump.toml
├── doc
└── make.py
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.bat text eol=crlf
2 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v3/async_drivers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/async_drivers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/flockwave/proxy/version.py:
--------------------------------------------------------------------------------
1 | __version_info__ = (0, 6, 2)
2 | __version__ = ".".join("{0}".format(x) for x in __version_info__)
3 |
--------------------------------------------------------------------------------
/etc/deployment/docker/pip.conf:
--------------------------------------------------------------------------------
1 | [global]
2 | extra-index-url =
3 | https://pypi.fury.io/skybrush/
4 | https://pypi.collmot.com
5 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/version.py:
--------------------------------------------------------------------------------
1 | __version_info__ = (0, 6, 2)
2 | __version__ = ".".join("{0}".format(x) for x in __version_info__)
3 |
--------------------------------------------------------------------------------
/src/flockwave/server/version.py:
--------------------------------------------------------------------------------
1 | __version_info__ = (2, 40, 2)
2 | __version__ = ".".join("{0}".format(x) for x in __version_info__)
3 |
--------------------------------------------------------------------------------
/test/test_crazyflie_trajectory/figure8.json.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skybrush-io/skybrush-server/HEAD/test/test_crazyflie_trajectory/figure8.json.gz
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .tags
2 | .git
3 | .venv/
4 | *.egg-info
5 | *.pyc
6 | *.pyo
7 | *.swp
8 | build/
9 | dist/
10 | tmp/
11 | __pycache__
12 | Pipfile*
13 |
--------------------------------------------------------------------------------
/test/test_crazyflie_trajectory/show_5cf_demo.json.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skybrush-io/skybrush-server/HEAD/test/test_crazyflie_trajectory/show_5cf_demo.json.gz
--------------------------------------------------------------------------------
/test/test_ext_mavlink_autopilots_ardupilot/param.pck:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/skybrush-io/skybrush-server/HEAD/test/test_ext_mavlink_autopilots_ardupilot/param.pck
--------------------------------------------------------------------------------
/etc/deployment/rpi/ufw.conf:
--------------------------------------------------------------------------------
1 | [Skybrush]
2 | title=Skybrush server
3 | description=Skybrush is a server to control drone swarms.
4 | ports=1900/udp|5000/tcp|14550:15000/udp
5 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/__init__.py:
--------------------------------------------------------------------------------
1 | from pkgutil import extend_path
2 |
3 | # Declare "flockwave.server.ext" as a namespace package
4 | __path__ = extend_path(__path__, __name__)
5 |
--------------------------------------------------------------------------------
/src/flockwave/server/__init__.py:
--------------------------------------------------------------------------------
1 | """Main package for the Skybrush server."""
2 |
3 | from .version import __version__, __version_info__
4 |
5 | __all__ = ("__version__", "__version_info__")
6 |
--------------------------------------------------------------------------------
/src/flockwave/proxy/logger.py:
--------------------------------------------------------------------------------
1 | """Logger object for the Skybrush proxy server."""
2 |
3 | from flockwave.logger import log as base_log
4 |
5 | __all__ = ("log",)
6 |
7 | log = base_log.getChild("proxy")
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/logger.py:
--------------------------------------------------------------------------------
1 | """Logger object for the Skybrush server."""
2 |
3 | from flockwave.logger import log as base_log
4 |
5 | __all__ = ("log",)
6 |
7 | log = base_log.getChild("server")
8 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/logger.py:
--------------------------------------------------------------------------------
1 | """Logger object for the Skybrush gateway server."""
2 |
3 | from flockwave.logger import log as base_log
4 |
5 | __all__ = ("log",)
6 |
7 | log = base_log.getChild("gateway")
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/beacon/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that implements support for beacons."""
2 |
3 | from .extension import construct, description, schema
4 |
5 | __all__ = ("construct", "description", "schema")
6 |
--------------------------------------------------------------------------------
/src/flockwave/server/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | if __name__ == "__main__":
4 | # Do not use relative imports here; it will confuse PyInstaller
5 | from flockwave.server.launcher import start
6 |
7 | sys.exit(start())
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/debug/__init__.py:
--------------------------------------------------------------------------------
1 | """Adds debugging tools to the Skybrush server."""
2 |
3 | from .extension import dependencies, description, run, schema
4 |
5 | __all__ = ("dependencies", "description", "run", "schema")
6 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/__init__.py:
--------------------------------------------------------------------------------
1 | """Adds a web-based configuration user interface to the server."""
2 |
3 | from .extension import dependencies, run, index, schema
4 |
5 | __all__ = ("dependencies", "run", "schema", "index")
6 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v3/__init__.py:
--------------------------------------------------------------------------------
1 | """Implementation of an Engine.IO server using the 3rd revision of the
2 | Engine.IO protocol.
3 | """
4 |
5 | from .trio_server import TrioServer
6 |
7 | __all__ = ("TrioServer",)
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/static/js/main.js:
--------------------------------------------------------------------------------
1 | /* globals feather:false */
2 |
3 | skybrush = (function () {
4 | "use strict";
5 |
6 | feather.replace();
7 |
8 | $(document).ready(function () {});
9 |
10 | return {};
11 | })();
12 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/_restart_banner.html.j2:
--------------------------------------------------------------------------------
1 | {% if restart_requested %}
2 |
3 | You should restart the server now in order for the changes to take effect.
4 |
5 | {% endif %}
6 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/lps/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that provides some basic facilities for local positioning systems."""
2 |
3 | from .extension import construct, description, schema, tags
4 |
5 | __all__ = ("construct", "description", "schema", "tags")
6 |
--------------------------------------------------------------------------------
/DEPENDENCIES.md:
--------------------------------------------------------------------------------
1 | # Dependency notes
2 |
3 | This document lists the reasons why specific dependencies are pinned down to
4 | exact versions. Make sure to consider these points before updating dependencies
5 | to their latest versions.
6 |
7 | There are no notes yet.
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/show/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that prepares the server to be able to manage drone shows."""
2 |
3 | from .extension import construct, dependencies, description, schema
4 |
5 | __all__ = ("construct", "dependencies", "description", "schema")
6 |
--------------------------------------------------------------------------------
/etc/deployment/rpi/tty1-override.conf:
--------------------------------------------------------------------------------
1 | [Service]
2 | ExecStart=
3 | ExecStart=-/opt/skybrush/frontend/bin/skybrush-console-frontend -c /opt/skybrush/config/frontend.json -k
4 | StandardInput=tty
5 | StandardOutput=tty
6 | Environment="CML_LICENSE=/boot/collmot/skybrushd.cml"
7 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/errors.py:
--------------------------------------------------------------------------------
1 | """Error classes used in the gateway server."""
2 |
3 | __all__ = ("NoIdleWorkerError",)
4 |
5 |
6 | class NoIdleWorkerError(RuntimeError):
7 | """Exception thrown when there aren't any idle workers available."""
8 |
9 | pass
10 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/missions/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that provides some basic facilities for mission planning and
2 | uploading missions to UAVs.
3 | """
4 |
5 | from .extension import construct, description, schema, tags
6 |
7 | __all__ = ("construct", "description", "schema", "tags")
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | .coverage
3 | .pytest_cache
4 | .tags
5 | *.egg-info
6 | *.pyc
7 | *.pyo
8 | *.swp
9 | .eggs/
10 | build/
11 | dist/
12 | doc/build/
13 | doc/skybrush-server-docs-*.zip
14 | htmlcov/
15 | private/
16 | tmp/
17 | .venv/
18 | .vscode/
19 | __pycache__/
20 | skybrush.cfg
21 |
--------------------------------------------------------------------------------
/etc/deployment/rpi/skybrush-console-frontend.json:
--------------------------------------------------------------------------------
1 | {
2 | "APPS": [
3 | {
4 | "name": "Skybrush server",
5 | "command": [
6 | "/opt/skybrush/server/bin/skybrushd",
7 | "-c",
8 | "/boot/collmot/skybrush.json"
9 | ]
10 | }
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/__init__.py:
--------------------------------------------------------------------------------
1 | """Main package for the Skybrush demo gateway that spins up server instances
2 | as needed in order to provide a limited "playground" area.
3 | """
4 |
5 | from .version import __version__, __version_info__
6 |
7 | __all__ = ("__version__", "__version_info__")
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that extends the Skybrush server with support for incoming
2 | messages on a Socket.IO connection.
3 | """
4 |
5 | from .extension import dependencies, description, run, schema
6 |
7 | __all__ = ("dependencies", "description", "run", "schema")
8 |
--------------------------------------------------------------------------------
/etc/deployment/rpi/collmot-init.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=CollMot-specific initialization tasks
3 | After=local-fs.target
4 | Before=raspberrypi-net-mods.service
5 |
6 | [Service]
7 | ExecStart=/opt/skybrush/boot/run-tasks-at-boot
8 | Type=oneshot
9 |
10 | [Install]
11 | WantedBy=network.target
12 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/frontend/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that adds a simple frontend index page to the Skybrush server,
2 | served over HTTP.
3 | """
4 |
5 | from .extension import dependencies, description, exports, load, schema
6 |
7 | __all__ = ("dependencies", "description", "exports", "load", "schema")
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v4/__init__.py:
--------------------------------------------------------------------------------
1 | """Implementation of a Socket.IO server using the 4th revision of the
2 | Socket.IO protocol.
3 |
4 | Socket.IO rev 4 is based on Engine.IO rev 3.
5 | """
6 |
7 | from .trio_server import TrioServer
8 |
9 | __all__ = ("TrioServer",)
10 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/http_server/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that extends the Skybrush server with an HTTP server listening
2 | on a specific port.
3 | """
4 |
5 | from .extension import description, exports, load, run, schema, unload
6 |
7 | __all__ = ("description", "exports", "load", "run", "schema", "unload")
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/ssdp/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that allows the Skybrush server to be discoverable on the
2 | local network with UPnP/SSDP.
3 | """
4 |
5 | from .extension import description, exports, get_schema, load, run, unload
6 |
7 | __all__ = ("description", "exports", "get_schema", "load", "run", "unload")
8 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/__init__.py:
--------------------------------------------------------------------------------
1 | """Implementation of an Engine.IO server using the 4th revision of the
2 | Engine.IO protocol.
3 | """
4 |
5 | from .trio_server import TrioServer
6 |
7 | __all__ = ("TrioServer",)
8 | __version__ = "4.3.0" # this vendored library is based on python-engineio@4.3.0
9 |
--------------------------------------------------------------------------------
/etc/deployment/docker/amd64/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd /app
3 |
4 | . .venv/bin/activate
5 |
6 | MODULE="$1"
7 |
8 | if [ "x${MODULE}" = xserver ]; then
9 | shift
10 | elif [ "x${MODULE}" = xgateway ]; then
11 | shift
12 | else
13 | MODULE=server
14 | fi
15 |
16 | cd /data
17 | PYTHONPATH=/app/src python -m flockwave.${MODULE}.launcher "$@"
18 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/virtual_uavs/fw_upload.py:
--------------------------------------------------------------------------------
1 | """Functions and objects related to uploading a virtual firmware to a virtual
2 | UAV in order to allow testing and demonstrating the remote firmware update
3 | functionality.
4 | """
5 |
6 | __all__ = ("FIRMWARE_UPDATE_TARGET_ID",)
7 |
8 | FIRMWARE_UPDATE_TARGET_ID = "io.skybrush.server.virtual_uav.firmware"
9 |
--------------------------------------------------------------------------------
/src/flockwave/server/tasks/__init__.py:
--------------------------------------------------------------------------------
1 | """Package containing general asynchronous tasks that may be useful in
2 | multple places in the server.
3 | """
4 |
5 | from .alarm import wait_until
6 | from .progress import ProgressReporter
7 | from .waiting import wait_for_dict_items
8 |
9 | __all__ = ("ProgressReporter", "wait_for_dict_items", "wait_until")
10 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/autopilots/__init__.py:
--------------------------------------------------------------------------------
1 | """Implementations of autopilot-specific functionality."""
2 |
3 | from .ardupilot import ArduPilot, ArduPilotWithSkybrush
4 | from .base import Autopilot
5 | from .px4 import PX4
6 | from .unknown import UnknownAutopilot
7 |
8 | __all__ = ("ArduPilot", "ArduPilotWithSkybrush", "Autopilot", "PX4", "UnknownAutopilot")
9 |
--------------------------------------------------------------------------------
/src/flockwave/server/middleware/__init__.py:
--------------------------------------------------------------------------------
1 | """Request middleware for the message hub of the server.
2 |
3 | Request middleware objects receive incoming messages so that they can modify
4 | them, log them or filter them as needed.
5 | """
6 |
7 | from .types import RequestMiddleware, ResponseMiddleware
8 |
9 | __all__ = ("RequestMiddleware", "ResponseMiddleware")
10 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/motion_capture/types.py:
--------------------------------------------------------------------------------
1 | __all__ = ("Attitude", "Position")
2 |
3 | Position = tuple[float, float, float]
4 | """Type alias for 3D position data."""
5 |
6 | Attitude = tuple[float, float, float, float]
7 | """Type alias for attitude data, expressed as a quaternion in Hamilton
8 | conventions; i.e., the order of items is ``(w, x, y, z)``.
9 | """
10 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/rtk/types.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 |
3 | from flockwave.gps.nmea import NMEAPacket
4 | from flockwave.gps.rtcm.packets import RTCMPacket
5 | from flockwave.gps.ubx import UBXPacket
6 |
7 | __all__ = ("GPSPacket",)
8 |
9 | #: Union type matching all the GPS packets that we expect on the wire
10 | GPSPacket = Union[NMEAPacket, RTCMPacket, UBXPacket]
11 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v5/__init__.py:
--------------------------------------------------------------------------------
1 | """Implementation of a Socket.IO server using the 5th revision of the
2 | Socket.IO protocol.
3 |
4 | Socket.IO rev 4 is based on Engine.IO rev 4.
5 | """
6 |
7 | from .trio_server import TrioServer
8 |
9 | __all__ = ("TrioServer",)
10 | __version__ = "5.5.0" # this vendored library is based on python-socketio@5.5.0
11 |
--------------------------------------------------------------------------------
/etc/deployment/rpi/skybrush.json:
--------------------------------------------------------------------------------
1 | {
2 | "EXTENSIONS": {
3 | "frontend": {},
4 | "http_server": {
5 | "host": ""
6 | },
7 | "rtk": {
8 | "add_serial_ports": [9600, 57600],
9 | "exclude_serial_ports": "*ttyAMA0*"
10 | },
11 | "ssdp": {
12 | "label": "Skybrush RPi server"
13 | },
14 | "mavlink": {
15 | "enabled": true
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v3/exceptions.py:
--------------------------------------------------------------------------------
1 | class EngineIOError(Exception):
2 | pass
3 |
4 |
5 | class ContentTooLongError(EngineIOError):
6 | pass
7 |
8 |
9 | class UnknownPacketError(EngineIOError):
10 | pass
11 |
12 |
13 | class QueueEmpty(EngineIOError):
14 | pass
15 |
16 |
17 | class SocketIsClosedError(EngineIOError):
18 | pass
19 |
20 |
21 | class ConnectionError(EngineIOError):
22 | pass
23 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/exceptions.py:
--------------------------------------------------------------------------------
1 | class EngineIOError(Exception):
2 | pass
3 |
4 |
5 | class ContentTooLongError(EngineIOError):
6 | pass
7 |
8 |
9 | class UnknownPacketError(EngineIOError):
10 | pass
11 |
12 |
13 | class QueueEmpty(EngineIOError):
14 | pass
15 |
16 |
17 | class SocketIsClosedError(EngineIOError):
18 | pass
19 |
20 |
21 | class ConnectionError(EngineIOError):
22 | pass
23 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/crazyflie/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that adds support for Crazyflie drones."""
2 |
3 | from .extension import construct, schema
4 |
5 | __all__ = ("construct", "optional_dependencies", "schema", "tags")
6 |
7 | description = "Support for Crazyflie drones"
8 | dependencies = ("signals", "show", "lps")
9 | optional_dependencies = {
10 | "rc": "allows one to control a Crazyflie drone with a remote controller"
11 | }
12 | tags = ("indoor",)
13 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/virtual_uavs/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that creates one or more virtual UAVs in the server.
2 |
3 | Useful primarily for debugging purposes and for testing the server without
4 | having access to real hardware that provides UAV position and velocity data.
5 | """
6 |
7 | from .extension import construct, dependencies, description, enhancers
8 | from .schema import schema
9 |
10 | __all__ = ("construct", "dependencies", "description", "enhancers", "schema")
11 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/rtk/__init__.py:
--------------------------------------------------------------------------------
1 | """Extension that connects to one or more data sources for RTK connections
2 | and forwards the corrections to the UAVs managed by the server.
3 | """
4 |
5 | from .extension import (
6 | construct,
7 | dependencies,
8 | description,
9 | get_schema,
10 | optional_dependencies,
11 | )
12 |
13 | __all__ = (
14 | "construct",
15 | "dependencies",
16 | "description",
17 | "get_schema",
18 | "optional_dependencies",
19 | )
20 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/json.py:
--------------------------------------------------------------------------------
1 | """JSON-compatible module with sane defaults."""
2 |
3 | from json import * # noqa: F401, F403
4 | from json import loads as original_loads
5 |
6 |
7 | def _safe_int(s):
8 | if len(s) > 100:
9 | raise ValueError("Integer is too large")
10 | return int(s)
11 |
12 |
13 | def loads(*args, **kwargs):
14 | if "parse_int" not in kwargs: # pragma: no cover
15 | kwargs["parse_int"] = _safe_int
16 | return original_loads(*args, **kwargs)
17 |
--------------------------------------------------------------------------------
/src/flockwave/server/registries/errors.py:
--------------------------------------------------------------------------------
1 | """Error classes specific to registries."""
2 |
3 | from flockwave.server.errors import FlockwaveError
4 |
5 | __all__ = ("RegistryError", "RegistryFull")
6 |
7 |
8 | class RegistryError(FlockwaveError):
9 | """Base class for all error classes related to registries."""
10 |
11 | pass
12 |
13 |
14 | class RegistryFull(RegistryError):
15 | """Error thrown when a new object cannot be registered in a registry due to
16 | some internal size limit.
17 | """
18 |
19 | pass
20 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | fail_fast: true
2 | repos:
3 | - repo: https://github.com/pre-commit/pre-commit-hooks
4 | rev: v6.0.0
5 | hooks:
6 | - id: end-of-file-fixer
7 | - id: trailing-whitespace
8 |
9 | - repo: https://github.com/charliermarsh/ruff-pre-commit
10 | rev: v0.14.6
11 | hooks:
12 | - id: ruff
13 | args: [--fix, --exit-non-zero-on-fix]
14 | - id: ruff-format
15 |
16 | - repo: https://github.com/astral-sh/uv-pre-commit
17 | rev: 0.9.11
18 | hooks:
19 | - id: uv-lock
20 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/__init__.py:
--------------------------------------------------------------------------------
1 | """Skybrush server extension that adds support for drone flocks that use
2 | the MAVLink protocol.
3 | """
4 |
5 | from .extension import MAVLinkDronesExtension
6 | from .schema import schema
7 |
8 | __all__ = ("construct", "dependencies", "description", "enhancers", "schema")
9 |
10 | construct = MAVLinkDronesExtension
11 | dependencies = ("show", "signals")
12 | description = "Support for drones that use the MAVLink protocol"
13 | enhancers = {"firmware_update": MAVLinkDronesExtension.use_firmware_update_support}
14 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "pip" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/src/flockwave/proxy/config.py:
--------------------------------------------------------------------------------
1 | """Default configuration for the Skybrush proxy server.
2 |
3 | This script will be evaluated first when the proxy attempts to load its
4 | configuration. Configuration files may import variables from this module
5 | with `from flockwave.proxy.config import SOMETHING`, and may also modify
6 | them if the variables are mutable.
7 | """
8 |
9 | # Location of the local Skybrush server that the proxy will connect to.
10 | LOCAL_SERVER = "tcp://localhost:5000"
11 |
12 | # Location of the remote socket that the proxy will connect to.
13 | REMOTE_SERVER = "tcp://proxy.skybrush.collmot.com:5555"
14 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/tasks.html.j2:
--------------------------------------------------------------------------------
1 | {% extends "_layout.html.j2" %}
2 |
3 | {% block body %}
4 |
5 |
6 |
7 | | Name |
8 |
9 |
10 |
11 | {% for prefix, task in tasks %}
12 |
13 | {{ prefix }}{{ task.name|e }} |
14 |
15 | {% endfor %}
16 |
17 |
18 |
19 | | {{ tasks|length }} active tasks(s). |
20 |
21 |
22 |
23 | {% endblock %}
24 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v5/msgpack_packet.py:
--------------------------------------------------------------------------------
1 | import msgpack
2 | from . import packet
3 |
4 |
5 | class MsgPackPacket(packet.Packet):
6 | uses_binary_events = False
7 |
8 | def encode(self):
9 | """Encode the packet for transmission."""
10 | return msgpack.dumps(self._to_dict())
11 |
12 | def decode(self, encoded_packet):
13 | """Decode a transmitted package."""
14 | decoded = msgpack.loads(encoded_packet)
15 | self.packet_type = decoded["type"]
16 | self.data = decoded["data"]
17 | self.id = decoded.get("id")
18 | self.namespace = decoded["nsp"]
19 |
--------------------------------------------------------------------------------
/src/flockwave/server/middleware/types.py:
--------------------------------------------------------------------------------
1 | from flockwave.server.model import Client, FlockwaveMessage
2 | from typing import Callable, Optional
3 |
4 | __all__ = ("RequestMiddleware", "ResponseMiddleware")
5 |
6 |
7 | RequestMiddleware = Callable[[FlockwaveMessage, Client], Optional[FlockwaveMessage]]
8 | """Type specification for middleware functions that process incoming requests."""
9 |
10 |
11 | ResponseMiddleware = Callable[
12 | [FlockwaveMessage, Optional[Client], Optional[FlockwaveMessage]],
13 | Optional[FlockwaveMessage],
14 | ]
15 | """Type specification for middleware functions that process outbound responses
16 | and notifications.
17 | """
18 |
--------------------------------------------------------------------------------
/src/flockwave/server/utils/data_structures.py:
--------------------------------------------------------------------------------
1 | from collections import OrderedDict
2 | from typing import TypeVar
3 |
4 | __all__ = ("LastUpdatedOrderedDict",)
5 |
6 | K = TypeVar("K")
7 | V = TypeVar("V")
8 |
9 |
10 | class LastUpdatedOrderedDict(OrderedDict[K, V]):
11 | """OrderedDict subclass that stores items in the order the keys were
12 | _last_ added.
13 | """
14 |
15 | @property
16 | def first_value(self) -> V:
17 | """The first value in the ordered dict."""
18 | return next(iter(self.values()))
19 |
20 | def __setitem__(self, key: K, value: V) -> None:
21 | super().__setitem__(key, value)
22 | self.move_to_end(key)
23 |
--------------------------------------------------------------------------------
/src/flockwave/server/command_handlers/__init__.py:
--------------------------------------------------------------------------------
1 | """Module containing implementations of common command handlers that are used
2 | by multiple UAV drivers.
3 | """
4 |
5 | from .calibration import create_calibration_command_handler
6 | from .color import create_color_command_handler
7 | from .parameters import create_parameter_command_handler
8 | from .test import create_test_command_handler
9 | from .version import create_version_command_handler
10 |
11 | __all__ = (
12 | "create_calibration_command_handler",
13 | "create_color_command_handler",
14 | "create_parameter_command_handler",
15 | "create_test_command_handler",
16 | "create_version_command_handler",
17 | )
18 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/_tags.html.j2:
--------------------------------------------------------------------------------
1 | {% macro inline_list(tags) -%}
2 | {% for tag in tags %}
3 | {% if tag == "experimental" %}
4 | {{ tag }}
5 | {% elif tag == "restart requested" %}
6 | {{ tag }}
7 | {% elif tag == "pro" %}
8 | {{ tag }}
9 | {% elif tag == "indoor" %}
10 | {{ tag }}
11 | {% else %}
12 | {{ tag }}
13 | {% endif %}
14 | {% endfor %}
15 | {%- endmacro %}
16 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/_navbar.html.j2:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/tbump.toml:
--------------------------------------------------------------------------------
1 | [version]
2 | current = "2.40.2"
3 | regex = '''
4 | (?P\d+)
5 | \.
6 | (?P\d+)
7 | \.
8 | (?P\d+)
9 | '''
10 |
11 | [git]
12 | message_template = "chore: bumped version to {new_version}"
13 | tag_template = "{new_version}"
14 |
15 | [[file]]
16 | src = "pyproject.toml"
17 | search = 'version = "{current_version}"'
18 |
19 | [[file]]
20 | src = "src/flockwave/server/version.py"
21 | version_template = "({major}, {minor}, {patch})"
22 | search = "__version_info__ = {current_version}"
23 |
24 | [[before_commit]]
25 | name = "Run tests and pre-commit hooks"
26 | cmd = "uv sync && uv run pytest && pre-commit run --all-files"
27 |
28 | [[after_push]]
29 | name = "Build tarball"
30 | cmd = "uv build"
31 |
--------------------------------------------------------------------------------
/src/flockwave/server/types.py:
--------------------------------------------------------------------------------
1 | """Type aliases used in multiple places throughout the server."""
2 |
3 | from __future__ import annotations
4 |
5 | from typing import Any, Callable, Protocol
6 |
7 | from flockwave.server.model.log import Severity
8 |
9 | __all__ = ("Disposer",)
10 |
11 |
12 | Disposer = Callable[[], Any]
13 | """Type specification for disposer functions that can be called with no arguments
14 | to get rid of something registered earlier.
15 | """
16 |
17 |
18 | class GCSLogMessageSender(Protocol):
19 | """Type specification for functions that can be used to send a log message
20 | to the GCS from a UAV, with a given severity.
21 | """
22 |
23 | def __call__(self, message: str, *, severity: Severity = Severity.INFO): ...
24 |
--------------------------------------------------------------------------------
/etc/conf/skybrush-indoor.jsonc:
--------------------------------------------------------------------------------
1 | // This is the main configuration file for Skybrush Server, pre-configured
2 | // for indoor shows with Crazyflie drones.
3 | //
4 | // The file is essentially a JSON file, but C-style comments are allowed, and
5 | // lines starting with a hash are ignored.
6 |
7 | {
8 | "EXTENSIONS": {
9 | // Make the server listen on all interfaces so it can be connected to from
10 | // other machines
11 | "http_server": {
12 | "host": ""
13 | },
14 |
15 | // Listen for Crazyflie drones
16 | "crazyflie": {
17 | "enabled": true
18 | },
19 |
20 | // Start shows automatically by default, don't expect an RC to be present
21 | "show": {
22 | "default_start_method": "auto"
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/show/types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import TYPE_CHECKING, Protocol
4 |
5 | if TYPE_CHECKING:
6 | from flockwave.server.tasks.led_lights import LightConfiguration
7 | from .clock import ShowClock
8 | from .config import DroneShowConfiguration
9 | from .metadata import ShowMetadata
10 |
11 | __all__ = ("ShowExtensionAPI",)
12 |
13 |
14 | class ShowExtensionAPI(Protocol):
15 | """Interface specification of the API exposed by the `show` extension."""
16 |
17 | def get_clock(self) -> ShowClock | None: ...
18 | def get_configuration(self) -> DroneShowConfiguration: ...
19 | def get_light_configuration(self) -> LightConfiguration: ...
20 | def get_last_uploaded_show_metadata(self) -> ShowMetadata | None: ...
21 |
--------------------------------------------------------------------------------
/etc/deployment/rpi/network.cfg:
--------------------------------------------------------------------------------
1 | # Specifies the IP address and the prefix length of the Ethernet interface, in
2 | # dotted notation. Uses DHCP when not given. Example: 192.168.4.250/24
3 | ADDRESS_ETH0=
4 |
5 | # Specifies the gateway address of the Ethernet interface, in dotted notation.
6 | # Leave empty if the network interface has no access to the Internet. Ignored
7 | # when configured for DHCP.
8 | GATEWAY_ETH0=
9 |
10 | # Specifies the IP address of the DNS server of the network interface. Leave
11 | # empty if you don't need a DNS server. Ignored when configured for DHCP.
12 | DNS_ETH0=
13 |
14 | # Specifies the name of the wireless access point to connect to. No wireless
15 | # connection will be established when left empty.
16 | WIRELESS_AP_NAME=
17 |
18 | # Specifies the WPA2 password of the wireless access point.
19 | WIRELESS_PASSWORD=
20 |
--------------------------------------------------------------------------------
/src/flockwave/server/utils/packaging.py:
--------------------------------------------------------------------------------
1 | """Utility functions related to distributing the server as a packaged,
2 | preferably single-file application.
3 | """
4 |
5 | from functools import lru_cache
6 |
7 | import sys
8 |
9 | __all__ = ("is_oxidized", "is_packaged", "is_packaged_with_pyinstaller")
10 |
11 |
12 | @lru_cache(maxsize=None)
13 | def is_packaged() -> bool:
14 | """Returns whether the application is packaged."""
15 | return is_oxidized() or is_packaged_with_pyinstaller()
16 |
17 |
18 | def is_oxidized() -> bool:
19 | """Returns whether the application is packaged with PyOxidizer."""
20 | return bool(getattr(sys, "oxidized", False))
21 |
22 |
23 | def is_packaged_with_pyinstaller() -> bool:
24 | """Returns whether the application is packaged with PyInstaller."""
25 | return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
26 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v4/exceptions.py:
--------------------------------------------------------------------------------
1 | class SocketIOError(Exception):
2 | pass
3 |
4 |
5 | class ConnectionError(SocketIOError):
6 | pass
7 |
8 |
9 | class ConnectionRefusedError(ConnectionError):
10 | """Connection refused exception.
11 |
12 | This exception can be raised from a connect handler when the connection
13 | is not accepted. The positional arguments provided with the exception are
14 | returned with the error packet to the client.
15 | """
16 |
17 | def __init__(self, *args):
18 | if len(args) == 0:
19 | self.error_args = None
20 | elif len(args) == 1:
21 | self.error_args = args[0]
22 | else:
23 | self.error_args = args
24 |
25 |
26 | class TimeoutError(SocketIOError):
27 | pass
28 |
29 |
30 | class BadNamespaceError(SocketIOError):
31 | pass
32 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/threads.html.j2:
--------------------------------------------------------------------------------
1 | {% extends "_layout.html.j2" %}
2 |
3 | {% block body %}
4 |
5 |
6 |
7 | | Ident |
8 | Name |
9 | Daemon? |
10 |
11 |
12 |
13 | {% for thread in threads %}
14 |
15 | | {{ thread.ident }} |
16 | {{ thread.name|e }} |
17 | {% if thread.daemon %}{% else %}{% endif %} |
18 |
19 | {% endfor %}
20 |
21 |
22 |
23 | | {{ threads|length }} running thread(s). |
24 |
25 |
26 |
27 | {% endblock %}
28 |
--------------------------------------------------------------------------------
/src/flockwave/server/utils/validation.py:
--------------------------------------------------------------------------------
1 | from cachetools import cached, LRUCache
2 | from typing import Any
3 |
4 | from flockwave.spec.validator import (
5 | Validator,
6 | create_validator_for_schema,
7 | ValidationError,
8 | )
9 |
10 | __all__ = ("cached_validator_for", "validator_for", "Validator", "ValidationError")
11 |
12 |
13 | def validator_for(schema: Any) -> Validator:
14 | """Creates a validator for the given JSON schema.
15 |
16 | Returns:
17 | the validator function of the schema
18 | """
19 | return create_validator_for_schema(schema)
20 |
21 |
22 | @cached(cache=LRUCache(maxsize=128), key=id)
23 | def cached_validator_for(schema: Any) -> Validator:
24 | """Cached version of `validator_for()`. Useful when you need the validator for
25 | the same schema multiple times and you cannot store the validator locally.
26 | """
27 | return validator_for(schema)
28 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/frontend/templates/index.html.j2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Skybrush Server
9 |
10 |
11 |
12 |
13 |
14 |
15 |
Skybrush Server
16 |
Server is up and running.
17 | {%- if links %}
18 |
23 | {% endif -%}
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/flockwave/server/show/lights.py:
--------------------------------------------------------------------------------
1 | """Temporary place for functions that are related to the processing of
2 | Skybrush-related light programs, until we find a better place for them.
3 | """
4 |
5 | from base64 import b64decode
6 | from typing import Dict
7 |
8 | __all__ = ("get_light_program_from_show_specification",)
9 |
10 |
11 | def get_light_program_from_show_specification(show: Dict) -> bytes:
12 | """Returns the raw Skybrush light program as bytecode from the given
13 | show specification object.
14 | """
15 | lights = show.get("lights", None)
16 | if not lights:
17 | return b"\x00" # single END cmmand
18 |
19 | version = lights.get("version", 0)
20 | if version is None:
21 | raise RuntimeError("light program must have a version number")
22 | if version != 1:
23 | raise RuntimeError("only version 1 light programs are supported")
24 |
25 | light_data = b64decode(lights["data"])
26 | return light_data
27 |
--------------------------------------------------------------------------------
/etc/conf/skybrush-indoor-dual.jsonc:
--------------------------------------------------------------------------------
1 | // This is the main configuration file for Skybrush Server, pre-configured
2 | // for indoor shows with Crazyflie drones and multiple radios.
3 | //
4 | // The file is essentially a JSON file, but C-style comments are allowed, and
5 | // lines starting with a hash are ignored.
6 |
7 | {
8 | "EXTENSIONS": {
9 | // Make the server listen on all interfaces so it can be connected to from
10 | // other machines
11 | "http_server": {
12 | "host": ""
13 | },
14 |
15 | // Listen for Crazyflie drones
16 | "crazyflie": {
17 | "enabled": true,
18 | "connections": [
19 | "crazyradio://0/70/2M/E7E7E7E7",
20 | "crazyradio://1/75/2M/E7E7E7E7"
21 | ],
22 | "fence": {
23 | "enabled": false
24 | }
25 | },
26 |
27 | // Start shows automatically by default, don't expect an RC to be present
28 | "show": {
29 | "default_start_method": "auto"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/doc/make.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | from datetime import datetime
4 | from pathlib import Path
5 | from shutil import make_archive, rmtree
6 |
7 | import pdoc
8 | import pdoc.render
9 |
10 | here = Path(__file__).parent
11 |
12 | if __name__ == "__main__":
13 | out_dir = here / "build"
14 | if out_dir.is_dir():
15 | rmtree(out_dir)
16 |
17 | pdoc.render.configure(
18 | docformat="google",
19 | )
20 |
21 | pdoc.pdoc(
22 | "flockwave",
23 | "!flockwave.app_framework", # requires urwid
24 | "!flockwave.protocols.mavlink", # not needed, too much cruft
25 | "!flockwave.server.ext.socketio.vendor", # vendored code, not ours
26 | output_directory=out_dir,
27 | )
28 |
29 | date = datetime.now().strftime("%Y%m%d")
30 | archive_path = str(here / f"skybrush-server-docs-{date}")
31 | make_archive(archive_path, "zip", out_dir)
32 |
33 | print(f"Documentation archive created in {archive_path}.zip")
34 |
--------------------------------------------------------------------------------
/test/test_utils_formatting.py:
--------------------------------------------------------------------------------
1 | from flockwave.server.utils.formatting import (
2 | format_list_nicely,
3 | format_uav_ids_nicely,
4 | )
5 |
6 |
7 | def spamify(x: str) -> str:
8 | return f"{x} spam"
9 |
10 |
11 | def test_format_list_nicely():
12 | fmt = format_list_nicely
13 |
14 | assert fmt([]) == ""
15 | assert fmt(["foo"]) == "foo"
16 | assert fmt(["foo", "bar"]) == "foo and bar"
17 | assert fmt(["spam", "ham", "eggs"]) == "spam, ham and eggs"
18 | assert fmt(["spam"] * 8) == "spam, spam, spam, spam, spam and 3 more"
19 | assert fmt(["spam"] * 4, max_items=4) == "spam, spam, spam and spam"
20 | assert (
21 | fmt(["lovely", "wonderful"], item_formatter=spamify)
22 | == "lovely spam and wonderful spam"
23 | )
24 |
25 |
26 | def test_format_uav_ids_nicely():
27 | fmt = format_uav_ids_nicely
28 |
29 | assert fmt(("17", "34", "81")) == "UAVs 17, 34 and 81"
30 | assert fmt(()) == "no UAVs"
31 | assert fmt(("42",)) == "UAV 42"
32 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/version_info.html.j2:
--------------------------------------------------------------------------------
1 | {% extends "_layout.html.j2" %}
2 |
3 | {% block body %}
4 |
5 |
6 |
7 | |
8 | Name |
9 | Version |
10 | Description |
11 | |
12 |
13 |
14 |
15 | {% for dist in distributions %}
16 |
17 | |
18 | |
19 |
20 | {{ dist.name }}
21 | |
22 |
23 | {{ dist.version or "" }}
24 | |
25 |
26 | {{ dist.metadata.get("Summary") }}
27 | |
28 |
29 | {% endfor %}
30 |
31 |
32 | {% endblock %}
33 |
--------------------------------------------------------------------------------
/src/flockwave/server/tasks/waiting.py:
--------------------------------------------------------------------------------
1 | from inspect import isawaitable
2 | from trio import open_nursery
3 | from typing import Any, Awaitable, Dict, TypeVar
4 |
5 | __all__ = ("wait_for_dict_items",)
6 |
7 | T = TypeVar("T")
8 |
9 | DictT = TypeVar("DictT", bound=Dict)
10 |
11 |
12 | async def wait_for_dict_items(obj: DictT) -> DictT:
13 | """Iterates over all key-value pairs of a dictionary and awaits all values
14 | that are awaitables, re-assigning their results to the appropriate keys
15 | in the dict.
16 |
17 | Exceptions raised by the awaitables are caught and assigned.
18 | """
19 | async with open_nursery() as nursery:
20 | for key, value in obj.items():
21 | if isawaitable(value):
22 | nursery.start_soon(_wait_safely_and_put, value, obj, key)
23 | return obj
24 |
25 |
26 | async def _wait_safely_and_put(obj: Awaitable[Any], d: dict[T, Any], key: T):
27 | try:
28 | d[key] = await obj
29 | except Exception as ex:
30 | d[key] = ex
31 |
--------------------------------------------------------------------------------
/src/flockwave/server/registries/__init__.py:
--------------------------------------------------------------------------------
1 | """Package that holds classes that implement registries of connections,
2 | UAVs, timers and so on in the server.
3 |
4 | Registries map human-readable unique identifiers to the actual business
5 | objects (connections, UAVs, timers and so on). The server will typically
6 | contain a separate registry for each type of object.
7 | """
8 |
9 | from .base import Registry, RegistryBase, find_in_registry
10 | from .channels import ChannelTypeRegistry
11 | from .clients import ClientRegistry
12 | from .connections import ConnectionRegistry, ConnectionRegistryEntry
13 | from .objects import ObjectRegistry
14 | from .uav_drivers import UAVDriverRegistry
15 | from .weather import WeatherProviderRegistry
16 |
17 | __all__ = (
18 | "Registry",
19 | "RegistryBase",
20 | "find_in_registry",
21 | "ClientRegistry",
22 | "ConnectionRegistry",
23 | "ConnectionRegistryEntry",
24 | "ChannelTypeRegistry",
25 | "ObjectRegistry",
26 | "UAVDriverRegistry",
27 | "WeatherProviderRegistry",
28 | )
29 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/crazyflie/types.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum
2 | from typing import Any, Optional
3 |
4 | __all__ = ("ControllerType",)
5 |
6 |
7 | class ControllerType(IntEnum):
8 | """Enum representing the controller types used in the Crazyflie ecosystem."""
9 |
10 | AUTO_SELECT = 0
11 | PID = 1
12 | MELLINGER = 2
13 | INDI = 3
14 | BRESCIANINI = 4
15 |
16 | @classmethod
17 | def from_json(cls, value: Any) -> Optional["ControllerType"]:
18 | if value is None:
19 | return None
20 |
21 | if isinstance(value, str):
22 | value = value.lower()
23 | if value == "auto" or value == "autoselect":
24 | return cls.AUTO_SELECT
25 | elif value == "pid":
26 | return cls.PID
27 | elif value == "mellinger":
28 | return cls.MELLINGER
29 | elif value == "indi":
30 | return cls.INDI
31 | elif value == "brescianini":
32 | return cls.BRESCIANINI
33 |
34 | return cls(value)
35 |
--------------------------------------------------------------------------------
/src/flockwave/proxy/__init__.py:
--------------------------------------------------------------------------------
1 | """Main package for the Skybrush proxy that connects to a remote IP address and
2 | port, and listens for incoming HTTP requests from there. Incoming requests are
3 | then parsed and forwarded to a _local_ Skybrush server; responses are relayed
4 | back.
5 |
6 | This can be used in field tests where the Skybrush server on the field does not
7 | have a public IP address that remote services may use. The setup in this case
8 | can be as follows:
9 |
10 | - A `socat` instance is set up on the remote server to listen on _two_
11 | TCP ports: port 5000 and 5001.
12 |
13 | - An `nginx` proxy is set up on the remote server that performs SSL offloading
14 | and forwards all incoming HTTP requests to port 5001.
15 |
16 | - The Skybrush server and the Skybrush proxy is started up on the field computer.
17 | The proxy is instructed to connect to the remote server on port 5000 and to
18 | the local Skybrush server, also on port 5000.
19 | """
20 |
21 | from .version import __version__, __version_info__
22 |
23 | __all__ = ("__version__", "__version_info__")
24 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/ssdp/types.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Callable, ContextManager, Protocol, TypedDict, TYPE_CHECKING
4 |
5 | if TYPE_CHECKING:
6 | from .registry import UPnPServiceRegistry, URIOrCallableReturningURI
7 |
8 | __all__ = ("SSDPExtensionAPI",)
9 |
10 |
11 | class SSDPExtensionAPIDict(TypedDict):
12 | register_service: Callable[[str, URIOrCallableReturningURI], None] | None
13 | registry: UPnPServiceRegistry | None
14 | unregister_service: Callable[[str], URIOrCallableReturningURI | None] | None
15 | use_service: Callable[[str, URIOrCallableReturningURI], ContextManager[None]] | None
16 |
17 |
18 | class SSDPExtensionAPI(Protocol):
19 | """Interface specification for the methods exposed by the ``ssdp``
20 | extension.
21 | """
22 |
23 | register_service: Callable[[str, URIOrCallableReturningURI], None]
24 | registry: UPnPServiceRegistry
25 | unregister_service: Callable[[str], URIOrCallableReturningURI | None]
26 | use_service: Callable[[str, URIOrCallableReturningURI], ContextManager[None]]
27 |
--------------------------------------------------------------------------------
/etc/conf/skybrush-virtual.jsonc:
--------------------------------------------------------------------------------
1 | // This is an example configuration file for Skybrush Server that configures
2 | // the server with a single virtual drone.
3 | //
4 | // The file is essentially a JSON file, but C-style comments are allowed, and
5 | // lines starting with a hash are ignored.
6 |
7 | {
8 | "EXTENSIONS": {
9 | // Make the server listen on all interfaces so it can be connected to from
10 | // other machines
11 | "http_server": {
12 | "host": ""
13 | },
14 |
15 | // RTK extension configuration
16 | "rtk": {
17 | // Add all serial ports as potential RTK data sources with baud rates 9600
18 | // and 57600
19 | "add_serial_ports": [9600, 57600]
20 | },
21 |
22 | // Add virtual drone provider
23 | "virtual_uavs": {
24 | "enabled": true,
25 | "count": 1,
26 | "id_format": "{0:03}",
27 |
28 | // Home position of the drone, in lon-lat-AMSL format
29 | "origin": [18.915125, 47.486305, 215],
30 |
31 | // Initial heading of the drone, in degrees, North = 0, East = 90 and so on
32 | "orientation": 59
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v5/exceptions.py:
--------------------------------------------------------------------------------
1 | class SocketIOError(Exception):
2 | pass
3 |
4 |
5 | class ConnectionError(SocketIOError):
6 | pass
7 |
8 |
9 | class ConnectionRefusedError(ConnectionError):
10 | """Connection refused exception.
11 |
12 | This exception can be raised from a connect handler when the connection
13 | is not accepted. The positional arguments provided with the exception are
14 | returned with the error packet to the client.
15 | """
16 |
17 | def __init__(self, *args):
18 | if len(args) == 0:
19 | self.error_args = {"message": "Connection rejected by server"}
20 | elif len(args) == 1:
21 | self.error_args = {"message": str(args[0])}
22 | else:
23 | self.error_args = {"message": str(args[0])}
24 | if len(args) == 2:
25 | self.error_args["data"] = args[1]
26 | else:
27 | self.error_args["data"] = args[1:]
28 |
29 |
30 | class TimeoutError(SocketIOError):
31 | pass
32 |
33 |
34 | class BadNamespaceError(SocketIOError):
35 | pass
36 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/debug/extension.py:
--------------------------------------------------------------------------------
1 | from contextlib import ExitStack
2 | from logging import Logger
3 | from trio import sleep_forever
4 | from typing import Optional, TYPE_CHECKING
5 |
6 | from flockwave.server.utils import overridden
7 |
8 | from .server import run_debug_port, setup_debugging_server
9 |
10 | if TYPE_CHECKING:
11 | from flockwave.server.app import SkybrushServer
12 |
13 | app: Optional["SkybrushServer"] = None
14 | log: Optional[Logger] = None
15 |
16 |
17 | async def run(app, configuration, logger):
18 | """Runs the extension."""
19 | global is_public
20 |
21 | host = configuration.get("host", "localhost")
22 | port = configuration.get("port")
23 |
24 | with ExitStack() as stack:
25 | stack.enter_context(overridden(globals(), app=app, log=logger))
26 |
27 | if port is not None:
28 | on_message = setup_debugging_server(app, stack, debug_clients=True)
29 | await run_debug_port(host or "", port, on_message=on_message, log=log)
30 | else:
31 | await sleep_forever()
32 |
33 |
34 | dependencies = ()
35 | description = "Debugging tools"
36 | schema = {}
37 |
--------------------------------------------------------------------------------
/etc/scripts/docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # Builds the skybrush-server Docker image.
4 |
5 | IMAGE_NAME=skybrush-server
6 |
7 | SCRIPT_ROOT=$(dirname $0)
8 | REPO_ROOT="${SCRIPT_ROOT}/../.."
9 |
10 | set -e
11 |
12 | cd ${REPO_ROOT}
13 |
14 | # Remove all requirements.txt files, we don't use them, only uv
15 | rm -f requirements*.txt
16 |
17 | # Generate requirements.txt from uv
18 | uv export --format requirements.txt -o requirements-main.txt \
19 | --no-annotate --no-dev --no-editable --no-emit-project --no-hashes >/dev/null
20 | trap "rm -f requirements-main.txt" EXIT
21 |
22 | # Build the Docker image
23 | docker build \
24 | --platform linux/amd64 \
25 | --secret id=NETRC_SECRET_ID,src=${HOME}/.netrc \
26 | -t docker.collmot.com/${IMAGE_NAME}:latest \
27 | -f etc/deployment/docker/amd64/Dockerfile \
28 | .
29 | echo "Successfully built Docker image."
30 |
31 | # If we are at an exact tag, also tag the image
32 | GIT_TAG=$(git describe --exact-match --tags 2>/dev/null || echo "")
33 | if [ "x$GIT_TAG" != x ]; then
34 | docker tag docker.collmot.com/${IMAGE_NAME}:latest docker.collmot.com/${IMAGE_NAME}:${GIT_TAG}
35 | echo "Image tagged as $GIT_TAG."
36 | fi
37 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/world.py:
--------------------------------------------------------------------------------
1 | """Representation of the outside world in which the flock of UAVs live."""
2 |
3 | from flockwave.gps.vectors import GPSCoordinate
4 | from typing import Any
5 |
6 | __all__ = ("World",)
7 |
8 |
9 | class World:
10 | """Representation of the outside world in which the flock of UAVs live.
11 |
12 | The world is essentially a spatial index containing arbitrary objects.
13 | Methods are provided to extract objects in the vicinity of a given
14 | coordinate, optionally filtered by the classes of these objects.
15 |
16 | TODO: no spatial index yet, but there will be if needed
17 | """
18 |
19 | _items: list[tuple[GPSCoordinate, Any]]
20 |
21 | def __init__(self):
22 | """Constructor.
23 |
24 | Creates an empty world with no objects.
25 | """
26 | self._items = []
27 |
28 | def add(self, obj: Any, location: GPSCoordinate) -> None:
29 | """Adds the given object at the given location.
30 |
31 | Parameters:
32 | obj: the object to add
33 | location: the location to add the object to. Altitudes will be ignored.
34 | """
35 | self._items.append((location, obj))
36 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/autopilots/registry.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Callable, TYPE_CHECKING, Type
4 |
5 | from .unknown import UnknownAutopilot
6 |
7 | if TYPE_CHECKING:
8 | from .base import Autopilot
9 |
10 | __all__ = ("get_autopilot_factory_by_mavlink_type", "register_for_mavlink_type")
11 |
12 |
13 | _autopilot_registry: dict[int, Type["Autopilot"]] = {}
14 |
15 |
16 | def get_autopilot_factory_by_mavlink_type(type: int) -> Type["Autopilot"]:
17 | return _autopilot_registry.get(type, UnknownAutopilot)
18 |
19 |
20 | def register_for_mavlink_type(
21 | type: int,
22 | ) -> Callable[[Type["Autopilot"]], Type["Autopilot"]]:
23 | """Class decorator to register an Autopilot subclass for a given MAVLink
24 | autopilot type.
25 |
26 | Args:
27 | type: The MAVLink autopilot type to register the class for.
28 |
29 | Returns:
30 | The class decorator.
31 | """
32 |
33 | def decorator(cls: Type["Autopilot"]) -> Type["Autopilot"]:
34 | if cls in _autopilot_registry:
35 | raise RuntimeError(f"{cls!r} is already registered")
36 |
37 | _autopilot_registry[type] = cls
38 | return cls
39 |
40 | return decorator
41 |
--------------------------------------------------------------------------------
/test/test_model.py:
--------------------------------------------------------------------------------
1 | from flockwave.server.model.attitude import Attitude
2 | from flockwave.server.model.gps import GPSFix, GPSFixType
3 | from flockwave.server.model.uav import UAVStatusInfo
4 |
5 |
6 | def test_attitude():
7 | attitude = Attitude()
8 | attitude.roll = 10
9 | attitude.pitch = 20
10 | attitude.yaw = 30
11 |
12 | assert attitude.json == [100, 200, 300]
13 |
14 | attitude.update_from(Attitude(200, 210, 220))
15 |
16 | assert attitude.json == [-1600, -1500, 2200]
17 | assert attitude.json == Attitude.from_json(attitude.json).json
18 |
19 |
20 | def test_gpsfix():
21 | gps = GPSFix(GPSFixType.FIX_3D, 15, 1.2, 1.5)
22 | assert gps.json == [3, 15, 1200, 1500]
23 |
24 | gps = GPSFix(GPSFixType.RTK_FLOAT, num_satellites=15)
25 | assert gps.json == [5, 15]
26 |
27 | gps = GPSFix(GPSFixType.RTK_FIXED, horizontal_accuracy=1.5)
28 | assert gps.json == [6, None, 1500]
29 |
30 | gps.update_from(GPSFix(GPSFixType.STATIC, vertical_accuracy=2))
31 | assert gps.json == [7, None, None, 2000]
32 |
33 | gps.update_from(GPSFixType.DGPS)
34 | assert gps.json == [4]
35 |
36 |
37 | def test_uavstatusinfo():
38 | status = UAVStatusInfo()
39 |
40 | assert status.attitude is None
41 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/lps/examples.py:
--------------------------------------------------------------------------------
1 | from trio import sleep
2 | from typing import Any
3 |
4 | from .model import LocalPositioningSystem, LocalPositioningSystemType
5 |
6 | __all__ = ("DummyLocalPositioningSystem",)
7 |
8 |
9 | class DummyLocalPositioningSystem(LocalPositioningSystem):
10 | """Dummy local positioning system (LPS) that does nothing.
11 |
12 | This LPS instance is mostly for illustrative and testing purposes.
13 | """
14 |
15 | async def calibrate(self) -> None:
16 | await sleep(3)
17 |
18 | def _configure_inner(self, cfg: dict[str, Any]) -> None:
19 | pass
20 |
21 |
22 | class DummyLocalPositioningSystemType(
23 | LocalPositioningSystemType[DummyLocalPositioningSystem]
24 | ):
25 | """Example local positioning system (LPS) type that does nothing.
26 |
27 | This LPS type is mostly for illustrative and testing purposes.
28 | """
29 |
30 | @property
31 | def description(self) -> str:
32 | return "Local positioning system example that does nothing."
33 |
34 | @property
35 | def name(self) -> str:
36 | return "Dummy LPS"
37 |
38 | def create(self) -> DummyLocalPositioningSystem:
39 | return DummyLocalPositioningSystem()
40 |
41 | def get_configuration_schema(self) -> dict[str, Any]:
42 | return {}
43 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/hotplug.py:
--------------------------------------------------------------------------------
1 | """Extension that watches the USB bus of the computer the server is running
2 | on and emits a signal whenever a new USB device is plugged in or an existing
3 | USB device is removed.
4 | """
5 |
6 | from __future__ import annotations
7 |
8 | from contextlib import aclosing
9 | from typing import TYPE_CHECKING
10 |
11 | from aio_usb_hotplug import HotplugDetector, NoBackendError
12 |
13 | from flockwave.server.ext.signals import SignalsExtensionAPI
14 |
15 | if TYPE_CHECKING:
16 | from logging import Logger
17 | from flockwave.server.app import SkybrushServer
18 |
19 |
20 | async def run(app: SkybrushServer, configuration, log: Logger):
21 | signal = app.import_api("signals", SignalsExtensionAPI).get("hotplug:event")
22 |
23 | try:
24 | gen = HotplugDetector().events()
25 | async with aclosing(gen):
26 | async for event in gen:
27 | signal.send(event=event)
28 | except NoBackendError:
29 | log.warning("No suitable backend found for scanning the USB bus")
30 | # TODO(ntamas):add hints about what to do. On macOS, one needs to
31 | # install libusb from Homebrew, and add /opt/homebrew/lib to
32 | # the DYLD_LIBRARY_PATH
33 |
34 |
35 | description = "Hotplug event provider for other extensions"
36 | schema = {}
37 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/weather.py:
--------------------------------------------------------------------------------
1 | from time import time
2 | from typing import Awaitable, Callable, Optional, Union
3 |
4 | from flockwave.gps.vectors import GPSCoordinate
5 | from flockwave.server.model.metamagic import ModelMeta
6 | from flockwave.spec.schema import get_complex_object_schema
7 |
8 | __all__ = ("Weather", "WeatherProvider")
9 |
10 |
11 | class Weather(metaclass=ModelMeta):
12 | """Class representing all that the server knows about the weather at a given
13 | geographical location and time.
14 | """
15 |
16 | class __meta__:
17 | schema = get_complex_object_schema("weather")
18 |
19 | def __init__(
20 | self, position: Optional[GPSCoordinate] = None, timestamp: Optional[int] = None
21 | ):
22 | self.position = position
23 | self.timestamp = timestamp if timestamp is not None else time()
24 |
25 |
26 | #: Type specification for synchronous weather provider functions
27 | SyncWeatherProvider = Callable[[Weather, GPSCoordinate], None]
28 |
29 | #: Type specification for asynchronous weather provider functions
30 | AsyncWeatherProvider = Callable[[Weather, GPSCoordinate], Awaitable[None]]
31 |
32 | #: Type specification for weather provider functions
33 | WeatherProvider = Callable[
34 | [Weather, Optional[GPSCoordinate]], Union[None, Awaitable[None]]
35 | ]
36 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/auto_shutdown.py:
--------------------------------------------------------------------------------
1 | """Extension that shuts down the server automatically if no clients are
2 | connected for a given number of seconds.
3 | """
4 |
5 | from contextlib import ExitStack
6 | from functools import partial
7 | from math import inf
8 | from trio import CancelScope, current_time, sleep_forever
9 |
10 |
11 | def on_client_count_changed(cancel_scope, timeout, sender, client=None):
12 | if sender.num_entries > 0:
13 | cancel_scope.deadline = inf
14 | else:
15 | cancel_scope.deadline = current_time() + timeout
16 |
17 |
18 | async def run(app, configuration, logger):
19 | timeout = float(configuration.get("timeout", 300))
20 |
21 | logger.warn(
22 | f"Server will shut down after {timeout} seconds if there are "
23 | + "no connected clients"
24 | )
25 |
26 | with ExitStack() as stack:
27 | cancel_scope = stack.enter_context(CancelScope())
28 |
29 | handler = partial(on_client_count_changed, cancel_scope, timeout)
30 | stack.enter_context(app.client_registry.added.connected_to(handler))
31 | stack.enter_context(app.client_registry.removed.connected_to(handler))
32 |
33 | handler(app.client_registry)
34 |
35 | await sleep_forever()
36 |
37 | logger.warn("Shutting down due to inactivity.")
38 | app.request_shutdown()
39 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/errors.py:
--------------------------------------------------------------------------------
1 | """Error classes specific to the Flockwave model."""
2 |
3 | from builtins import str
4 |
5 | from flockwave.server.errors import FlockwaveError
6 |
7 | __all__ = ("ClientNotSubscribedError", "NoSuchPathError")
8 |
9 |
10 | class ClientNotSubscribedError(FlockwaveError):
11 | """Error thrown when a client attempts to unsubscribe from a part of the
12 | device tree that it is not subscribed to.
13 | """
14 |
15 | def __init__(self, client, path):
16 | """Constructor.
17 |
18 | Parameters:
19 | client (Client): the client that attempted to unsubscribe
20 | path (DeviceTreePath): the path that the client attempted to
21 | unsubscribe from
22 | """
23 | super(ClientNotSubscribedError, self).__init__(str(client))
24 | self.client = client
25 | self.path = path
26 |
27 |
28 | class NoSuchPathError(FlockwaveError):
29 | """Error thrown when the device tree failed to resolve a device tree
30 | path to a corresponding node.
31 | """
32 |
33 | def __init__(self, path):
34 | """Constructor.
35 |
36 | Parameters:
37 | path (DeviceTreePath): the path that could not be resolved into
38 | a node
39 | """
40 | super(NoSuchPathError, self).__init__(str(path))
41 | self.path = path
42 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/virtual_clocks.py:
--------------------------------------------------------------------------------
1 | """Extension that creates one or more virtual clock objects in the server.
2 |
3 | Right now the clocks stay fixed at their epochs. Later on this extension may
4 | provide support for starting or stopping the clocks.
5 |
6 | Useful primarily for debugging purposes.
7 | """
8 |
9 | from contextlib import ExitStack
10 | from trio import sleep_forever
11 |
12 | from flockwave.server.model.clock import ClockBase
13 |
14 | __all__ = ()
15 |
16 |
17 | class VirtualClock(ClockBase):
18 | """Virtual clock that always stays at its epoch."""
19 |
20 | def ticks_given_time(self, now: float) -> float:
21 | """Returns zero unconditionally.
22 |
23 | Returns:
24 | zero, no matter what the current time is
25 | """
26 | return 0.0
27 |
28 | @property
29 | def running(self) -> bool:
30 | return False
31 |
32 | @property
33 | def ticks_per_second(self) -> int:
34 | return 10
35 |
36 |
37 | async def run(app, configuration, logger):
38 | """Runs the main task of the extension."""
39 | use_clock = app.import_api("clocks").use_clock
40 | with ExitStack() as stack:
41 | for clock_id in configuration.get("ids", []):
42 | stack.enter_context(use_clock(VirtualClock(id=clock_id)))
43 | await sleep_forever()
44 |
45 |
46 | dependencies = ("clocks",)
47 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/user.py:
--------------------------------------------------------------------------------
1 | """Model classes related to a single user that is connected to the server
2 | via a client connection.
3 | """
4 |
5 | from dataclasses import dataclass
6 | from flockwave.spec.ids import parse_user
7 |
8 | __all__ = ("User",)
9 |
10 |
11 | @dataclass(frozen=True)
12 | class User:
13 | """A single user connected to the Skybrush server via a client
14 | connection.
15 |
16 | Attributes:
17 | name: the name of the user
18 | domain: the domain of the user; this allows multiple users to have
19 | the same user name as long as they belong to different
20 | authentication domains. Useful when integrating with third-party
21 | authentication systems such as Windows Active Directory
22 | domains.
23 | """
24 |
25 | name: str
26 | domain: str = ""
27 |
28 | @classmethod
29 | def from_string(cls, value):
30 | name, domain = parse_user(value)
31 | return cls(name=name, domain=domain)
32 |
33 | @property
34 | def is_logged_in(self) -> bool:
35 | """Returns whether this object represents a logged-in user."""
36 | return self.name or self.domain
37 |
38 | @property
39 | def json(self) -> str:
40 | return str(self)
41 |
42 | def __str__(self):
43 | return f"{self.name}@{self.domain}" if self.domain else self.name
44 |
--------------------------------------------------------------------------------
/src/flockwave/server/errors.py:
--------------------------------------------------------------------------------
1 | """Common exception classes used in many places throughout the server."""
2 |
3 | from typing import Optional
4 |
5 | __all__ = ("CommandInvocationError", "FlockwaveError", "NotSupportedError")
6 |
7 |
8 | class FlockwaveError(RuntimeError):
9 | """Base class for all Flockwave-related errors."""
10 |
11 | pass
12 |
13 |
14 | class CommandInvocationError(FlockwaveError):
15 | """Exception class that signals that the user tried to call some command
16 | of a remote UAV but failed to parameterize the command properly.
17 | """
18 |
19 | def __init__(self, message: Optional[str] = None):
20 | """Constructor.
21 |
22 | Parameters:
23 | message: the error message
24 | """
25 | super().__init__(message or "Command invocation error")
26 |
27 |
28 | class NotSupportedError(FlockwaveError):
29 | """Exception thrown by operations that are not supported and there are
30 | no plans to support them.
31 |
32 | This exception should be thrown instead of NotImplementedError_ if we
33 | know that the operation is not likely to be implemented in the future.
34 | """
35 |
36 | def __init__(self, message: Optional[str] = None):
37 | """Constructor.
38 |
39 | Parameters:
40 | message: the error message
41 | """
42 | super().__init__(message or "Operation not supported")
43 |
--------------------------------------------------------------------------------
/src/flockwave/server/utils/networking.py:
--------------------------------------------------------------------------------
1 | from errno import EADDRINUSE
2 | import platform
3 | from typing import Callable
4 | from trio import serve_tcp
5 |
6 | __all__ = ("serve_tcp_and_log_errors",)
7 |
8 |
9 | def _is_macos() -> bool:
10 | return platform.system() == "Darwin"
11 |
12 |
13 | KNOWN_APPS: dict[int, list[tuple[Callable[[], bool], str]]] = {
14 | 5000: [(_is_macos, "AirPlay receiver")],
15 | }
16 |
17 |
18 | def get_known_apps_for_port(port: int) -> list[str]:
19 | """Return a list of known applications that may use the given port.
20 |
21 | Args:
22 | port: The port number to check.
23 |
24 | Returns:
25 | A list of application names that may use the given port.
26 | """
27 | apps: list[str] = []
28 | for predicate, app in KNOWN_APPS.get(port, []):
29 | try:
30 | if predicate():
31 | apps.append(app)
32 | except Exception:
33 | pass
34 | return apps
35 |
36 |
37 | async def serve_tcp_and_log_errors(handler, port, *, log, **kwds):
38 | """Wrapper of Trio's `serve_tcp()` that handles and logs some common
39 | errors.
40 | """
41 | try:
42 | return await serve_tcp(handler, port, **kwds)
43 | except OSError as ex:
44 | if ex.errno == EADDRINUSE:
45 | log.error(f"Port {port} is already in use", extra={"telemetry": "ignore"})
46 | else:
47 | raise
48 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/rssi.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from typing import Optional
3 |
4 | __all__ = ("RSSIMode", "rtcm_counter_to_rssi")
5 |
6 |
7 | class RSSIMode(Enum):
8 | """Specifies how a given MAVLink network will derive the RSSI (received
9 | signal strength indicator) value of its own drones.
10 | """
11 |
12 | # If you extend this enum, do not forget to update the extension schema
13 |
14 | NONE = "none"
15 | """No RSSI value will be derived."""
16 |
17 | RADIO_STATUS = "radio_status"
18 | """The RSSI value will be derived from the MAVLink RADIO_STATUS message."""
19 |
20 | RTCM_COUNTERS = "rtcm_counters"
21 | """The RSSI value will be derived from the RTCM message counters embedded
22 | in Skybrush-specific status packets. Works with a Skybrush firmware only.
23 | """
24 |
25 |
26 | def rtcm_counter_to_rssi(value: Optional[int]) -> Optional[int]:
27 | """Converts an RTCM message counter to a simulated RSSI value.
28 |
29 | The conversion is done as follows:
30 |
31 | - ``None`` is left as is.
32 | - No RTCM messages are converted to 0%.
33 | - 10 RTCM messages or more are converted to 100%.
34 | - Values in between are linearly interpolated.
35 | """
36 | if value is None:
37 | return None
38 | elif value <= 0:
39 | return 0
40 | elif value >= 10:
41 | return 100
42 | else:
43 | return value * 10
44 |
--------------------------------------------------------------------------------
/src/flockwave/server/command_handlers/version.py:
--------------------------------------------------------------------------------
1 | """Factory function to create handlers for the "version" command in UAV drivers."""
2 |
3 | from inspect import iscoroutinefunction
4 | from typing import Awaitable, Callable
5 |
6 | from flockwave.server.model.uav import UAV, UAVDriver
7 |
8 | __all__ = ("create_version_command_handler",)
9 |
10 |
11 | async def _version_command_handler(driver: UAVDriver, uav: UAV) -> str:
12 | if iscoroutinefunction(uav.get_version_info):
13 | version_info = await uav.get_version_info()
14 | else:
15 | version_info = uav.get_version_info()
16 |
17 | if version_info:
18 | parts = [f"{key} = {version_info[key]}" for key in sorted(version_info.keys())]
19 | return "\n".join(parts)
20 | else:
21 | return "No version information available"
22 |
23 |
24 | def create_version_command_handler() -> Callable[[UAVDriver, UAV], Awaitable[str]]:
25 | """Creates a generic async command handler function that allows the user to
26 | retrieve the version information of the UAV, assuming that the UAV
27 | has a sync or async method named `get_version_info()`.
28 |
29 | Assign the function returned from this factory function to the
30 | `handle_command_version()` method of a UAVDriver_ subclass to make the
31 | driver version number retrievals, assuming that the corresponding UAV_
32 | object already supports it.
33 | """
34 | return _version_command_handler
35 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/insomnia.py:
--------------------------------------------------------------------------------
1 | """Extension that prevents the machine running the server from going to sleep
2 | while the server is running.
3 | """
4 |
5 | from contextlib import ExitStack, nullcontext
6 | from trio import sleep_forever
7 |
8 |
9 | async def run(app, configuration, logger):
10 | keep_display_on = bool(configuration.get("keep_display_on", False))
11 |
12 | try:
13 | from adrenaline import prevent_sleep
14 |
15 | context = prevent_sleep(
16 | app_name="Skybrush Server",
17 | display=keep_display_on,
18 | reason="Skybrush Server",
19 | )
20 | except Exception:
21 | context = nullcontext()
22 | logger.warn("Cannot prevent sleep mode on this platform")
23 |
24 | with ExitStack() as stack:
25 | from adrenaline.errors import NotSupportedError
26 |
27 | try:
28 | stack.enter_context(context)
29 | except NotSupportedError:
30 | logger.warn("Cannot prevent sleep mode on this platform")
31 |
32 | await sleep_forever()
33 |
34 |
35 | description = "Prevents the machine running the server from going to sleep"
36 | schema = {
37 | "properties": {
38 | "keep_display_on": {
39 | "type": "boolean",
40 | "title": "Keep display on",
41 | "description": "Tick this checkbox to prevent the display from turning off while the server is running.",
42 | "format": "checkbox",
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/test/test_preflight_check.py:
--------------------------------------------------------------------------------
1 | from flockwave.server.model.preflight import PreflightCheckResult
2 |
3 |
4 | def test_preflight_check_result():
5 | item = PreflightCheckResult.OFF
6 | assert not item.failed
7 | assert not item.failed_conclusively
8 | assert item.passed
9 | assert item.passed_without_warnings
10 |
11 | item = PreflightCheckResult.PASS
12 | assert not item.failed
13 | assert not item.failed_conclusively
14 | assert item.passed
15 | assert item.passed_without_warnings
16 |
17 | item = PreflightCheckResult.WARNING
18 | assert not item.failed
19 | assert not item.failed_conclusively
20 | assert item.passed
21 | assert not item.passed_without_warnings
22 |
23 | item = PreflightCheckResult.RUNNING
24 | assert not item.failed
25 | assert not item.failed_conclusively
26 | assert not item.passed
27 | assert not item.passed_without_warnings
28 |
29 | item = PreflightCheckResult.SOFT_FAILURE
30 | assert item.failed
31 | assert not item.failed_conclusively
32 | assert not item.passed
33 | assert not item.passed_without_warnings
34 |
35 | item = PreflightCheckResult.FAILURE
36 | assert item.failed
37 | assert item.failed_conclusively
38 | assert not item.passed
39 | assert not item.passed_without_warnings
40 |
41 | item = PreflightCheckResult.ERROR
42 | assert item.failed
43 | assert item.failed_conclusively
44 | assert not item.passed
45 | assert not item.passed_without_warnings
46 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/fw_upload.py:
--------------------------------------------------------------------------------
1 | """Functions and objects related to uploading a firmware to a MAVLink-based
2 | drone over a wireless connection.
3 | """
4 |
5 | from enum import Enum
6 |
7 | __all__ = ("FirmwareUpdateTarget", "FirmwareUpdateResult")
8 |
9 |
10 | class FirmwareUpdateTarget(Enum):
11 | ABIN = "org.ardupilot.firmware.abin"
12 |
13 | def describe(self):
14 | """Returns a human-readable description of the target."""
15 | if self == FirmwareUpdateTarget.ABIN:
16 | return "ArduPilot ABIN firmware"
17 | return self.value
18 |
19 |
20 | class FirmwareUpdateResult(Enum):
21 | """Result of a firmware update."""
22 |
23 | UNSUPPORTED = 0
24 | FAILED_TO_VERIFY = 1
25 | INVALID = 2
26 | FLASHING_FAILED = 3
27 | SUCCESS = 4
28 |
29 | def describe(self):
30 | """Returns a human-readable description of the result."""
31 | if self == FirmwareUpdateResult.UNSUPPORTED:
32 | return "Firmware update is not supported on this UAV"
33 | elif self == FirmwareUpdateResult.FAILED_TO_VERIFY:
34 | return "Failed to verify firmware update"
35 | elif self == FirmwareUpdateResult.INVALID:
36 | return "Firmware update is invalid"
37 | elif self == FirmwareUpdateResult.FLASHING_FAILED:
38 | return "Firmware update failed"
39 | elif self == FirmwareUpdateResult.SUCCESS:
40 | return "Firmware update completed successfully"
41 |
42 | @property
43 | def successful(self) -> bool:
44 | return self == FirmwareUpdateResult.SUCCESS
45 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/system_clock.py:
--------------------------------------------------------------------------------
1 | """Extension that provides a clock named ``system`` in the Skybrush
2 | server. The ``system`` clock always returns the current timestamp
3 | according to the server, expressed as the number of seconds elapsed since
4 | the Unix epoch, in UTC.
5 | """
6 |
7 | from trio import sleep_forever
8 |
9 | from flockwave.server.model import ClockBase
10 |
11 |
12 | class SystemClock(ClockBase):
13 | """The system clock that the extension registers."""
14 |
15 | def __init__(self):
16 | """Constructor."""
17 | super().__init__(id="system", epoch=0)
18 |
19 | @property
20 | def running(self) -> bool:
21 | return True
22 |
23 | def ticks_given_time(self, now: float) -> float:
24 | """Returns the number of clock ticks elapsed since the Unix epoch,
25 | assuming that the server clock reports that the current time is
26 | the one given in the 'now' argument.
27 |
28 | Parameters:
29 | now: the number of seconds elapsed since the Unix epoch,
30 | according to the internal clock of the server.
31 |
32 | Returns:
33 | the number of clock ticks elapsed
34 | """
35 | return now
36 |
37 | @property
38 | def ticks_per_second(self) -> int:
39 | return 1
40 |
41 |
42 | async def run(app):
43 | """Runs the extension."""
44 | with app.import_api("clocks").use_clock(SystemClock()):
45 | await sleep_forever()
46 |
47 |
48 | dependencies = ("clocks",)
49 | description = "System clock that always shows the current timestamp of the server"
50 | schema = {}
51 |
--------------------------------------------------------------------------------
/src/flockwave/server/utils/__init__.py:
--------------------------------------------------------------------------------
1 | """Utility functions that do not fit elsewhere."""
2 |
3 | from .data_structures import LastUpdatedOrderedDict
4 | from .formatting import (
5 | format_list_nicely,
6 | format_number_nicely,
7 | format_timestamp_nicely,
8 | format_uav_ids_nicely,
9 | )
10 | from .generic import (
11 | chunks,
12 | clamp,
13 | color_to_rgb565,
14 | color_to_rgb8_triplet,
15 | consecutive_pairs,
16 | constant,
17 | datetime_to_unix_timestamp,
18 | divide_by,
19 | identity,
20 | is_timezone_aware,
21 | itersubclasses,
22 | longest_common_prefix,
23 | maybe_round,
24 | multiply_by,
25 | nop,
26 | once,
27 | optional_float,
28 | optional_int,
29 | overridden,
30 | rename_keys,
31 | to_uppercase_string,
32 | )
33 | from .system_time import get_current_unix_timestamp_msec
34 |
35 | __all__ = (
36 | "chunks",
37 | "clamp",
38 | "color_to_rgb565",
39 | "color_to_rgb8_triplet",
40 | "consecutive_pairs",
41 | "constant",
42 | "datetime_to_unix_timestamp",
43 | "divide_by",
44 | "format_list_nicely",
45 | "format_number_nicely",
46 | "format_timestamp_nicely",
47 | "format_uav_ids_nicely",
48 | "get_current_unix_timestamp_msec",
49 | "identity",
50 | "is_timezone_aware",
51 | "itersubclasses",
52 | "LastUpdatedOrderedDict",
53 | "longest_common_prefix",
54 | "maybe_round",
55 | "multiply_by",
56 | "nop",
57 | "once",
58 | "optional_float",
59 | "optional_int",
60 | "overridden",
61 | "rename_keys",
62 | "to_uppercase_string",
63 | )
64 |
--------------------------------------------------------------------------------
/etc/conf/skybrush-outdoor.jsonc:
--------------------------------------------------------------------------------
1 | // This is the main configuration file for Skybrush Server, pre-configured
2 | // for outdoor shows with MAVLink-based drones.
3 | //
4 | // The file is essentially a JSON file, but C-style comments are allowed, and
5 | // lines starting with a hash are ignored.
6 |
7 | {
8 | "EXTENSIONS": {
9 | // Make the server listen on all interfaces so it can be connected to from
10 | // other machines
11 | "http_server": {
12 | "host": ""
13 | },
14 |
15 | // RTK extension configuration
16 | "rtk": {
17 | // Add all serial ports as potential RTK data sources with baud rates 9600
18 | // and 57600
19 | "add_serial_ports": [9600, 57600]
20 | },
21 |
22 | // Listen for MAVLink-based drones
23 | "mavlink": {
24 | "enabled": true,
25 |
26 | // Specify the MAVLink networks that the extension will manage; each
27 | // network may contain up to 250 drones with system IDs from 1 to 250
28 | "networks": {
29 | "mav": {
30 | // Listen for heartbeats on UDP port 14550, send broadcasts to UDP port 14555
31 | "connections": ["default"]
32 | // Delete the previous line and uncomment the following two lines to enable a
33 | // secondary radio channel on port COM6, 57600 baud that is used for RTK
34 | // corrections and as a fallback for commands. Do _not_ uncomment this
35 | // line if you use Skybrush Sidekick to manage the radio channel.
36 | // "connections": ["default", "serial:COM6?baud=57600"],
37 | // "routing": {"rtk": [1]}
38 | }
39 | }
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/show/metadata.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Literal, Optional, TypedDict
2 |
3 |
4 | class ShowCoordinateSystem(TypedDict):
5 | """The coordinate system of a show."""
6 |
7 | origin: Optional[list[float]]
8 | """The origin of the coordinate system (longitude, latitude); ``None`` for
9 | indoor shows.
10 | """
11 |
12 | orientation: str
13 | """The orientation of the X axis of the coordinate system, stored as a string
14 | to avoid rounding errors.
15 | """
16 |
17 | type: Optional[Literal["nwu", "neu"]]
18 | """The type of the coordinate system; ``None`` for indoor shows."""
19 |
20 |
21 | class MissionInfo(TypedDict):
22 | id: str
23 | """Unique ID of the mission; may be empty if not provided."""
24 |
25 | title: str
26 | """The human-readable title of the mission; may be empty if not provided."""
27 |
28 | numDrones: int
29 | """The number of drones participating in the mission."""
30 |
31 |
32 | class ShowMetadata(TypedDict):
33 | """The metadata of a show upload attempt.
34 |
35 | Note the camelCased properties; this is intentional as this has to match
36 | what is being posted from Skybrush Live.
37 | """
38 |
39 | coordinateSystem: ShowCoordinateSystem
40 | """The coordinate system in which the show is defined."""
41 |
42 | geofence: Optional[dict[str, Any]]
43 | """The geofence of the show."""
44 |
45 | amslReference: Optional[float]
46 | """The altitude above mean sea level that corresponds to Z=0 in the show;
47 | ``None`` if the show is controlled based on AGL instead.
48 | """
49 |
50 | mission: MissionInfo
51 |
--------------------------------------------------------------------------------
/etc/deployment/docker/amd64/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax = docker/dockerfile:1.0-experimental
2 | FROM python:3.11-slim AS builder
3 |
4 | # Switch working directory
5 | WORKDIR /app
6 |
7 | # Copy the requirements file first so we don't need to rebuild the image from
8 | # scratch if the requirements don't change
9 | COPY requirements-main.txt ./
10 |
11 | # Create a virtual environment
12 | RUN python -m venv .venv
13 |
14 | # Run the build script
15 | ENV PIP_NO_CACHE_DIR=1
16 | RUN .venv/bin/pip install wheel
17 | RUN \
18 | --mount=type=secret,id=NETRC_SECRET_ID,dst=/root/.netrc \
19 | --mount=source=etc/deployment/docker/pip.conf,dst=/etc/pip.conf,readonly \
20 | .venv/bin/pip install -r requirements-main.txt
21 |
22 | # Now we can copy the application itself
23 | COPY ./src/flockwave ./src/flockwave
24 |
25 | # Clean up __pycache__ files
26 | RUN find . | grep -E "(__pycache__|\.pyc$)" | xargs rm -rf
27 |
28 | # Start the second stage where we don't add the stuff that we only need for
29 | # compiling things
30 | FROM python:3.11-slim
31 |
32 | # Add tini as an init system
33 | ENV TINI_VERSION v0.19.0
34 | ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
35 | RUN chmod +x /tini
36 |
37 | # Switch working directory
38 | WORKDIR /app
39 |
40 | # Copy the application from the first stage
41 | COPY --from=builder /app .
42 |
43 | # Create /data folder to put application-specific data in
44 | RUN mkdir /data
45 |
46 | # Set up the entrypoint
47 | COPY etc/deployment/docker/amd64/entrypoint.sh /usr/local/bin/
48 | RUN chmod 755 /usr/local/bin/entrypoint.sh
49 | ENTRYPOINT ["/tini", "--", "entrypoint.sh"]
50 |
51 | # Expose the default port
52 | EXPOSE 5000
53 |
--------------------------------------------------------------------------------
/etc/conf/skybrush-outdoor-dual-ip.jsonc:
--------------------------------------------------------------------------------
1 | /*
2 | * This configuration file shows how you can configure two MAVLink networks
3 | * if you have more than 250 drones. The networks are assumed to span different
4 | * IP networks; the first network is on 192.168.1.0/24 and the second network
5 | * is on 192.168.2.0/24. Drones in both networks are accessible on UDP port
6 | * 14555 and they send their telemetry data to the GCS on UDP port 14550.
7 | *
8 | * Furthermore, the configuration file assumes that the IP address of your GCS
9 | * is 192.168.1.254 in the first network and 192.168.2.254 in the second network.
10 | *
11 | * Refer to skybrush-outdoor-dual-udp.jsonc instead if you have a single IP
12 | * network and the drones are separated into groups of 250 by their UDP port
13 | * numbers.
14 | *
15 | * Refer to skybrush-outdoor.jsonc if you have a single IP network and not more
16 | * than 250 drones.
17 | */
18 | {
19 | "EXTENSIONS": {
20 | "http_server": {
21 | "host": ""
22 | },
23 | "show": {
24 | "default_start_method": "auto"
25 | },
26 | "mavlink": {
27 | "enabled": true,
28 | "networks": {
29 | "mav": null,
30 | "mav1": {
31 | "connections": [
32 | "udp-listen://192.168.1.254:14550?broadcast_port=14555"
33 | ],
34 | "routing": { "rtk": [0] }
35 | },
36 | "mav2": {
37 | "connections": [
38 | "udp-listen://192.168.2.254:14550?broadcast_port=14555"
39 | ],
40 | "routing": { "rtk": [0] },
41 | "id_offset": 250
42 | }
43 | }
44 | },
45 | "rtk": {
46 | "add_serial_ports": [9600, 57600]
47 | }
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/extensions.html.j2:
--------------------------------------------------------------------------------
1 | {% extends "_layout.html.j2" %}
2 |
3 | {% import "_tags.html.j2" as tags %}
4 |
5 | {% block body %}
6 |
7 |
8 |
9 | |
10 | Name |
11 | Version |
12 | Description |
13 | |
14 |
15 |
16 |
17 | {% for extension in extensions %}
18 |
20 | |
21 | {% if extension.restart_requested %}
22 |
23 | {% elif extension.loaded %}
24 |
25 | {% else %}
26 |
27 | {% endif %}
28 | |
29 |
30 | {{ prefix }}{{ extension.name }}
31 | |
32 |
33 | {{ extension.version or "" }}
34 | |
35 |
36 | {{ extension.description }}
37 | {{ tags.inline_list(extension.tags) }}
38 | |
39 |
40 | {% endfor %}
41 |
42 |
43 | {% endblock %}
44 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/constants.py:
--------------------------------------------------------------------------------
1 | """Constants used in several places throughout the model."""
2 |
3 | from __future__ import division
4 |
5 | from math import pi
6 |
7 | __all__ = (
8 | "WGS84",
9 | "GPS_PI",
10 | "PI_OVER_180",
11 | "SPEED_OF_LIGHT_M_S",
12 | "SPEED_OF_LIGHT_KM_S",
13 | )
14 |
15 |
16 | class WGS84(object):
17 | """WGS84 ellipsoid model parameters for Earth."""
18 |
19 | #: Equatorial radius of Earth in the WGS ellipsoid model
20 | EQUATORIAL_RADIUS_IN_METERS = 6378137.0
21 |
22 | #: Inverse flattening of Earth in the WGS ellipsoid model
23 | INVERSE_FLATTENING = 298.257223563
24 |
25 | #: Flattening of Earth in the WGS ellipsoid model
26 | FLATTENING = 1.0 / INVERSE_FLATTENING
27 |
28 | #: Eccentricity of Earth in the WGS ellipsoid model
29 | ECCENTRICITY = (FLATTENING * (2 - FLATTENING)) ** 0.5
30 |
31 | #: Square of the eccentricity of Earth in the WGS ellipsoid model
32 | ECCENTRICITY_SQUARED = ECCENTRICITY**2
33 |
34 | #: Polar radius of Earth in the WGS ellipsoid model
35 | POLAR_RADIUS_IN_METERS = EQUATORIAL_RADIUS_IN_METERS * (1 - FLATTENING)
36 |
37 | #: Gravitational constant times Earth's mass
38 | GRAVITATIONAL_CONSTANT_TIMES_MASS = 3.986005e14
39 |
40 | #: Earth's rotation rate [rad/sec]
41 | ROTATION_RATE_IN_RADIANS_PER_SEC = 7.2921151467e-5
42 |
43 |
44 | #: pi over 180; multiplicative constant to turn degrees into radians
45 | PI_OVER_180 = pi / 180
46 |
47 | #: Value of pi used in some GPS-specific calculations.
48 | GPS_PI = 3.1415926535898
49 |
50 | #: Speed of light in km/s
51 | SPEED_OF_LIGHT_KM_S = 299792.458
52 |
53 | #: Speed of light in m/s
54 | SPEED_OF_LIGHT_M_S = 299792458.0
55 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/payload.py:
--------------------------------------------------------------------------------
1 | import urllib
2 |
3 | from . import packet
4 |
5 |
6 | class Payload(object):
7 | """Engine.IO payload."""
8 |
9 | max_decode_packets = 16
10 |
11 | def __init__(self, packets=None, encoded_payload=None):
12 | self.packets = packets or []
13 | if encoded_payload is not None:
14 | self.decode(encoded_payload)
15 |
16 | def encode(self, jsonp_index=None):
17 | """Encode the payload for transmission."""
18 | encoded_payload = ""
19 | for pkt in self.packets:
20 | if encoded_payload:
21 | encoded_payload += "\x1e"
22 | encoded_payload += pkt.encode(b64=True)
23 | if jsonp_index is not None:
24 | encoded_payload = (
25 | "___eio["
26 | + str(jsonp_index)
27 | + ']("'
28 | + encoded_payload.replace('"', '\\"')
29 | + '");'
30 | )
31 | return encoded_payload
32 |
33 | def decode(self, encoded_payload):
34 | """Decode a transmitted payload."""
35 | self.packets = []
36 |
37 | if len(encoded_payload) == 0:
38 | return
39 |
40 | # JSONP POST payload starts with 'd='
41 | if encoded_payload.startswith("d="):
42 | encoded_payload = urllib.parse.parse_qs(encoded_payload)["d"][0]
43 |
44 | encoded_packets = encoded_payload.split("\x1e")
45 | if len(encoded_packets) > self.max_decode_packets:
46 | raise ValueError("Too many packets in payload")
47 | self.packets = [
48 | packet.Packet(encoded_packet=encoded_packet)
49 | for encoded_packet in encoded_packets
50 | ]
51 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/missions/types.py:
--------------------------------------------------------------------------------
1 | """Types specific to the mission planning and management extension."""
2 |
3 | from enum import Enum
4 |
5 | __all__ = ("MissionState",)
6 |
7 |
8 | class MissionState(Enum):
9 | """Enum representing the possible states of a single mission on the server."""
10 |
11 | NEW = "new"
12 | """The mission is newly created and may or may not have been parameterized.
13 | It is not ready to start yet.
14 | """
15 |
16 | AUTHORIZED_TO_START = "authorizedToStart"
17 | """The mission parameters and the mission plan have been finalized and the
18 | mission is waiting for a scheduled start time or a start signal. Modifications
19 | to the parameters or the plan are not allowed; the authorization must be
20 | revoked first to modify parameters further. The start time of the mission
21 | may still be modified or cleared.
22 | """
23 |
24 | ONGOING = "ongoing"
25 | """The mission is ongoing and its scheduled task is running on the server,
26 | managing the drones that are associated to the mission.
27 | """
28 |
29 | CANCELLED = "cancelled"
30 | """The mission was cancelled before it had a chance to start."""
31 |
32 | ABORTED = "aborted"
33 | """The mission was aborted by an unexpected event or by user intervention
34 | while it was being executed. The task associated to the mission is not
35 | running any more.
36 | """
37 |
38 | SUCCESSFUL = "successful"
39 | """The mission terminated successfully. The task associated to the mission
40 | is not running any more.
41 | """
42 |
43 | FAILED = "failed"
44 | """The mission terminated but it did not achieve its goals. The task
45 | associated to the mission is not running any more.
46 | """
47 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/crazyflie/led_lights.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from struct import Struct
4 | from typing import Optional
5 |
6 | from flockwave.server.tasks.led_lights import (
7 | LEDLightConfigurationManagerBase,
8 | LightConfiguration,
9 | LightEffectType,
10 | )
11 |
12 | from flockwave.server.ext.crazyflie.crtp_extensions import (
13 | DRONE_SHOW_PORT,
14 | DroneShowCommand,
15 | )
16 |
17 | from .connection import BroadcasterFunction
18 |
19 | __all__ = ("CrazyflieLEDLightConfigurationManager",)
20 |
21 |
22 | _light_control_packet_struct = Struct(" Optional[bytes]:
40 | """Creates a CRTP message payload for the message that we need to
41 | send to all the drones in order to instruct them to do the current light
42 | effect.
43 | """
44 | return _light_control_packet_struct.pack(
45 | DroneShowCommand.TRIGGER_GCS_LIGHT_EFFECT,
46 | 1 if config.effect == LightEffectType.SOLID else 0,
47 | config.color[0],
48 | config.color[1],
49 | config.color[2],
50 | )
51 |
52 | async def _send_light_control_packet(self, packet: bytes) -> None:
53 | self._broadcaster(DRONE_SHOW_PORT, 0, packet)
54 |
--------------------------------------------------------------------------------
/etc/conf/skybrush-outdoor-dual-udp.jsonc:
--------------------------------------------------------------------------------
1 | /*
2 | * This configuration file shows how you can configure two MAVLink networks
3 | * if you have more than 250 drones. The networks are assumed to live in the
4 | * same IP network that has enough IP addresses to host all the drones. Drones
5 | * in different MAVLink networks are separated by their UDP port numbers:
6 | * drones in the first network send their telemetry data to the GCS on UDP
7 | * port 14550 and listen for broadcast packets on UDP port 14555, while drones
8 | * in the second network send their telemetry data to UDP port 14650 and listen
9 | * for broadcast packets on UDP port 14655.
10 | *
11 | * Furthermore, the configuration file assumes that the IP address of your GCS
12 | * is 192.168.1.254.
13 | *
14 | * Refer to skybrush-outdoor-dual-ip.jsonc instead if you have more than one IP
15 | * network and all drones use the same UDP port numbes.
16 | *
17 | * Refer to skybrush-outdoor.jsonc if you have a single IP network and not more
18 | * than 250 drones.
19 | */
20 | {
21 | "EXTENSIONS": {
22 | "http_server": {
23 | "host": ""
24 | },
25 | "show": {
26 | "default_start_method": "auto"
27 | },
28 | "mavlink": {
29 | "enabled": true,
30 | "networks": {
31 | "mav": null,
32 | "mav1": {
33 | "connections": [
34 | "udp-listen://192.168.1.254:14550?broadcast_port=14555"
35 | ],
36 | "routing": { "rtk": [0] }
37 | },
38 | "mav2": {
39 | "connections": [
40 | "udp-listen://192.168.1.254:14650?broadcast_port=14655"
41 | ],
42 | "routing": { "rtk": [0] },
43 | "id_offset": 250
44 | }
45 | }
46 | },
47 | "rtk": {
48 | "add_serial_ports": [9600, 57600]
49 | }
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/flockwave/server/show/__init__.py:
--------------------------------------------------------------------------------
1 | """Temporary place for functions that are related to the processing of
2 | Skybrush-related file formats, until we find a better place for them.
3 | """
4 |
5 | from .flight_area import get_flight_area_configuration_from_show_specification
6 | from .formats import SkybrushBinaryShowFile
7 | from .geofence import get_geofence_configuration_from_show_specification
8 | from .lights import get_light_program_from_show_specification
9 | from .player import LightPlayer, TrajectoryPlayer
10 | from .safety import get_safety_configuration_from_show_specification
11 | from .specification import (
12 | get_altitude_reference_from_show_specification,
13 | get_coordinate_system_from_show_specification,
14 | get_drone_count_from_show_specification,
15 | get_group_index_from_show_specification,
16 | get_home_position_from_show_specification,
17 | get_trajectory_from_show_specification,
18 | is_coordinate_system_in_show_specification_geodetic,
19 | ShowSpecification,
20 | )
21 | from .trajectory import TrajectorySpecification
22 |
23 | __all__ = (
24 | "get_altitude_reference_from_show_specification",
25 | "get_coordinate_system_from_show_specification",
26 | "get_drone_count_from_show_specification",
27 | "get_flight_area_configuration_from_show_specification",
28 | "get_geofence_configuration_from_show_specification",
29 | "get_group_index_from_show_specification",
30 | "get_home_position_from_show_specification",
31 | "get_light_program_from_show_specification",
32 | "get_safety_configuration_from_show_specification",
33 | "get_trajectory_from_show_specification",
34 | "is_coordinate_system_in_show_specification_geodetic",
35 | "LightPlayer",
36 | "ShowSpecification",
37 | "SkybrushBinaryShowFile",
38 | "TrajectoryPlayer",
39 | "TrajectorySpecification",
40 | )
41 |
--------------------------------------------------------------------------------
/test/test_model_error_set.py:
--------------------------------------------------------------------------------
1 | from flockwave.server.model import ErrorSet
2 |
3 |
4 | def test_empty_errorset_reports_zero_length_and_falsey():
5 | es = ErrorSet()
6 | assert len(es) == 0
7 | assert not bool(es)
8 |
9 |
10 | def test_init_with_iterable():
11 | es = ErrorSet([17, 42, 80, 42])
12 | assert len(es) == 3
13 | assert set(es) == {17, 42, 80}
14 | assert sorted(es.json) == [17, 42, 80]
15 |
16 |
17 | def test_addition_removal_single():
18 | es = ErrorSet()
19 | es.ensure(17)
20 |
21 | assert 17 in es
22 | assert len(es) == 1
23 | assert list(es) == [17]
24 | assert es.json == [17]
25 |
26 | es.ensure(42)
27 | assert 42 in es
28 | assert len(es) == 2
29 | assert set(es) == {17, 42}
30 | assert es.json == [17, 42] or es.json == [42, 17]
31 |
32 | es.ensure(17, present=False)
33 | assert 17 not in es
34 | assert len(es) == 1
35 | assert list(es) == [42]
36 | assert es.json == [42]
37 |
38 | es.ensure(80, present=False) # removing non-existing code
39 | assert len(es) == 1
40 | assert list(es) == [42]
41 | assert es.json == [42]
42 |
43 |
44 | def test_addition_removal_many():
45 | es = ErrorSet([1, 2, 3, 4, 5])
46 |
47 | es.ensure_many({3: False, 4: False, 6: True, 7: True})
48 | assert len(es) == 5
49 | assert set(es) == {1, 2, 5, 6, 7}
50 | assert sorted(es.json) == [1, 2, 5, 6, 7]
51 |
52 |
53 | def test_replace_all_items():
54 | es = ErrorSet([10, 20, 30])
55 | es.set([20, 40, 50])
56 |
57 | assert len(es) == 3
58 | assert set(es) == {20, 40, 50}
59 | assert sorted(es.json) == [20, 40, 50]
60 |
61 |
62 | def test_clear_errors():
63 | es = ErrorSet([100, 200, 300])
64 | es.clear()
65 |
66 | assert len(es) == 0
67 | assert not bool(es)
68 | assert list(es) == []
69 | assert es.json == []
70 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/channel.py:
--------------------------------------------------------------------------------
1 | """Base model class that represents a communication channel between the
2 | server and a connected client.
3 |
4 | Concrete communication channel classes are typically implemented in
5 | extensions (e.g., the ``socketio`` extension for Socket.IO communication
6 | channels). The class in this package declares the base that the extensions
7 | must extend.
8 | """
9 |
10 | from __future__ import annotations
11 |
12 | from abc import ABC, abstractmethod
13 | from typing import Generic, TYPE_CHECKING, TypeVar
14 |
15 | if TYPE_CHECKING:
16 | from .client import Client
17 |
18 | __all__ = ("CommunicationChannel",)
19 |
20 | T = TypeVar("T")
21 |
22 |
23 | class CommunicationChannel(Generic[T], ABC):
24 | """Base model object representing a communication channel between the
25 | server and a client. Concrete implementations of this class are to be
26 | found in the appropriate Skybrush server extensions (e.g., the
27 | ``socketio`` extension for Socket.IO channels).
28 | """
29 |
30 | def bind_to(self, client: Client): # noqa: B027
31 | """Notifies the channel that it is communicating with the given
32 | client. Useful when the actual communication medium represented
33 | by this object is shared between multiple clients and the sending
34 | method has to know which client a message is intended to.
35 |
36 | Parameters:
37 | client: the client to bind the channel to
38 | """
39 | pass
40 |
41 | async def close(self, force: bool = False) -> None:
42 | """Closes the server's endpoint of the channel.
43 |
44 | Parameters:
45 | force: whethr to attempt a forceful close
46 | """
47 | raise NotImplementedError
48 |
49 | @abstractmethod
50 | async def send(self, message: T) -> None:
51 | """Sends the given message over the communication channel."""
52 | raise NotImplementedError
53 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/webui/templates/_layout.html.j2:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{ title }} — Skybrush Server
9 |
10 |
11 |
12 |
13 |
14 | {% block frontmatter %}{% endblock %}
15 |
16 |
17 |
18 | {% block navbar %}
19 | {% include "_navbar.html.j2" %}
20 | {% endblock %}
21 |
22 |
23 |
24 |
31 |
32 |
33 | {% include "_restart_banner.html.j2" %}
34 | {% block body %}{% endblock %}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {% block backmatter %}{% endblock %}
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/flockwave/proxy/launcher.py:
--------------------------------------------------------------------------------
1 | """Command line launcher for the Skybrush proxy server."""
2 |
3 | import click
4 | import dotenv
5 | import logging
6 | import sys
7 | import trio
8 |
9 | from flockwave import logger
10 | from flockwave.logger import log
11 |
12 | from .version import __version__
13 |
14 |
15 | @click.command()
16 | @click.option(
17 | "-c",
18 | "--config",
19 | type=click.Path(resolve_path=True),
20 | help="Name of the configuration file to load; defaults to "
21 | "skybrush-proxy.cfg in the current directory",
22 | )
23 | @click.option(
24 | "-d", "--debug/--no-debug", default=False, help="Start the proxy in debug mode"
25 | )
26 | @click.option(
27 | "-q", "--quiet/--no-quiet", default=False, help="Start the proxy in quiet mode"
28 | )
29 | @click.option(
30 | "--log-style",
31 | type=click.Choice(["fancy", "plain"]),
32 | default="fancy",
33 | help="Specify the style of the logging output",
34 | )
35 | @click.version_option(version=__version__)
36 | def start(config, debug, quiet, log_style):
37 | """Start the Skybrush proxy server."""
38 | # Set up the logging format
39 | logger.install(
40 | level=logging.DEBUG if debug else logging.WARN if quiet else logging.INFO,
41 | style=log_style,
42 | )
43 |
44 | # Load environment variables from .env
45 | dotenv.load_dotenv(verbose=debug)
46 |
47 | # Note the lazy import; this is to ensure that the logging is set up by the
48 | # time we start configuring the app.
49 | from flockwave.proxy.app import app
50 |
51 | # Log what we are doing
52 | log.info(f"Starting Skybrush proxy server {__version__}")
53 |
54 | # Configure the application
55 | retval = app.prepare(config, debug=debug)
56 | if retval is not None:
57 | return retval
58 |
59 | # Now start the server
60 | trio.run(app.run)
61 |
62 | # Log that we have stopped cleanly.
63 | log.info("Shutdown finished")
64 |
65 |
66 | if __name__ == "__main__":
67 | sys.exit(start(prog_name="skybrush-proxyd"))
68 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/launcher.py:
--------------------------------------------------------------------------------
1 | """Command line launcher for the Skybrush gateway server."""
2 |
3 | import click
4 | import dotenv
5 | import logging
6 | import sys
7 | import trio
8 |
9 | from flockwave import logger
10 | from flockwave.logger import log
11 |
12 | from .version import __version__
13 |
14 |
15 | @click.command()
16 | @click.option(
17 | "-c",
18 | "--config",
19 | type=click.Path(resolve_path=True),
20 | help="Name of the configuration file to load; defaults to "
21 | "skybrush-gateway.cfg in the current directory",
22 | )
23 | @click.option(
24 | "-d", "--debug/--no-debug", default=False, help="Start the gateway in debug mode"
25 | )
26 | @click.option(
27 | "-q", "--quiet/--no-quiet", default=False, help="Start the gateway in quiet mode"
28 | )
29 | @click.option(
30 | "--log-style",
31 | type=click.Choice(["fancy", "plain"]),
32 | default="fancy",
33 | help="Specify the style of the logging output",
34 | )
35 | @click.version_option(version=__version__)
36 | def start(config, debug, quiet, log_style):
37 | """Start the Skybrush gateway server."""
38 | # Set up the logging format
39 | logger.install(
40 | level=logging.DEBUG if debug else logging.WARN if quiet else logging.INFO,
41 | style=log_style,
42 | )
43 |
44 | # Load environment variables from .env
45 | dotenv.load_dotenv(verbose=debug)
46 |
47 | # Note the lazy import; this is to ensure that the logging is set up by the
48 | # time we start configuring the app.
49 | from flockwave.gateway.app import app
50 |
51 | # Log what we are doing
52 | log.info(f"Starting Skybrush gateway server {__version__}")
53 |
54 | # Configure the application
55 | retval = app.prepare(config, debug=debug)
56 | if retval is not None:
57 | return retval
58 |
59 | # Now start the server
60 | trio.run(app.run)
61 |
62 | # Log that we have stopped cleanly.
63 | log.info("Shutdown finished")
64 |
65 |
66 | if __name__ == "__main__":
67 | sys.exit(start(prog_name="skybrush-gatewayd"))
68 |
--------------------------------------------------------------------------------
/test/test_ext_mavlink_autopilots_ardupilot.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from flockwave.server.ext.mavlink.autopilots.ardupilot import (
3 | decode_parameters_from_packed_format,
4 | encode_parameters_to_packed_format,
5 | )
6 | from flockwave.server.ext.mavlink.enums import MAVParamType
7 |
8 |
9 | def test_decode_parameters_from_packed_format(datadir: Path) -> None:
10 | with (datadir / "param.pck").open("rb") as fp:
11 | params = list(decode_parameters_from_packed_format(fp))
12 |
13 | assert len(params) == 1431
14 |
15 | # Smoke test on a few parameters
16 | param_map = {param.name.decode("ascii"): param for param in params}
17 | assert param_map["SYSID_THISMAV"].value == 3
18 | assert param_map["PILOT_THR_BHV"].value == 0
19 | assert param_map["PILOT_THR_BHV"].type == MAVParamType.INT16
20 | assert param_map["SHOW_LED0_TYPE"].value == 6
21 | assert param_map["SHOW_LED0_TYPE"].type == MAVParamType.INT8
22 |
23 |
24 | def test_encode_parameters_to_packed_format() -> None:
25 | params = {
26 | "SHOW_START_TIME": 7654321,
27 | "SHOW_START_AUTH": 1,
28 | "show_origin_lat": 47.12345, # intentionally lowercase
29 | "SHOW_ORIGIN_LON": 8.12345,
30 | "FENCE_ENABLE": 1,
31 | "FENCE_ACTION": 2,
32 | # Add two parameters where one is a prefix of the other
33 | "FOO": 3,
34 | "FOOBAR": 4,
35 | # Add two parameters that differ only in their last character
36 | "FROB1": 5,
37 | "FROB2": 6,
38 | }
39 | packed = encode_parameters_to_packed_format(params)
40 | assert packed == (
41 | # fmt: off
42 | b"\x1b\x67\x0a\x00h\x00"
43 | b"\x01\xb0FENCE_ACTION"
44 | b"\x02\x01\x56ENABLE\x01"
45 | b"\x01\x11OO\x03"
46 | b"\x01\x23BAR\x04"
47 | b"\x01\x31ROB1\x05"
48 | b"\x01\x042\x06"
49 | b"\x04\xe0SHOW_ORIGIN_LATj~ MotionCaptureFrameItem:
47 | """Adds a new item to this frame and returns the newly added item.
48 |
49 | Parameters:
50 | name: the name of the rigid body
51 | position: the position information; ``None`` if tracking was lost
52 | attitude: the attitude quaternion; ``None`` if tracking was lost or
53 | it is not tracked
54 | """
55 | item = MotionCaptureFrameItem(name=name, position=position, attitude=attitude)
56 | self.items.append(item)
57 | return item
58 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/errors.py:
--------------------------------------------------------------------------------
1 | """Error classes specific to the MAVLink extension."""
2 |
3 | from typing import Optional, Union
4 |
5 | from .enums import MAVMissionResult
6 |
7 | __all__ = (
8 | "MAVLinkExtensionError",
9 | "UnknownFlightModeError",
10 | "InvalidSigningKeyError",
11 | "InvalidSystemIdError",
12 | "MissionAcknowledgmentError",
13 | )
14 |
15 |
16 | class MAVLinkExtensionError(RuntimeError):
17 | """Base class for all error classes related to the MAVLink extension."""
18 |
19 | pass
20 |
21 |
22 | class UnknownFlightModeError(MAVLinkExtensionError):
23 | """Error thrown when the driver cannot decode a flight mode name to a
24 | base mode / custom mode configuration.
25 | """
26 |
27 | def __init__(self, mode: Union[str, int], message: Optional[str] = None):
28 | message = message or f"Unknown flight mode: {mode!r}"
29 | super().__init__(message)
30 |
31 |
32 | class InvalidSigningKeyError(MAVLinkExtensionError):
33 | """Error thrown when there is a problem with a MAVLink signing key."""
34 |
35 | pass
36 |
37 |
38 | class InvalidSystemIdError(MAVLinkExtensionError):
39 | """Error thrown when a system ID is invalid or outside an allowed range."""
40 |
41 | def __init__(self, system_id: int, message: Optional[str] = None):
42 | message = message or f"Invalid system ID: {system_id!r}"
43 | super().__init__(message)
44 |
45 |
46 | class MissionAcknowledgmentError(MAVLinkExtensionError):
47 | """Error thrown when a mission item operation fails with a non-success
48 | MAV_MISSION_RESULT value.
49 | """
50 |
51 | result: int
52 | operation: Optional[str]
53 |
54 | def __init__(self, result: int, operation: Optional[str] = None):
55 | self.result = result
56 | self.operation = operation
57 |
58 | operation = (
59 | f"MAVLink mission operation ({operation})"
60 | if operation
61 | else "MAVLink mission operation"
62 | )
63 | message = f"{operation} returned {MAVMissionResult.describe(result)}"
64 | super().__init__(message)
65 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/__init__.py:
--------------------------------------------------------------------------------
1 | """Model-related classes for the Skybrush server."""
2 |
3 | from .battery import BatteryInfo
4 | from .builders import CommandExecutionStatusBuilder, FlockwaveMessageBuilder
5 | from .channel import CommunicationChannel
6 | from .client import Client
7 | from .clock import Clock, ClockBase, StoppableClockBase
8 | from .commands import CommandExecutionStatus, Progress
9 | from .connection import ConnectionPurpose, ConnectionInfo, ConnectionStatus
10 | from .devices import (
11 | ChannelNode,
12 | ChannelOperation,
13 | ChannelType,
14 | DeviceClass,
15 | DeviceTree,
16 | DeviceNode,
17 | DeviceTreeNodeType,
18 | DeviceTreeSubscriptionManager,
19 | ObjectNode,
20 | )
21 | from .errors import ClientNotSubscribedError, NoSuchPathError
22 | from .error_set import ErrorSet
23 | from .identifiers import default_id_generator
24 | from .messages import FlockwaveMessage, FlockwaveNotification, FlockwaveResponse
25 | from .object import ModelObject
26 | from .uav import PassiveUAVDriver, UAVStatusInfo, UAVDriver, UAV, UAVBase
27 | from .weather import Weather
28 | from .world import World
29 |
30 |
31 | __all__ = (
32 | "default_id_generator",
33 | "FlockwaveMessage",
34 | "FlockwaveMessageBuilder",
35 | "FlockwaveNotification",
36 | "FlockwaveResponse",
37 | "UAVStatusInfo",
38 | "UAVDriver",
39 | "UAV",
40 | "UAVBase",
41 | "ModelObject",
42 | "BatteryInfo",
43 | "Client",
44 | "ConnectionInfo",
45 | "ConnectionPurpose",
46 | "ConnectionStatus",
47 | "CommandExecutionStatus",
48 | "CommandExecutionStatusBuilder",
49 | "Clock",
50 | "ClockBase",
51 | "StoppableClockBase",
52 | "ChannelNode",
53 | "ChannelOperation",
54 | "ChannelType",
55 | "DeviceClass",
56 | "DeviceNode",
57 | "DeviceTree",
58 | "DeviceTreeNodeType",
59 | "ErrorSet",
60 | "ObjectNode",
61 | "DeviceTreeSubscriptionManager",
62 | "NoSuchPathError",
63 | "ClientNotSubscribedError",
64 | "World",
65 | "CommunicationChannel",
66 | "PassiveUAVDriver",
67 | "Weather",
68 | "Progress",
69 | )
70 |
--------------------------------------------------------------------------------
/src/flockwave/server/show/safety.py:
--------------------------------------------------------------------------------
1 | """Temporary place for functions that are related to the processing of
2 | Skybrush-related safety specifications, until we find a better place for them.
3 | """
4 |
5 | from typing import Dict
6 |
7 | from flockwave.server.model.safety import (
8 | LowBatteryThreshold,
9 | LowBatteryThresholdType,
10 | SafetyConfigurationRequest,
11 | )
12 | from flockwave.server.utils import optional_float
13 |
14 | __all__ = ("get_safety_configuration_from_show_specification",)
15 |
16 |
17 | def get_safety_configuration_from_show_specification(
18 | show: Dict,
19 | ) -> SafetyConfigurationRequest:
20 | result = SafetyConfigurationRequest()
21 |
22 | safety = show.get("safety", None)
23 | if not safety:
24 | # Show contains no safety specification so nothing to configure, just
25 | # leave the request empty
26 | return result
27 |
28 | version = safety.get("version", 0)
29 | if version is None:
30 | raise RuntimeError("safety specification must have a version number")
31 | if version not in [1, 2]:
32 | raise RuntimeError("only version 1 or 2 safety specifications are supported")
33 |
34 | if version == 1:
35 | voltage = optional_float(safety.get("lowBatteryVoltage"))
36 | if voltage is not None:
37 | result.low_battery_threshold = LowBatteryThreshold(
38 | type=LowBatteryThresholdType.VOLTAGE, value=voltage
39 | )
40 | else:
41 | result.low_battery_threshold = None
42 | elif version == 2:
43 | threshold = safety.get("lowBatteryThreshold")
44 | if threshold is not None:
45 | result.low_battery_threshold = LowBatteryThreshold.from_json(threshold)
46 | else:
47 | result.low_battery_threshold = None
48 |
49 | result.critical_battery_voltage = optional_float(
50 | safety.get("criticalBatteryVoltage")
51 | )
52 | result.return_to_home_altitude = optional_float(safety.get("returnToHomeAltitude"))
53 | result.return_to_home_speed = optional_float(safety.get("returnToHomeSpeed"))
54 |
55 | return result
56 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/config.py:
--------------------------------------------------------------------------------
1 | """Default configuration for the Skybrush gateway server.
2 |
3 | This script will be evaluated first when the gateway attempts to load its
4 | configuration. Configuration files may import variables from this module
5 | with `from flockwave.gateway.config import SOMETHING`, and may also modify
6 | them if the variables are mutable.
7 | """
8 |
9 | # IP address on which the gateway will be listening for incoming HTTP requests
10 | HOST = "127.0.0.1"
11 |
12 | # Port on which the gateway will be listening for incoming HTTP requests
13 | PORT = 8080
14 |
15 | # Maximum number of workers to launch at the same time
16 | MAX_WORKERS = 4
17 |
18 | # Secret key used for JWT tokens sent to the gateway when someone wants to
19 | # spin up a new worker. Only tokens signed with this key will be accepted.
20 | JWT_SECRET = "a-string-secret-at-least-256-bits-long"
21 |
22 | # Set this to a truthy value to make the root URL redirect to aonther address
23 | ROOT_REDIRECTS_TO = None
24 |
25 | # Specify a custom URL template to return for the spawned worker processes
26 | # if you are sitting behind a proxy.
27 | PUBLIC_URL = "http://share.skybrush.io:4117/app/"
28 |
29 | # Configuration object to use for spawned workers
30 | WORKER_CONFIG = {
31 | "EXTENSIONS": {
32 | "auth": {},
33 | "auth_jwt": {"secret": JWT_SECRET},
34 | "auto_shutdown": {"timeout": 30},
35 | "connection_limits": {
36 | "auth_deadline": 10,
37 | "max_clients": 1,
38 | "max_duration": 3600,
39 | },
40 | "frontend": {},
41 | "http_server": {"host": "", "port": "@PORT@"},
42 | "show": {},
43 | "socketio": {},
44 | "system_clock": {},
45 | "virtual_uavs": {
46 | "arm_after_boot": True,
47 | "add_noise": False,
48 | "count": 5,
49 | "delay": 0.2,
50 | "enabled": False,
51 | "id_format": "{0:02}",
52 | "origin": [19.062159, 47.473360], # ELTE kert
53 | "orientation": 0,
54 | "takeoff_area": {"type": "grid", "spacing": 5},
55 | },
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/missions/examples.py:
--------------------------------------------------------------------------------
1 | from logging import Logger
2 | from trio import sleep
3 | from typing import Any, Optional
4 |
5 | from .model import Mission, MissionType
6 |
7 | __all__ = ("LandImmediatelyMissionType",)
8 |
9 |
10 | class LandImmediatelyMission(Mission):
11 | """Example mission that lands all associated drones as soon as it gains
12 | control of the drone.
13 |
14 | This mission type is mostly for illustrative and testing purposes.
15 | """
16 |
17 | delay: float = 0.0
18 | """Number of seconds to wait before sending the landing command to a drone."""
19 |
20 | @Mission.parameters.getter
21 | def parameters(self) -> dict[str, Any]:
22 | return {"delay": self.delay}
23 |
24 | async def _run(self, log: Optional[Logger] = None) -> None:
25 | await sleep(30)
26 |
27 | def _update_parameters(self, parameters: dict[str, Any]) -> None:
28 | delay = parameters.get("delay")
29 | if delay is not None:
30 | self.delay = delay
31 |
32 |
33 | class LandImmediatelyMissionType(MissionType[LandImmediatelyMission]):
34 | """Example mission type that lands all associate drones as soon as it
35 | gains control of the drone.
36 |
37 | This mission type is mostly for illustrative and testing purposes.
38 | """
39 |
40 | @property
41 | def description(self) -> str:
42 | return "Lands all associated UAVs as soon as possible."
43 |
44 | @property
45 | def name(self) -> str:
46 | return "Landing"
47 |
48 | def create_mission(self) -> LandImmediatelyMission:
49 | return LandImmediatelyMission()
50 |
51 | def get_parameter_schema(self) -> dict[str, Any]:
52 | return {
53 | "type": "object",
54 | "properties": {
55 | "delay": {
56 | "description": "Number of seconds to wait before landing.",
57 | "type": "number",
58 | "inclusiveMinimum": 0,
59 | "default": 0,
60 | }
61 | },
62 | "additionalProperties": False,
63 | }
64 |
65 | def get_plan_parameter_schema(self) -> dict[str, Any]:
66 | return {}
67 |
--------------------------------------------------------------------------------
/etc/deployment/rpi/run-tasks-at-boot:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | BOOT_DIR=/boot
6 | CONFIG_FILE=$BOOT_DIR/collmot/network.cfg
7 |
8 | if [ ! -f $CONFIG_FILE ]; then
9 | exit 0
10 | fi
11 |
12 | # If we have a saved hash of the configuration, compare it with the current one
13 | if [ -f ${CONFIG_FILE}.md5 ]; then
14 | md5sum --status --strict -c ${CONFIG_FILE}.md5 && exit 0
15 | fi
16 |
17 | # Calculate MD5 hash of collmot.cfg
18 | md5sum $CONFIG_FILE >${CONFIG_FILE}.md5
19 |
20 | # Read configuration file from boot partition
21 | source $CONFIG_FILE
22 |
23 | # Remove custom network configuration section from /etc/dhcpcd.conf
24 | cp /etc/dhcpcd.conf /tmp/dhcpcd.conf.new
25 | sed -i -e '/^# Start of CollMot/,/^# End of CollMot/d' /tmp/dhcpcd.conf.new
26 |
27 | # Iterate over network interfaces to be configured
28 | echo "# Start of CollMot-specific section, do not modify" >>/tmp/dhcpcd.conf.new
29 | for IFACE in eth0; do
30 | IFACE_UPPER=`echo $IFACE | tr '[:lower:]' '[:upper:]'`
31 | ADDRESS_VAR=ADDRESS_${IFACE_UPPER}
32 | ADDRESS="${!ADDRESS_VAR}"
33 |
34 | if [ "x$ADDRESS" = x ]; then
35 | # Configure for DHCP; no extra lines needed
36 | true
37 | else
38 | GATEWAY_VAR=GATEWAY_${IFACE_UPPER}
39 | GATEWAY="${!GATEWAY_VAR}"
40 |
41 | DNS_VAR=DNS_${IFACE_UPPER}
42 | DNS="${!DNS_VAR}"
43 |
44 | # Configure for static IP
45 | cat >>/tmp/dhcpcd.conf.new <>/tmp/dhcpcd.conf.new
52 | fi
53 |
54 | if [ "x${DNS}" != x ]; then
55 | echo "static domain_name_servers=${DNS}" >>/tmp/dhcpcd.conf.new
56 | fi
57 | fi
58 | done
59 | echo "# End of CollMot-specific section" >>/tmp/dhcpcd.conf.new
60 | mv /tmp/dhcpcd.conf.new /etc/dhcpcd.conf
61 |
62 | # Configure wireless interface if needed
63 | if [ "x$WIRELESS_AP_NAME" != x ]; then
64 | cat >/boot/wpa_supplicant.conf < float:
37 | """Returns the roll angle of the UAV in [deg]."""
38 | return self._roll
39 |
40 | @roll.setter
41 | def roll(self, value: float) -> None:
42 | self._roll = float(value)
43 |
44 | @property
45 | def pitch(self) -> float:
46 | """Returns the pitch angle of the UAV in [deg]."""
47 | return self._pitch
48 |
49 | @pitch.setter
50 | def pitch(self, value: float) -> None:
51 | self._pitch = float(value)
52 |
53 | @property
54 | def yaw(self) -> float:
55 | """Returns the yaw angle of the UAV in [deg]."""
56 | return self._yaw
57 |
58 | @yaw.setter
59 | def yaw(self, value: float) -> None:
60 | self._yaw = float(value)
61 |
62 | @property
63 | def json(self):
64 | roll = int(round(self.roll * 10)) % 3600
65 | if roll >= 1800:
66 | roll -= 3600
67 | pitch = int(round(self.pitch * 10)) % 3600
68 | if pitch >= 1800:
69 | pitch -= 3600
70 | yaw = int(round(self.yaw * 10)) % 3600
71 |
72 | return [roll, pitch, yaw]
73 |
74 | def update_from(self, other):
75 | self._roll = other._roll
76 | self._pitch = other._pitch
77 | self._yaw = other._yaw
78 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/transport.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, ClassVar
2 |
3 | from flockwave.spec.schema import get_complex_object_schema
4 |
5 | from .metamagic import ModelMeta
6 |
7 | __all__ = ("TransportOptions",)
8 |
9 |
10 | class TransportOptions(metaclass=ModelMeta):
11 | """Class representing the transport options attached to some of the UAV
12 | command requests.
13 | """
14 |
15 | class __meta__:
16 | schema = get_complex_object_schema("transportOptions")
17 |
18 | from_json: ClassVar[Callable[[Any], "TransportOptions"]]
19 |
20 | @classmethod
21 | def is_broadcast(cls, transport: Any) -> bool:
22 | """Returns whether the given object is a transport options object (with
23 | type checking) and whether it indicates that we should broadcast a
24 | particular message.
25 |
26 | This function is safe to be called with any type of object.
27 | """
28 | if isinstance(transport, cls):
29 | return bool(getattr(transport, "broadcast", False))
30 | else:
31 | return False
32 |
33 | @classmethod
34 | def is_secondary(cls, transport: Any) -> bool:
35 | """Returns whether the given object is a transport options object (with
36 | type checking) and whether it indicates that the message should be sent
37 | over some non-primary channel. Non-primary channels are channels with
38 | indices larger than zero.
39 |
40 | This function is safe to be called with any type of object.
41 | """
42 | if isinstance(transport, cls):
43 | index = getattr(transport, "channel", 0)
44 | return isinstance(index, (int, float)) and index > 0
45 | else:
46 | return False
47 |
48 | @classmethod
49 | def should_ignore_ids(cls, transport: Any) -> bool:
50 | """Returns whether the given object is a transport options object (with
51 | type checking) and whether it indicates that we should ignore the UAV
52 | IDs submitted in a particular message.
53 |
54 | This function is safe to be called with any type of object.
55 | """
56 | if isinstance(transport, cls):
57 | return bool(getattr(transport, "ignoreIds", False))
58 | else:
59 | return False
60 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/static_files.py:
--------------------------------------------------------------------------------
1 | content_types = {
2 | "css": "text/css",
3 | "gif": "image/gif",
4 | "html": "text/html",
5 | "jpg": "image/jpeg",
6 | "js": "application/javascript",
7 | "json": "application/json",
8 | "png": "image/png",
9 | "txt": "text/plain",
10 | }
11 |
12 |
13 | def get_static_file(path, static_files):
14 | """Return the local filename and content type for the requested static
15 | file URL.
16 |
17 | :param path: the path portion of the requested URL.
18 | :param static_files: a static file configuration dictionary.
19 |
20 | This function returns a dictionary with two keys, "filename" and
21 | "content_type". If the requested URL does not match any static file, the
22 | return value is None.
23 | """
24 | extra_path = ""
25 | if path in static_files:
26 | f = static_files[path]
27 | else:
28 | f = None
29 | while path != "":
30 | path, last = path.rsplit("/", 1)
31 | extra_path = "/" + last + extra_path
32 | if path in static_files:
33 | f = static_files[path]
34 | break
35 | elif path + "/" in static_files:
36 | f = static_files[path + "/"]
37 | break
38 | if f:
39 | if isinstance(f, str):
40 | f = {"filename": f}
41 | else:
42 | f = f.copy() # in case it is mutated below
43 | if f["filename"].endswith("/") and extra_path.startswith("/"):
44 | extra_path = extra_path[1:]
45 | f["filename"] += extra_path
46 | if f["filename"].endswith("/"):
47 | if "" in static_files:
48 | if isinstance(static_files[""], str):
49 | f["filename"] += static_files[""]
50 | else:
51 | f["filename"] += static_files[""]["filename"]
52 | if "content_type" in static_files[""]:
53 | f["content_type"] = static_files[""]["content_type"]
54 | else:
55 | f["filename"] += "index.html"
56 | if "content_type" not in f:
57 | ext = f["filename"].rsplit(".")[-1]
58 | f["content_type"] = content_types.get(ext, "application/octet-stream")
59 | return f
60 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/frontend/static/style.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css?family=Roboto:300,400&display=swap");
2 |
3 | body {
4 | margin: 0;
5 | padding: 0;
6 | }
7 |
8 | .cover-page {
9 | background: #eceff1;
10 | font-family: "Roboto", "Open Sans", "SF UI Text", Arial, sans-serif;
11 | font-size: 16px;
12 | height: 100vh;
13 | text-align: center;
14 | width: 100%;
15 | z-index: 10000;
16 |
17 | -webkit-touch-callout: none; /* iOS Safari */
18 | -webkit-user-select: none; /* Safari */
19 | -khtml-user-select: none; /* Konqueror HTML */
20 | -moz-user-select: none; /* Firefox */
21 | -ms-user-select: none; /* Internet Explorer/Edge */
22 | user-select: none;
23 | }
24 |
25 | .cover-page-content {
26 | color: #607d8b;
27 | position: absolute;
28 | left: 50%;
29 | top: 50%;
30 | transform: translate(-50%, -50%);
31 | }
32 |
33 | .cover-page h1 {
34 | color: #455a64;
35 | font-size: 32px;
36 | font-weight: 200;
37 | margin: 0.25em 0;
38 | text-shadow: 0 1px 1px rgba(0, 0, 0, 0.3);
39 | }
40 |
41 | .button-bar {
42 | display: flex;
43 | flex-direction: row;
44 | justify-content: center;
45 | margin: 1.75rem 0;
46 | }
47 |
48 | .button-bar .button {
49 | background-color: #037de8;
50 | border: 1px solid #037de8;
51 | border-radius: 0.25rem;
52 | color: white;
53 | display: inline-block;
54 | font-size: 1rem;
55 | line-height: 1.75rem;
56 | margin: 0 0.4375rem;
57 | padding: 0.4375rem 1.375rem;
58 | text-align: center;
59 | text-decoration: none;
60 | user-select: none;
61 | vertical-align: middle;
62 |
63 | box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
64 | text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
65 |
66 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,
67 | border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out,
68 | transform 0.15s ease-in-out;
69 | }
70 |
71 | .button-bar .button:hover {
72 | background-color: #036ecc;
73 | border-color: #036ecc;
74 | text-decoration: none;
75 | }
76 |
77 | .button-bar .button:active {
78 | background-color: #0464b9;
79 | border-color: #0464b9;
80 | box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
81 | text-decoration: none;
82 | transform: translateY(1px);
83 | }
84 |
85 | .button-bar .button-disabled {
86 | opacity: 0.5;
87 | filter: grayscale(1);
88 | }
89 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/magnetic_field.py:
--------------------------------------------------------------------------------
1 | """Extension that registers a weather provider that provides information about
2 | Earth's magnetic field using the IGRF13 model.
3 | """
4 |
5 | from datetime import datetime
6 | from trio import sleep_forever
7 | from typing import Optional
8 |
9 | from igrf_model import DateBoundIGRFModel, IGRFModel
10 |
11 | from flockwave.gps.vectors import GPSCoordinate
12 | from flockwave.server.model.weather import Weather
13 |
14 |
15 | last_model: Optional[DateBoundIGRFModel] = None
16 | """The last model that the extension used."""
17 |
18 | model_validity_range: tuple[float, float] = (-1, -1)
19 | """Tuple storing the POSIX timestamp range when the model is considered valid."""
20 |
21 | ONE_WEEK = 7 * 24 * 3600
22 | """One week in seconds."""
23 |
24 |
25 | async def provide_magnetic_field(
26 | weather: Weather, position: Optional[GPSCoordinate]
27 | ) -> None:
28 | """Extends the given weather object with Earth's magnetic field according
29 | to the IGRF13 model, at the timestamp corresponding to the weather object.
30 | """
31 | global last_model, model_validity_range
32 |
33 | if getattr(weather, "magneticVector", None) is not None:
34 | return
35 |
36 | if position is None:
37 | return
38 |
39 | model_valid_from, model_valid_until = model_validity_range
40 | timestamp = int(weather.timestamp)
41 | if (
42 | last_model is None
43 | or timestamp < model_valid_from
44 | or timestamp > model_valid_until
45 | ):
46 | last_model = IGRFModel.get(version=13).at(datetime.fromtimestamp(timestamp))
47 |
48 | # We assume that the magnetic field does not change significantly in
49 | # two weeks
50 | model_validity_range = timestamp - ONE_WEEK, timestamp + ONE_WEEK
51 |
52 | vec = last_model.evaluate(
53 | position.lat, position.lon, position.amsl if position.amsl is not None else 0
54 | )
55 | weather.magneticVector = (vec.north, vec.east, vec.down) # type: ignore
56 |
57 |
58 | async def run(app):
59 | id = "magneticField:igrf13"
60 | with app.import_api("weather").use_provider(provide_magnetic_field, id=id):
61 | await sleep_forever()
62 |
63 |
64 | dependencies = ("weather",)
65 | description = "Weather provider that provides information about Earth's magnetic field based on the IGRF13 model"
66 | schema = {}
67 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v4/trio_manager.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import trio
3 |
4 | from .base_manager import BaseManager
5 |
6 |
7 | class TrioManager(BaseManager):
8 | """Manage a client list for a Trio server."""
9 |
10 | async def emit(
11 | self, event, data, namespace, room=None, skip_sid=None, callback=None, **kwargs
12 | ):
13 | """Emit a message to a single client, a room, or all the clients
14 | connected to the namespace.
15 |
16 | Note: this method is a coroutine.
17 | """
18 | if namespace not in self.rooms or room not in self.rooms[namespace]:
19 | return
20 | tasks = []
21 | if not isinstance(skip_sid, list):
22 | skip_sid = [skip_sid]
23 | for sid in self.get_participants(namespace, room):
24 | if sid not in skip_sid:
25 | if callback is not None:
26 | id = self._generate_ack_id(sid, namespace, callback)
27 | else:
28 | id = None
29 | tasks.append(
30 | (self.server._emit_internal, (sid, event, data, namespace, id))
31 | )
32 | if tasks == []: # pragma: no cover
33 | return
34 | async with trio.open_nursery() as nursery:
35 | for func, args in tasks:
36 | nursery.start_soon(func, *args)
37 |
38 | async def close_room(self, room, namespace):
39 | """Remove all participants from a room.
40 |
41 | Note: this method is a coroutine.
42 | """
43 | return super().close_room(room, namespace)
44 |
45 | async def trigger_callback(self, sid, namespace, id, data):
46 | """Invoke an application callback.
47 |
48 | Note: this method is a coroutine.
49 | """
50 | callback = None
51 | try:
52 | callback = self.callbacks[sid][namespace][id]
53 | except KeyError:
54 | # if we get an unknown callback we just ignore it
55 | self._get_logger().warning("Unknown callback received, ignoring.")
56 | else:
57 | del self.callbacks[sid][namespace][id]
58 | if callback is not None:
59 | ret = callback(*data)
60 | if inspect.iscoroutine(ret):
61 | with trio.CancelScope():
62 | await ret
63 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/led_lights.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from logging import Logger
4 | from struct import Struct
5 | from typing import Optional, TYPE_CHECKING
6 |
7 | from flockwave.server.tasks.led_lights import (
8 | LightConfiguration,
9 | LightEffectType,
10 | LEDLightConfigurationManagerBase,
11 | )
12 |
13 | from .packets import create_led_control_packet
14 | from .types import MAVLinkMessageSpecification
15 |
16 | __all__ = ("MAVLinkLEDLightConfigurationManager",)
17 |
18 | if TYPE_CHECKING:
19 | from .network import MAVLinkNetwork
20 |
21 |
22 | _light_control_packet_struct = Struct(" Optional[MAVLinkMessageSpecification]:
44 | """Creates a MAVLink message specification for the MAVLink message that
45 | we need to send to all the drones in order to instruct them to do the
46 | current light effect.
47 | """
48 | is_active = config.effect == LightEffectType.SOLID
49 | data = _light_control_packet_struct.pack(
50 | config.color[0],
51 | config.color[1],
52 | config.color[2],
53 | (
54 | 30000 # drone will switch back to normal mode after 30 sec
55 | if is_active
56 | else 0
57 | ), # submitting zero duration turns off any effect that we have
58 | 1 if is_active else 0,
59 | )
60 | return create_led_control_packet(data, broadcast=True)
61 |
62 | def _get_logger(self) -> Optional[Logger]:
63 | return self._network.log if self._network else None
64 |
65 | async def _send_light_control_packet(
66 | self, packet: MAVLinkMessageSpecification
67 | ) -> None:
68 | return await self._network.broadcast_packet(packet)
69 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/socketio_v5/trio_manager.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import trio
3 |
4 | from .base_manager import BaseManager
5 |
6 |
7 | class TrioManager(BaseManager):
8 | """Manage a client list for a Trio server."""
9 |
10 | async def can_disconnect(self, sid, namespace):
11 | return self.is_connected(sid, namespace)
12 |
13 | async def emit(
14 | self, event, data, namespace, room=None, skip_sid=None, callback=None, **kwargs
15 | ):
16 | """Emit a message to a single client, a room, or all the clients
17 | connected to the namespace.
18 |
19 | Note: this method is a coroutine.
20 | """
21 | if namespace not in self.rooms or room not in self.rooms[namespace]:
22 | return
23 | tasks = []
24 | if not isinstance(skip_sid, list):
25 | skip_sid = [skip_sid]
26 | for sid, eio_sid in self.get_participants(namespace, room):
27 | if sid not in skip_sid:
28 | if callback is not None:
29 | id = self._generate_ack_id(sid, callback)
30 | else:
31 | id = None
32 | tasks.append(
33 | (self.server._emit_internal, (eio_sid, event, data, namespace, id))
34 | )
35 | if not tasks: # pragma: no cover
36 | return
37 | async with trio.open_nursery() as nursery:
38 | for func, args in tasks:
39 | nursery.start_soon(func, *args)
40 |
41 | async def close_room(self, room, namespace):
42 | """Remove all participants from a room.
43 |
44 | Note: this method is a coroutine.
45 | """
46 | return super().close_room(room, namespace)
47 |
48 | async def trigger_callback(self, sid, id, data):
49 | """Invoke an application callback.
50 |
51 | Note: this method is a coroutine.
52 | """
53 | callback = None
54 | try:
55 | callback = self.callbacks[sid][id]
56 | except KeyError:
57 | # if we get an unknown callback we just ignore it
58 | self._get_logger().warning("Unknown callback received, ignoring.")
59 | else:
60 | del self.callbacks[sid][id]
61 | if callback is not None:
62 | ret = callback(*data)
63 | if inspect.iscoroutine(ret):
64 | with trio.CancelScope():
65 | await ret
66 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/battery.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | __all__ = ("BatteryInfo",)
4 |
5 |
6 | class BatteryInfo:
7 | """Class representing the battery information of a single device
8 | (typically a UAV).
9 | """
10 |
11 | def __init__(self):
12 | self._charging = False
13 | self._voltage = None
14 | self._percentage = None
15 |
16 | @property
17 | def charging(self) -> bool:
18 | return self._charging
19 |
20 | @charging.setter
21 | def charging(self, value: bool) -> None:
22 | self._charging = bool(value)
23 |
24 | @property
25 | def percentage(self) -> Optional[int]:
26 | return self._percentage
27 |
28 | @percentage.setter
29 | def percentage(self, value: Optional[int]) -> None:
30 | self._percentage = int(value) if value is not None else None
31 |
32 | @property
33 | def voltage(self) -> Optional[float]:
34 | return self._voltage
35 |
36 | @voltage.setter
37 | def voltage(self, value: Optional[float]) -> None:
38 | self._voltage = float(value) if value is not None else None
39 |
40 | @property
41 | def json(self):
42 | if self.voltage is None:
43 | if self.percentage is None:
44 | result = [0.0]
45 | else:
46 | result = [None, self.percentage]
47 | elif self.percentage is None:
48 | result = [int(round(self.voltage * 10))]
49 | else:
50 | result = [int(round(self.voltage * 10)), self.percentage]
51 | if self._charging:
52 | while len(result) < 2:
53 | result.append(None)
54 | result.append(True)
55 | return result
56 |
57 | @json.setter
58 | def json(self, value):
59 | length = len(value)
60 | if length == 0:
61 | self._voltage = self._percentage = None
62 | self._charging = False
63 | else:
64 | self._voltage = value[0] / 10
65 | if length < 2:
66 | self._percentage = None
67 | self._charging = False
68 | else:
69 | if value[1] is not None:
70 | self._percentage = int(value[1])
71 | if length > 2:
72 | self._charging = bool(value[2])
73 |
74 | def update_from(self, other):
75 | self._voltage = other._voltage
76 | self._percentage = other._percentage
77 | self._charging = other._charging
78 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/mixins.py:
--------------------------------------------------------------------------------
1 | """Mixin classes for other model objects."""
2 |
3 | from datetime import datetime
4 | from typing import Optional, Union
5 |
6 | from flockwave.server.utils import get_current_unix_timestamp_msec, is_timezone_aware
7 |
8 | __all__ = ("TimestampMixin",)
9 |
10 |
11 | #: Type specification for timestamps that we accept in a TimestampMixin
12 | TimestampLike = Union[datetime, int]
13 |
14 |
15 | def _timestamplike_to_timestamp(timestamp: Optional[TimestampLike]) -> int:
16 | if timestamp is None:
17 | return get_current_unix_timestamp_msec()
18 | elif isinstance(timestamp, datetime):
19 | assert is_timezone_aware(timestamp), "Timestamp must be timezone-aware"
20 | return int(round(timestamp.timestamp() * 1000))
21 | else:
22 | return int(timestamp)
23 |
24 |
25 | class TimestampMixin:
26 | """Mixin for classes that support a timestamp property."""
27 |
28 | timestamp: int
29 | """The timestamp, expressed in milliseconds elapsed since the UNIX epoch."""
30 |
31 | def __init__(self, timestamp: Optional[TimestampLike] = None):
32 | """Mixin constructor. Must be called from the constructor of the
33 | class where this mixin is mixed in.
34 |
35 | Parameters:
36 | timestamp: the initial timestamp. ``None`` means to use the current
37 | date and time. Integers mean the number of milliseconds elapsed
38 | since the UNIX epoch, in UTC.
39 | """
40 | self.update_timestamp(timestamp)
41 |
42 | @property
43 | def age_msec(self) -> int:
44 | """Returns the number of milliseconds elapsed since the last update of
45 | the timestamp.
46 | """
47 | return get_current_unix_timestamp_msec() - self.timestamp
48 |
49 | def get_age_msec_at(self, now: TimestampLike) -> int:
50 | """Returns the number of milliseconds elapsed since the last update of
51 | the timestamp, assuming that the current time is given in `now`.
52 |
53 | Args:
54 | now: the current timestamp
55 | """
56 | return _timestamplike_to_timestamp(now) - self.timestamp
57 |
58 | def update_timestamp(self, timestamp: Optional[TimestampLike] = None) -> None:
59 | """Updates the timestamp of the object.
60 |
61 | Parameters:
62 | timestamp: the new timestamp; ``None`` means to use the current date
63 | and time.
64 | """
65 | self.timestamp = _timestamplike_to_timestamp(timestamp)
66 |
--------------------------------------------------------------------------------
/src/flockwave/gateway/asgi_app.py:
--------------------------------------------------------------------------------
1 | """ASGI web application for the gateway server."""
2 |
3 | from argparse import Namespace
4 | from functools import partial, wraps
5 |
6 | from quart import abort, redirect, request
7 | from quart_trio import QuartTrio
8 |
9 | from .errors import NoIdleWorkerError
10 | from .logger import log as base_log
11 |
12 | PACKAGE_NAME = __name__.rpartition(".")[0]
13 |
14 | log = base_log.getChild("asgi_app")
15 |
16 | app = QuartTrio(PACKAGE_NAME)
17 | api = Namespace()
18 |
19 |
20 | def update_api(app):
21 | api.get_root_redirect_url = partial(app.config.get, "ROOT_REDIRECTS_TO")
22 | api.get_public_url_of_worker = app.get_public_url_of_worker
23 | api.request_worker = app.worker_manager.request_worker
24 | api.validate_jwt_token = app.validate_jwt_token
25 |
26 |
27 | def use_fake_token(func):
28 | @wraps(func)
29 | async def handler(*args, **kwds):
30 | token = {"sub": "foo", "name": "bar"}
31 | return await func(token, *args, **kwds)
32 |
33 | return handler
34 |
35 |
36 | def use_jwt_token(func):
37 | @wraps(func)
38 | async def handler(*args, **kwds):
39 | authorization = request.headers.get("Authorization")
40 | if not authorization or not authorization.startswith("Bearer "):
41 | return "", 401, {"WWW-Authenticate": "Bearer"}
42 |
43 | try:
44 | token = api.validate_jwt_token(authorization[7:])
45 | except Exception:
46 | return "Invalid bearer token", 403, {"WWW-Authenticate": "Bearer"}
47 |
48 | return await func(token, *args, **kwds)
49 |
50 | return handler
51 |
52 |
53 | @app.route("/")
54 | async def index():
55 | url = api.get_root_redirect_url()
56 | if url:
57 | return redirect(url)
58 | else:
59 | abort(404)
60 |
61 |
62 | @app.route("/api/operations/start-worker", methods=["POST"])
63 | @use_jwt_token
64 | async def start_worker(token):
65 | user_id = token.get("sub")
66 | username = token.get("name")
67 |
68 | if not user_id or not username:
69 | return "Required information missing from token", 400
70 |
71 | try:
72 | index = await api.request_worker(id=user_id, name=username)
73 | return {"result": {"url": api.get_public_url_of_worker(index)}}
74 | except NoIdleWorkerError:
75 | log.warning(f"No idle workers available for user {username} (id={user_id})")
76 | return "No idle workers available", 503, {"Retry-After": "60"}
77 | except Exception as ex:
78 | log.exception(ex)
79 | return {"error": str(ex) or str(type(ex).__name__)}
80 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/crazyflie/math.py:
--------------------------------------------------------------------------------
1 | """Math related functions."""
2 |
3 | from functools import lru_cache
4 | from typing import Sequence
5 |
6 | __all__ = (
7 | "get_poly_degree",
8 | "to_bernstein_form",
9 | )
10 |
11 |
12 | @lru_cache(maxsize=64)
13 | def pascal_triangle_row(index: int) -> tuple[int, ...]:
14 | """Returns the given row of the Pascal triangle.
15 |
16 | This function is memoized.
17 |
18 | Parameters:
19 | index: the row index; the triangle starts from row 0.
20 |
21 | Returns:
22 | the given row of the Pascal triangle
23 | """
24 | assert index >= 0 and index < 64
25 | if index == 0:
26 | return (1,)
27 | else:
28 | previous_row = pascal_triangle_row(index - 1)
29 | prev = 0
30 | current_row = []
31 | for curr in previous_row:
32 | current_row.append(prev + curr)
33 | prev = curr
34 | current_row.append(prev)
35 | return tuple(current_row)
36 |
37 |
38 | def get_poly_degree(poly: Sequence[float], eps: float = 0.0) -> int:
39 | """Returns the degree of the given polynomial.
40 |
41 | Parameters:
42 | poly: the coefficients of the polynomial terms, starting from the
43 | zero-degree term
44 | eps: tolerance threshold to determine whether a coefficient is zero
45 |
46 | Returns
47 | the degree of the polynomial
48 | """
49 | degree = len(poly) - 1
50 | while degree > 0:
51 | if abs(poly[degree]) > eps:
52 | return degree
53 | degree -= 1
54 | return 0
55 |
56 |
57 | def to_bernstein_form(poly: Sequence[float], eps: float = 0.0) -> list[float]:
58 | """Converts a polynomial given with its coefficients to the corresponding
59 | coefficients in its Bernstein form.
60 |
61 | Zero coefficients will be eliminated from the end of the input. The given
62 | epsilon parameter determines what constitutes as a zero coefficient.
63 |
64 | Parameters:
65 | poly: the coefficients of the polynomial terms, starting from the
66 | zero-degree term
67 | eps: tolerance threshold to determine whether a coefficient is zero
68 |
69 | Returns:
70 | the Bernstein coefficients of the polynomial
71 | """
72 | degree = get_poly_degree(poly, eps)
73 | divisors = pascal_triangle_row(degree)
74 | coeffs = [coeff / divisor for coeff, divisor in zip(poly, divisors)]
75 |
76 | result = []
77 | while coeffs:
78 | result.append(coeffs[0])
79 | for index in range(len(coeffs) - 1):
80 | coeffs[index] += coeffs[index + 1]
81 | coeffs.pop()
82 |
83 | return result[: (get_poly_degree(result, eps) + 1)]
84 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/trio_queue.py:
--------------------------------------------------------------------------------
1 | from math import inf
2 | from trio import Cancelled, Event, open_memory_channel, WouldBlock
3 | from typing import Generic, Optional, TypeVar
4 |
5 | __all__ = ("TrioQueue",)
6 |
7 | T = TypeVar("T")
8 |
9 |
10 | class TrioQueue(Generic[T]):
11 | """Trio-based queue that provides an interface that is compatible with
12 | standard Python queues.
13 | """
14 |
15 | _join_event: Optional[Event]
16 | _maxsize: float
17 | _tasks_pending: int
18 |
19 | def __init__(self, maxsize: int = 0):
20 | """Constructor."""
21 | self._maxsize = maxsize if maxsize > 0 else inf
22 | self._sender, self._receiver = open_memory_channel(self._maxsize)
23 | self._join_event = None
24 | self._tasks_pending = 0
25 |
26 | def empty(self) -> bool:
27 | return self.qsize() == 0
28 |
29 | def full(self) -> bool:
30 | return self.qsize() >= self._maxsize
31 |
32 | async def join(self) -> None:
33 | if self._tasks_pending == 0 and not self._join_event:
34 | return
35 | else:
36 | self._ensure_join_event()
37 | await self._join_event.wait() # type: ignore
38 |
39 | @property
40 | def maxsize(self) -> float:
41 | return self._maxsize
42 |
43 | async def get(self) -> T:
44 | return await self._receiver.receive()
45 |
46 | def get_nowait(self):
47 | return self._receiver.receive_nowait()
48 |
49 | async def put(self, value: T) -> None:
50 | self._tasks_pending += 1
51 | try:
52 | await self._sender.send(value)
53 | except Cancelled:
54 | self._tasks_pending -= 1
55 | raise
56 |
57 | def put_nowait(self, value: T) -> None:
58 | self._tasks_pending += 1
59 | try:
60 | self._sender.send_nowait(value)
61 | except WouldBlock:
62 | self._tasks_pending -= 1
63 | raise
64 |
65 | def qsize(self) -> int:
66 | return self._sender.statistics().current_buffer_used
67 |
68 | def task_done(self) -> None:
69 | self._tasks_pending -= 1
70 | if self._tasks_pending < 0:
71 | raise RuntimeError("task_done() called too many times")
72 | self._trigger_join_event_if_needed()
73 |
74 | def _ensure_join_event(self) -> None:
75 | if self._join_event is None:
76 | self._join_event = Event()
77 | self._trigger_join_event_if_needed()
78 |
79 | def _trigger_join_event_if_needed(self) -> None:
80 | if self._join_event and self._tasks_pending == 0:
81 | self._join_event.set()
82 | self._join_event = None
83 |
--------------------------------------------------------------------------------
/test/test_mission.py:
--------------------------------------------------------------------------------
1 | from pytest import fixture, raises
2 |
3 | from flockwave.server.model.mission import (
4 | Altitude,
5 | AltitudeReference,
6 | GoToMissionCommand,
7 | HoverMissionCommand,
8 | LandMissionCommand,
9 | MissionCommand,
10 | MissionCommandBundle,
11 | ReturnToHomeMissionCommand,
12 | TakeoffMissionCommand,
13 | )
14 |
15 |
16 | @fixture
17 | def command() -> MissionCommand:
18 | return GoToMissionCommand(
19 | id=None,
20 | participants=None,
21 | latitude=47,
22 | longitude=19,
23 | altitude=Altitude(value=100, reference=AltitudeReference.HOME),
24 | )
25 |
26 |
27 | @fixture
28 | def bundle() -> MissionCommandBundle:
29 | return MissionCommandBundle(
30 | commands=[
31 | TakeoffMissionCommand(
32 | id=None,
33 | participants=None,
34 | altitude=Altitude(value=10, reference=AltitudeReference.HOME),
35 | ),
36 | GoToMissionCommand(
37 | id=None,
38 | participants=[0],
39 | latitude=47.1,
40 | longitude=19.1,
41 | altitude=Altitude(value=100, reference=AltitudeReference.HOME),
42 | ),
43 | HoverMissionCommand(
44 | id=None,
45 | participants=[0, 1],
46 | duration=5,
47 | ),
48 | GoToMissionCommand(
49 | id=None,
50 | participants=[1],
51 | latitude=47.2,
52 | longitude=19.2,
53 | altitude=Altitude(value=100, reference=AltitudeReference.HOME),
54 | ),
55 | ReturnToHomeMissionCommand(id=None, participants=None),
56 | LandMissionCommand(id=None, participants=None),
57 | ],
58 | start_positions=[
59 | (470000000, 190000000),
60 | (470010000, 190000000),
61 | (470020000, 190000000),
62 | ],
63 | )
64 |
65 |
66 | def test_mission_command(command):
67 | assert command == GoToMissionCommand.from_json(command.json)
68 |
69 |
70 | def test_participants(command):
71 | command.participants = [0, 1, 2]
72 | assert command == GoToMissionCommand.from_json(command.json)
73 |
74 |
75 | def test_invalid_participants(command):
76 | command.participants = [0, 1, -1]
77 | with raises(
78 | RuntimeError, match="mission item participant IDs must be nonnegative integers"
79 | ):
80 | command = GoToMissionCommand.from_json(command.json)
81 |
82 |
83 | def test_mission_command_bundle(bundle):
84 | assert bundle.participants == [0, 1]
85 | assert bundle == MissionCommandBundle.from_json(bundle.json)
86 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/rtk/enums.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from enum import Enum
4 | from typing import TYPE_CHECKING
5 |
6 | from flockwave.gps.rtcm.packets import RTCMV3Packet
7 |
8 | if TYPE_CHECKING:
9 | from flockwave.server.ext.rtk.types import GPSPacket
10 |
11 | __all__ = ("RTKConfigurationPresetType",)
12 |
13 |
14 | # fmt: off
15 | _BASIC_RTCMV3_PACKETS: frozenset[int] = frozenset(
16 | [
17 | # Legacy GPS RTK messages
18 | 1001, 1002, 1003, 1004,
19 | # Antenna position (optionally with height)
20 | 1005, 1006,
21 | # Legacy GLONASS RTK messages
22 | 1009, 1010, 1011, 1012,
23 | # MSM messages for various satellite constellations
24 | *range(1071, 1140),
25 | # GLONASS code-phase biases
26 | 1230,
27 | ]
28 | )
29 | """This set contains the numeric RTCMv3 identifiers of the minimum set of
30 | messages that need to be forwarded to a UAV to allow it to use RTK corrections.
31 | """
32 | # fmt: on
33 |
34 |
35 | def _is_basic_rtcm_packet(packet: GPSPacket) -> bool:
36 | return (
37 | isinstance(packet, RTCMV3Packet) and packet.packet_type in _BASIC_RTCMV3_PACKETS
38 | )
39 |
40 |
41 | class MessageSet(Enum):
42 | """Types of supported RTCM message sets."""
43 |
44 | BASIC = "basic"
45 | """Basic RTCM message set that contains only those messages that are needed
46 | by GNSS receivers to apply RTK corrections.
47 | """
48 |
49 | FULL = "full"
50 | """Full RTCM message set that accepts all messages."""
51 |
52 | def accepts(self, packet: GPSPacket) -> bool:
53 | """Returns whether the message set should accept the given GPS packet.
54 |
55 | A GPS packet will be accepted if it is an RTCM packet and its message
56 | type matches the one included in the message set.
57 | """
58 | if self is MessageSet.BASIC:
59 | return _is_basic_rtcm_packet(packet)
60 | else:
61 | return True
62 |
63 | def contains(self, message_type: int) -> bool:
64 | """Returns whether the message set contains the given RTCM message type."""
65 | return True
66 |
67 |
68 | class RTKConfigurationPresetType(Enum):
69 | """Type of RTK configuration presets."""
70 |
71 | BUILTIN = "builtin"
72 | """BUilt-in configuration presets specified in the main configuration file."""
73 |
74 | DYNAMIC = "dynamic"
75 | """Dynamic configuration presets created by the extension automatically
76 | based on hardware detection. Presets created for serial ports are of this
77 | type.
78 | """
79 |
80 | USER = "user"
81 | """User-specified presets in a separate configuration file of the extension.
82 | These presets can be created, modified or removed by the user.
83 | """
84 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/ssdp/registry.py:
--------------------------------------------------------------------------------
1 | """A registry that contains information about UPnP services that the server
2 | provides, their IDs and URLs.
3 | """
4 |
5 | __all__ = ("UPnPServiceRegistry",)
6 |
7 | from contextlib import contextmanager
8 | from typing import Callable
9 |
10 | from flockwave.connections import IPAddressAndPort
11 | from flockwave.server.registries.base import RegistryBase
12 |
13 |
14 | URIOrCallableReturningURI = str | Callable[[IPAddressAndPort], str | None]
15 |
16 |
17 | class UPnPServiceRegistry(RegistryBase[URIOrCallableReturningURI]):
18 | """Registry that contains information about the UPnP services that the
19 | server provides.
20 | """
21 |
22 | def add(self, service_id: str, uri: URIOrCallableReturningURI) -> None:
23 | """Registers a UPnP service with the given URL in the registry.
24 |
25 | Parameters:
26 | service_id: ID of the service to register
27 | uri: the URI of the service, or a callable that returns the URI
28 | of the service when called with an IP address as the first
29 | argument. In such cases, the callable should attempt to return
30 | a service that is in the same subnet as the given IP address.
31 | The callable may also return `None` to indicate that the service
32 | is temporarily unavailable.
33 |
34 | Throws:
35 | KeyError: if the service ID of the clock is already taken by a
36 | different service
37 | """
38 | if service_id in self._entries:
39 | raise KeyError(f"Service ID already taken: {service_id}")
40 |
41 | self._entries[service_id] = uri
42 |
43 | def remove(self, service_id: str) -> URIOrCallableReturningURI | None:
44 | """Removes the service with the given ID from the registry.
45 |
46 | This function is a no-op if the service is not registered.
47 |
48 | Parameters:
49 | service_id: ID of the service to deregister
50 |
51 | Returns:
52 | the URL of the service that was deregistered, or ``None`` if the
53 | service was not registered
54 | """
55 | return self._entries.pop(service_id, None)
56 |
57 | @contextmanager
58 | def use(self, service_id: str, uri: URIOrCallableReturningURI):
59 | """Temporarily adds a new service URL with a given service ID, hands
60 | control back to the caller in a context, and then removes the service
61 | when the caller exits the context.
62 |
63 | Parameters:
64 | service_id: ID of the service to register
65 | uri: the URI of the service; see `add()` for more information
66 | """
67 | self.add(service_id, uri)
68 | try:
69 | yield
70 | finally:
71 | self.remove(service_id)
72 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/flight_area.py:
--------------------------------------------------------------------------------
1 | """Flight area related data structures and functions for the server."""
2 |
3 | from dataclasses import dataclass, field
4 | from typing import Any, Optional
5 |
6 | from flockwave.gps.vectors import GPSCoordinate
7 |
8 | __all__ = (
9 | "FlightAreaConfigurationRequest",
10 | "FlightAreaPoint",
11 | "FlightAreaPolygon",
12 | )
13 |
14 |
15 | #: Type specification for points in the flight area
16 | FlightAreaPoint = GPSCoordinate
17 |
18 |
19 | @dataclass
20 | class FlightAreaPolygon:
21 | """Flight area inclusion or exclusion in the form of a polygon."""
22 |
23 | points: list[FlightAreaPoint] = field(default_factory=list)
24 | is_inclusion: bool = True
25 |
26 | @property
27 | def json(self) -> dict[str, Any]:
28 | """Returns the JSON representation of a flight area polygon
29 | in absolute (geodetic) coordinates."""
30 | return {
31 | "isInclusion": self.is_inclusion,
32 | "points": [point.json for point in self.points],
33 | }
34 |
35 |
36 | @dataclass
37 | class FlightAreaConfigurationRequest:
38 | """Object representing a flight area configuration object that can be enforced
39 | on a drone.
40 |
41 | This is admittedly minimal for the time being. We can update it as we
42 | implement support for more complex flight areas. Things that are missing:
43 |
44 | - circular flight areas
45 |
46 | - selectively turning on/off certain flight area types
47 | """
48 |
49 | min_altitude: Optional[float] = None
50 | """Minimum altitude that the drone must maintain; `None` means not to
51 | change the minimum altitude requirement.
52 | """
53 |
54 | max_altitude: Optional[float] = None
55 | """Maximum altitude that the drone is allowed to fly to; `None` means not
56 | to change the maximum altitude limit.
57 | """
58 |
59 | polygons: Optional[list[FlightAreaPolygon]] = None
60 | """Inclusion and exclusion polygons in the flight area; `None` means not to
61 | update the polygons.
62 | """
63 |
64 | @property
65 | def json(self) -> dict[str, Any]:
66 | """Returns a JSON representation of the flight area configuration in
67 | absolute (geodetic) coordinates."""
68 | return {
69 | "version": 1,
70 | "maxAltitude": (
71 | None
72 | if self.max_altitude is None
73 | else round(self.max_altitude, ndigits=3)
74 | ),
75 | "minAltitude": (
76 | None
77 | if self.min_altitude is None
78 | else round(self.min_altitude, ndigits=3)
79 | ),
80 | "polygons": (
81 | None
82 | if self.polygons is None
83 | else [polygon.json for polygon in self.polygons]
84 | ),
85 | }
86 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/rtk/registry.py:
--------------------------------------------------------------------------------
1 | """A registry that contains information about all the clocks and timers that
2 | the server knows.
3 | """
4 |
5 | __all__ = ("RTKPresetRegistry",)
6 |
7 | from contextlib import contextmanager
8 | from typing import Iterator, Optional
9 |
10 | from flockwave.server.registries.base import RegistryBase
11 |
12 | from .preset import RTKConfigurationPreset
13 |
14 |
15 | class RTKPresetRegistry(RegistryBase[RTKConfigurationPreset]):
16 | """Registry that contains information about the RTK presets registered in
17 | the server.
18 | """
19 |
20 | def add(self, preset: RTKConfigurationPreset) -> None:
21 | """Registers an RTK preset in the registry.
22 |
23 | This function is a no-op if the preset is already registered.
24 |
25 | Parameters:
26 | clock: the clock to register
27 |
28 | Throws:
29 | KeyError: if the ID of the preset is already taken by a different preset
30 | """
31 | old_preset = self._entries.get(preset.id, None)
32 | if old_preset is not None and old_preset != preset:
33 | raise KeyError(f"Preset ID already taken: {preset.id}")
34 | self._entries[preset.id] = preset
35 |
36 | def remove(
37 | self, preset: RTKConfigurationPreset
38 | ) -> Optional[RTKConfigurationPreset]:
39 | """Removes the given preset from the registry.
40 |
41 | This function is a no-op if the preset is not registered.
42 |
43 | Parameters:
44 | preset: the preset to deregister
45 |
46 | Returns:
47 | the preset that was deregistered, or ``None`` if the preset was not
48 | registered
49 | """
50 | return self.remove_by_id(preset.id)
51 |
52 | def remove_by_id(self, preset_id: str) -> Optional[RTKConfigurationPreset]:
53 | """Removes the preset with the given ID from the registry.
54 |
55 | This function is a no-op if no preset is registered with the given ID.
56 |
57 | Parameters:
58 | preset_id: the ID of the preset to deregister
59 |
60 | Returns:
61 | the preset that was deregistered, or ``None`` if the preset was not
62 | registered
63 | """
64 | return self._entries.pop(preset_id, None)
65 |
66 | @contextmanager
67 | def use(self, preset: RTKConfigurationPreset) -> Iterator[RTKConfigurationPreset]:
68 | """Temporarily adds a new preset, hands control back to the caller in a
69 | context, and then removes the preset when the caller exits the context.
70 |
71 | Parameters:
72 | preset: the preset to add
73 |
74 | Yields:
75 | RTKConfigurationPreset: the preset object that was added
76 | """
77 | self.add(preset)
78 | try:
79 | yield preset
80 | finally:
81 | self.remove(preset)
82 |
--------------------------------------------------------------------------------
/src/flockwave/server/command_handlers/test.py:
--------------------------------------------------------------------------------
1 | """Factory function to create handlers for the "test" command in UAV drivers."""
2 |
3 | from inspect import isasyncgenfunction, iscoroutinefunction
4 | from typing import Callable, Iterable, Optional
5 |
6 | from flockwave.server.errors import NotSupportedError
7 | from flockwave.server.model.commands import ProgressEvents
8 | from flockwave.server.model.uav import UAV, UAVDriver
9 |
10 | __all__ = ("create_test_command_handler",)
11 |
12 |
13 | STANDARD_COMPONENTS = {
14 | "accel": "Accelerometer",
15 | "baro": "Pressure sensor",
16 | "battery": "Battery",
17 | "compass": "Compass",
18 | "gyro": "Gyroscope",
19 | "led": "LED",
20 | "motor": "Motor",
21 | }
22 |
23 |
24 | def create_test_command_handler(
25 | supported_components: Iterable[str],
26 | ) -> Callable[[UAVDriver, UAV, Optional[str]], ProgressEvents[str]]:
27 | """Creates a generic async command handler function that allows the user to
28 | test certain components of the UAV, assuming that the UAV has an async or
29 | sync method named `test_component()` that accepts a single component name
30 | as a string. Async functions returning an iterator that yields Progress_
31 | objects is also accepted.
32 |
33 | Assign the function returned from this factory function to the
34 | `handle_command_test()` method of a UAVDriver_ subclass to make the
35 | driver support component tests, assuming that the corresponding UAV_ object
36 | already supports it.
37 | """
38 | supported = set(supported_components)
39 |
40 | options = "|".join(sorted(supported))
41 | help_text = f"Usage: test <{options}>"
42 |
43 | async def _test_command_handler(
44 | driver: UAVDriver,
45 | uav: UAV,
46 | component: Optional[str] = None,
47 | ) -> ProgressEvents[str]:
48 | if component is None:
49 | yield help_text
50 | return
51 |
52 | if component not in supported:
53 | raise NotSupportedError
54 |
55 | test_component = uav.test_component
56 | if test_component is None:
57 | raise RuntimeError("Component tests not supported")
58 |
59 | if isasyncgenfunction(test_component):
60 | async for event in test_component(component):
61 | yield event
62 | else:
63 | if iscoroutinefunction(test_component):
64 | result = await test_component(component)
65 | else:
66 | result = test_component(component)
67 |
68 | if not isinstance(result, str):
69 | component_name = f"Component {component!r}"
70 | result = (
71 | str(STANDARD_COMPONENTS.get(component or "", component_name))
72 | + " test executed"
73 | )
74 |
75 | yield result
76 |
77 | return _test_command_handler
78 |
--------------------------------------------------------------------------------
/src/flockwave/server/command_handlers/calibration.py:
--------------------------------------------------------------------------------
1 | """Factory function to create handlers for the "calib" command in UAV drivers."""
2 |
3 | from inspect import isasyncgenfunction, iscoroutinefunction
4 | from typing import Callable, Iterable, Optional
5 |
6 | from flockwave.server.errors import NotSupportedError
7 | from flockwave.server.model.commands import ProgressEvents
8 | from flockwave.server.model.uav import UAV, UAVDriver
9 |
10 | from .test import STANDARD_COMPONENTS as STANDARD_TEST_COMPONENTS
11 |
12 | __all__ = ("create_calibration_command_handler",)
13 |
14 |
15 | STANDARD_COMPONENTS = dict(STANDARD_TEST_COMPONENTS, level="Level")
16 |
17 | SUCCESS_MESSAGES = {"level": "Level calibration executed"}
18 |
19 |
20 | def create_calibration_command_handler(
21 | supported_components: Iterable[str],
22 | ) -> Callable[[UAVDriver, UAV, Optional[str]], ProgressEvents[str]]:
23 | """Creates a generic async command handler function that allows the user to
24 | calibrate certain components of the UAV, assuming that the UAV has an async or
25 | sync method named `calibrate_component()` that accepts a single component name
26 | as a string.
27 |
28 | Assign the function returned from this factory function to the
29 | `handle_command_calib()` method of a UAVDriver_ subclass to make the
30 | driver support component tests, assuming that the corresponding UAV_ object
31 | already supports it.
32 | """
33 | supported = set(supported_components)
34 |
35 | options = "|".join(sorted(supported))
36 | help_text = f"Usage: calib <{options}>"
37 |
38 | async def _calibration_command_handler(
39 | driver: UAVDriver,
40 | uav: UAV,
41 | component: Optional[str] = None,
42 | ) -> ProgressEvents[str]:
43 | if component is None:
44 | yield help_text
45 | return
46 |
47 | if component not in supported:
48 | raise NotSupportedError
49 |
50 | calibrate_component = uav.calibrate_component
51 | if calibrate_component is None:
52 | raise RuntimeError("Component calibration not supported")
53 |
54 | if isasyncgenfunction(calibrate_component):
55 | async for event in calibrate_component(component):
56 | yield event
57 | else:
58 | if iscoroutinefunction(calibrate_component):
59 | result = await calibrate_component(component)
60 | else:
61 | result = calibrate_component(component)
62 |
63 | if not isinstance(result, str):
64 | component_name = f"Component {component!r}"
65 | result = SUCCESS_MESSAGES.get(component or "") or (
66 | str(STANDARD_COMPONENTS.get(component or "", component_name))
67 | + " calibrated"
68 | )
69 |
70 | yield result
71 |
72 | return _calibration_command_handler
73 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/autopilots/unknown.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 | from typing import TYPE_CHECKING
3 |
4 | from flockwave.server.errors import NotSupportedError
5 | from flockwave.server.model.geofence import GeofenceConfigurationRequest, GeofenceStatus
6 | from flockwave.server.model.safety import SafetyConfigurationRequest
7 |
8 | from ..types import MAVLinkFlightModeNumbers, MAVLinkMessage
9 |
10 | from .base import Autopilot
11 |
12 | if TYPE_CHECKING:
13 | from ..driver import MAVLinkUAV
14 |
15 | __all__ = ("UnknownAutopilot",)
16 |
17 |
18 | class UnknownAutopilot(Autopilot):
19 | """Class representing an autopilot that we do not know."""
20 |
21 | name = "Unknown autopilot"
22 |
23 | def calibrate_accelerometer(self, uav: MAVLinkUAV):
24 | raise NotSupportedError
25 |
26 | def calibrate_compass(self, uav: MAVLinkUAV):
27 | raise NotSupportedError
28 |
29 | def can_handle_firmware_update_target(self, target_id: str) -> bool:
30 | return False
31 |
32 | async def configure_geofence(
33 | self, uav: MAVLinkUAV, configuration: GeofenceConfigurationRequest
34 | ) -> None:
35 | raise NotSupportedError
36 |
37 | async def configure_safety(
38 | self, uav: MAVLinkUAV, configuration: SafetyConfigurationRequest
39 | ) -> None:
40 | raise NotSupportedError
41 |
42 | def are_motor_outputs_disabled(
43 | self, heartbeat: MAVLinkMessage, sys_status: MAVLinkMessage
44 | ) -> bool:
45 | return False
46 |
47 | def get_flight_mode_numbers(self, mode: str) -> MAVLinkFlightModeNumbers:
48 | raise NotSupportedError
49 |
50 | async def get_geofence_status(self, uav: MAVLinkUAV) -> GeofenceStatus:
51 | raise NotSupportedError
52 |
53 | def handle_firmware_update(self, uav: MAVLinkUAV, target_id: str, blob: bytes):
54 | raise NotSupportedError
55 |
56 | @property
57 | def is_battery_percentage_reliable(self) -> bool:
58 | # Let's be optimistic :)
59 | return True
60 |
61 | def is_duplicate_message(self, message: MAVLinkMessage) -> bool:
62 | return False
63 |
64 | def is_prearm_error_message(self, text: str) -> bool:
65 | return False
66 |
67 | def is_prearm_check_in_progress(
68 | self, heartbeat: MAVLinkMessage, sys_status: MAVLinkMessage
69 | ) -> bool:
70 | return False
71 |
72 | def is_rth_flight_mode(self, base_mode: int, custom_mode: int) -> bool:
73 | return False
74 |
75 | @property
76 | def supports_local_frame(self) -> bool:
77 | # Let's be pessimistic :(
78 | return False
79 |
80 | @property
81 | def supports_mavftp_parameter_upload(self) -> bool:
82 | return False
83 |
84 | @property
85 | def supports_repositioning(self) -> bool:
86 | return False
87 |
88 | @property
89 | def supports_scheduled_takeoff(self):
90 | return False
91 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/gps.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from dataclasses import dataclass
4 | from enum import IntEnum
5 | from typing import Optional, Union
6 |
7 |
8 | __all__ = ("GPSFix", "GPSFixType", "ScaledLatLonPair")
9 |
10 |
11 | ScaledLatLonPair = tuple[int, int]
12 | """Type specification for a latitude and longitude pair stored in [1e-7 deg]"""
13 |
14 |
15 | class GPSFixType(IntEnum):
16 | """Known GPS fix types."""
17 |
18 | NO_GPS = 0
19 | NO_FIX = 1
20 | FIX_2D = 2
21 | FIX_3D = 3
22 | DGPS = 4
23 | RTK_FLOAT = 5
24 | RTK_FIXED = 6
25 | STATIC = 7
26 |
27 |
28 | #: Type alias for objects that can be used to update a GPSFix object
29 | GPSFixLike = Union[int, GPSFixType, "GPSFix"]
30 |
31 |
32 | @dataclass
33 | class GPSFix:
34 | """Class representing basic GPS fix information of a single UAV."""
35 |
36 | type: GPSFixType = GPSFixType.NO_GPS
37 | """GPS fix type."""
38 |
39 | num_satellites: Optional[int] = None
40 | """Number of satellites."""
41 |
42 | horizontal_accuracy: Optional[float] = None
43 | """Horizontal accuracy in meters."""
44 |
45 | vertical_accuracy: Optional[float] = None
46 | """Vertical accuracy in meters."""
47 |
48 | @property
49 | def json(self):
50 | retval = [int(self.type)]
51 | optionals = [
52 | int(self.num_satellites) if self.num_satellites is not None else None,
53 | (
54 | int(round(self.horizontal_accuracy * 1000))
55 | if self.horizontal_accuracy is not None
56 | else None
57 | ),
58 | (
59 | int(round(self.vertical_accuracy * 1000))
60 | if self.vertical_accuracy is not None
61 | else None
62 | ),
63 | ]
64 | while optionals and optionals[-1] is None:
65 | del optionals[-1]
66 |
67 | return retval + optionals
68 |
69 | def update_from(self, other: GPSFixLike) -> None:
70 | """Updates this GPS fix object from another one. You may also specify a
71 | single GPSFixType_ as the input; in this case, the fix type will be
72 | updated and the number of satellites will be cleared.
73 | """
74 | if isinstance(other, int):
75 | self.type = GPSFixType(other)
76 | self.num_satellites = None
77 | self.horizontal_accuracy = None
78 | self.vertical_accuracy = None
79 | elif isinstance(other, GPSFixType):
80 | self.type = other
81 | self.num_satellites = None
82 | self.horizontal_accuracy = None
83 | self.vertical_accuracy = None
84 | else:
85 | self.type = other.type
86 | self.num_satellites = other.num_satellites
87 | self.horizontal_accuracy = other.horizontal_accuracy
88 | self.vertical_accuracy = other.vertical_accuracy
89 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/weather.py:
--------------------------------------------------------------------------------
1 | """Extension that provides weather station related commands to the server, and
2 | allow weather station providers to register themselves.
3 | """
4 |
5 | from inspect import isawaitable
6 | from trio import sleep_forever
7 |
8 | from flockwave.gps.vectors import GPSCoordinate
9 | from flockwave.server.message_hub import MessageHub
10 | from flockwave.server.model.client import Client
11 | from flockwave.server.model.messages import FlockwaveMessage, FlockwaveResponse
12 | from flockwave.server.model.weather import Weather
13 | from flockwave.server.registries import find_in_registry, WeatherProviderRegistry
14 |
15 | #: Registry containing the registered weather providers by ID
16 | registry = WeatherProviderRegistry()
17 |
18 |
19 | async def handle_WTH_AT(message: FlockwaveMessage, sender: Client, hub: MessageHub):
20 | body = {}
21 | position = message.body.get("position")
22 | if not isinstance(position, list) or len(position) < 2:
23 | return hub.acknowledge(
24 | message, outcome=False, reason="Invalid GPS coordinate provided"
25 | )
26 |
27 | if registry.num_entries > 0:
28 | gps_coordinate = GPSCoordinate.from_json(position)
29 | weather = Weather(position=gps_coordinate)
30 | for provider in registry.iter_providers_by_priority():
31 | result = provider(weather, gps_coordinate)
32 | if isawaitable(result):
33 | await result
34 | body["weather"] = weather
35 |
36 | return body
37 |
38 |
39 | async def handle_WTH_INF(
40 | message: FlockwaveMessage, sender: Client, hub: MessageHub
41 | ) -> FlockwaveResponse:
42 | statuses = {}
43 |
44 | body = {"status": statuses, "type": "WTH-INF"}
45 | response = hub.create_response_or_notification(body=body, in_response_to=message)
46 |
47 | for station_id in message.get_ids():
48 | provider = find_in_registry(
49 | registry,
50 | station_id,
51 | response=response,
52 | failure_reason="No such weather provider",
53 | )
54 | if provider:
55 | weather = Weather()
56 | result = provider(weather, None)
57 | if isawaitable(result):
58 | await result
59 | statuses[station_id] = weather
60 |
61 | return response
62 |
63 |
64 | def handle_WTH_LIST(message: FlockwaveMessage, sender: Client, hub: MessageHub):
65 | return {"ids": list(registry.ids)}
66 |
67 |
68 | async def run(app):
69 | """Unloads the extension."""
70 | with app.message_hub.use_message_handlers(
71 | {
72 | "WTH-AT": handle_WTH_AT,
73 | "WTH-INF": handle_WTH_INF,
74 | "WTH-LIST": handle_WTH_LIST,
75 | }
76 | ):
77 | await sleep_forever()
78 |
79 |
80 | description = "Basic support for weather stations"
81 | exports = {"add_provider": registry.add, "use_provider": registry.use}
82 | schema = {}
83 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/error_set.py:
--------------------------------------------------------------------------------
1 | from collections.abc import Set
2 | from typing import Iterable, Iterator, Sequence
3 |
4 | __all__ = ("ErrorSet",)
5 |
6 |
7 | class ErrorSet(Set[int]):
8 | """Data structure to store a list of non-negative numeric error codes
9 | efficiently.
10 | """
11 |
12 | _errors: set[int]
13 |
14 | def __init__(self, errors: Iterable[int] | None = None) -> None:
15 | self._errors = set(errors) if errors is not None else set()
16 |
17 | def __contains__(self, item) -> bool:
18 | return item in self._errors
19 |
20 | def __iter__(self) -> Iterator[int]:
21 | return iter(self._errors)
22 |
23 | def __len__(self) -> int:
24 | return len(self._errors)
25 |
26 | @property
27 | def json(self) -> Sequence[int]:
28 | return sorted(self._errors)
29 |
30 | def clear(self) -> None:
31 | """Clears all error codes."""
32 | self._errors.clear()
33 |
34 | def ensure(self, code: int, present: bool = True) -> None:
35 | """Ensures that the given error code is present (or not present) in the
36 | error code list.
37 |
38 | Parameters:
39 | code: the code to add or remove
40 | present: whether to add the code (True) or remove it (False)
41 | """
42 | # If the error code is to be cleared and we don't have any errors
43 | # (which is the common code path), we can bail out immediately.
44 | if not self._errors and not present:
45 | return
46 |
47 | code = int(code)
48 | if code in self._errors:
49 | if not present:
50 | self._errors.remove(code)
51 | else:
52 | if present:
53 | self._errors.add(code)
54 |
55 | def ensure_many(self, codes: dict[int, bool]) -> None:
56 | """Updates multiple error codes with a single function call.
57 |
58 | Parameters:
59 | codes: dictionary mapping error codes to a boolean specifying
60 | whether the error code should be present or absent
61 | """
62 | # If all error codes are to be cleared and we don't have any errors
63 | # (which is the common code path), we can bail out immediately.
64 | if not self._errors and not any(present for present in codes.values()):
65 | return
66 |
67 | for code, present in codes.items():
68 | code = int(code)
69 | if code in self._errors:
70 | if not present:
71 | self._errors.remove(code)
72 | else:
73 | if present:
74 | self._errors.add(code)
75 |
76 | def set(self, codes: Iterable[int]) -> None:
77 | """Replaces the current error code list with the given list.
78 |
79 | Parameters:
80 | codes: the new list of error codes
81 | """
82 | self._errors.clear()
83 | self._errors.update(codes)
84 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/socketio/vendor/engineio_v4/packet.py:
--------------------------------------------------------------------------------
1 | import base64
2 | from . import json as _json
3 |
4 | (OPEN, CLOSE, PING, PONG, MESSAGE, UPGRADE, NOOP) = (0, 1, 2, 3, 4, 5, 6)
5 | packet_names = ["OPEN", "CLOSE", "PING", "PONG", "MESSAGE", "UPGRADE", "NOOP"]
6 |
7 | binary_types = (bytes, bytearray)
8 |
9 |
10 | class Packet(object):
11 | """Engine.IO packet."""
12 |
13 | json = _json
14 |
15 | def __init__(self, packet_type=NOOP, data=None, encoded_packet=None):
16 | self.packet_type = packet_type
17 | self.data = data
18 | if isinstance(data, str):
19 | self.binary = False
20 | elif isinstance(data, binary_types):
21 | self.binary = True
22 | else:
23 | self.binary = False
24 | if self.binary and self.packet_type != MESSAGE:
25 | raise ValueError("Binary packets can only be of type MESSAGE")
26 | if encoded_packet is not None:
27 | self.decode(encoded_packet)
28 |
29 | def encode(self, b64=False):
30 | """Encode the packet for transmission."""
31 | if self.binary:
32 | if b64:
33 | encoded_packet = "b" + base64.b64encode(self.data).decode("utf-8")
34 | else:
35 | encoded_packet = self.data
36 | else:
37 | encoded_packet = str(self.packet_type)
38 | if isinstance(self.data, str):
39 | encoded_packet += self.data
40 | elif isinstance(self.data, dict) or isinstance(self.data, list):
41 | encoded_packet += self.json.dumps(self.data, separators=(",", ":"))
42 | elif self.data is not None:
43 | encoded_packet += str(self.data)
44 | return encoded_packet
45 |
46 | def decode(self, encoded_packet):
47 | """Decode a transmitted package."""
48 | self.binary = isinstance(encoded_packet, binary_types)
49 | b64 = not self.binary and encoded_packet[0] == "b"
50 | if b64:
51 | self.binary = True
52 | self.packet_type = MESSAGE
53 | self.data = base64.b64decode(encoded_packet[1:])
54 | else:
55 | if self.binary and not isinstance(encoded_packet, bytes):
56 | encoded_packet = bytes(encoded_packet)
57 | if self.binary:
58 | self.packet_type = MESSAGE
59 | self.data = encoded_packet
60 | else:
61 | self.packet_type = int(encoded_packet[0])
62 | try:
63 | self.data = self.json.loads(encoded_packet[1:])
64 | if isinstance(self.data, int):
65 | # do not allow integer payloads, see
66 | # github.com/miguelgrinberg/python-engineio/issues/75
67 | # for background on this decision
68 | raise ValueError
69 | except ValueError:
70 | self.data = encoded_packet[1:]
71 |
--------------------------------------------------------------------------------
/src/flockwave/server/utils/formatting.py:
--------------------------------------------------------------------------------
1 | """Formatting-related utility functions."""
2 |
3 | from datetime import datetime, timedelta
4 | from typing import Callable, Sequence, TypeVar, Union
5 |
6 | from flockwave.gps.formatting import format_gps_coordinate
7 |
8 | __all__ = (
9 | "format_gps_coordinate",
10 | "format_list_nicely",
11 | "format_number_nicely",
12 | "format_uav_ids_nicely",
13 | "format_timedelta_nicely",
14 | "format_timestamp_nicely",
15 | )
16 |
17 | T = TypeVar("T")
18 |
19 |
20 | def format_list_nicely(
21 | items: Sequence[T], *, max_items: int = 5, item_formatter: Callable[[T], str] = str
22 | ) -> str:
23 | if not items:
24 | return ""
25 |
26 | num_items = len(items)
27 | excess_items = max(0, num_items - max_items)
28 | if excess_items:
29 | return (
30 | ", ".join(item_formatter(item) for item in items[:max_items])
31 | + f" and {excess_items} more"
32 | )
33 |
34 | if num_items == 1:
35 | return item_formatter(items[0])
36 | else:
37 | return (
38 | ", ".join(item_formatter(item) for item in items[:-1])
39 | + " and "
40 | + item_formatter(items[-1])
41 | )
42 |
43 |
44 | def format_number_nicely(value: float) -> str:
45 | """Formats a float nicely, stripping trailing zeros and avoiding scientific
46 | notation where possible.
47 | """
48 | return f"{value:.7f}".rstrip("0").rstrip(".")
49 |
50 |
51 | def format_timedelta_nicely(delta: Union[float, timedelta]) -> str:
52 | """Formats a Python timedelta object or a float containing seconds; the
53 | result will be separated into hours, minutes and seconds.
54 | """
55 | dt = delta.total_seconds() if isinstance(delta, timedelta) else delta
56 | sign = "-" if dt < 0 else ""
57 | minutes, seconds = divmod(abs(dt), 60)
58 | minutes = int(minutes)
59 | hours, minutes = divmod(minutes, 60)
60 | seconds = round(seconds, 3)
61 | maybe_zero = "0" if seconds < 10 else ""
62 | if seconds.is_integer():
63 | seconds = int(seconds)
64 | return f"{sign}{hours:02}:{minutes:02}:{maybe_zero}{seconds}"
65 | else:
66 | return f"{sign}{hours:02}:{minutes:02}:{maybe_zero}{seconds:.3}"
67 |
68 |
69 | def format_timestamp_nicely(timestamp: Union[float, datetime]) -> str:
70 | """Formats a UNIX timestamp or a Python datetime object nicely, including
71 | the date part of the timestamp as well.
72 | """
73 | dt = (
74 | timestamp
75 | if isinstance(timestamp, datetime)
76 | else datetime.fromtimestamp(timestamp)
77 | )
78 | return dt.isoformat().replace("T", " ")
79 |
80 |
81 | def format_uav_ids_nicely(ids: Sequence[str], *, max_items: int = 5) -> str:
82 | if not ids:
83 | return "no UAVs"
84 | elif len(ids) == 1:
85 | return "UAV " + format_list_nicely(ids, max_items=max_items)
86 | else:
87 | return "UAVs " + format_list_nicely(ids, max_items=max_items)
88 |
--------------------------------------------------------------------------------
/src/flockwave/server/command_handlers/color.py:
--------------------------------------------------------------------------------
1 | """Factory function to create handlers for the "color" command in UAV drivers."""
2 |
3 | from colour import Color
4 | from inspect import iscoroutinefunction
5 | from typing import Awaitable, Callable, Optional, Union
6 |
7 | from flockwave.server.model.uav import UAV, UAVDriver
8 |
9 | __all__ = ("create_color_command_handler",)
10 |
11 |
12 | def _parse_color(
13 | red: Optional[Union[str, int]] = None,
14 | green: Optional[int] = None,
15 | blue: Optional[int] = None,
16 | ) -> Optional[Color]:
17 | """Parses a color from its red, green and blue components specified as
18 | integers, or from a string representation, which must be submitted in place
19 | of the "red" argument (the first positional argument).
20 |
21 | Returns:
22 | the RGB color as an integer triplet, each component being in the range
23 | [0; 255], or `None` if the red component is "off", which means to turn
24 | off any color overrides
25 | """
26 | if isinstance(red, str):
27 | if red.lower() == "off":
28 | return None
29 | else:
30 | # Try to parse the "red" argument as a number
31 | try:
32 | red = int(red)
33 | except ValueError:
34 | # Parse it as a color name
35 | return Color(red)
36 |
37 | return Color(
38 | red=(int(red) or 0) / 255,
39 | green=(int(green) or 0) / 255,
40 | blue=(int(blue) or 0) / 255,
41 | )
42 |
43 |
44 | async def _color_command_handler(
45 | driver: UAVDriver,
46 | uav: UAV,
47 | red: Optional[Union[str, int]] = None,
48 | green: Optional[int] = None,
49 | blue: Optional[int] = None,
50 | ) -> str:
51 | if red is None and green is None and blue is None:
52 | raise RuntimeError(
53 | "Please provide the red, green and blue components of the color to set"
54 | )
55 |
56 | try:
57 | color = _parse_color(red, green, blue)
58 | except ValueError as ex:
59 | raise RuntimeError(ex) from ex
60 |
61 | if iscoroutinefunction(uav.set_led_color):
62 | await uav.set_led_color(color)
63 | else:
64 | uav.set_led_color(color)
65 |
66 | if color is not None:
67 | return f"Color set to {color.hex_l}"
68 | else:
69 | return "Color override turned off"
70 |
71 |
72 | def create_color_command_handler() -> Callable[
73 | [UAVDriver, UAV, Optional[Union[str, int]], Optional[int], Optional[int]],
74 | Awaitable[str],
75 | ]:
76 | """Creates a generic async command handler function that allows the user to
77 | set the color of the LED lights on the UAV, assuming that the UAV
78 | has an async or sync method named `set_led_color()`.
79 |
80 | Assign the function returned from this factory function to the
81 | `handle_command_color()` method of a UAVDriver_ subclass to make the
82 | driver support color updates, assuming that the corresponding UAV_ object
83 | already supports it.
84 | """
85 | return _color_command_handler
86 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Skybrush Server
2 |
3 | Skybrush Server is the server component behind the Skybrush ecosystem; it handles
4 | communication channels to drones and provides an abstraction layer on top of them
5 | so frontend apps (like Skybrush Live) do not need to know what type of drones
6 | they are communicating with.
7 |
8 | The server also provides additional facilities like clocks, RTK correction
9 | sources, weather providers and so on. It is extensible via extension modules
10 | that can be loaded automatically at startup or dynamically while the server is
11 | running. In fact, most of the functionality in the server is implemented in the
12 | form of extensions; see the `flockwave.server.ext` module in the source code
13 | for the list of built-in extensions. You may also develop your own extensions to
14 | extend the functionality of the server.
15 |
16 | ## Installation
17 |
18 | 1. Install `uv`. `uv` will manage a virtual environment for this project to keep
19 | things nicely separated. You won't pollute the system Python with the
20 | dependencies of the Skybrush server and everyone will be happier.
21 | See for installation instructions.
22 |
23 | 2. Check out the source code of the server.
24 |
25 | 3. Run `uv sync` to install all the dependencies and the server itself in a
26 | separate virtualenv. The virtualenv will be created in a folder named
27 | `.venv` in the project folder.
28 |
29 | 4. Run `uv run skybrushd` to start the server.
30 |
31 | ## Documentation
32 |
33 | - [User guide](https://doc.collmot.com/public/skybrush-live-doc/latest/)
34 |
35 | ## Development
36 |
37 | This project contains both public and private dependencies in `pyproject.toml`.
38 | Public dependencies are either on PyPI or in our public PyPI index at
39 | [Gemfury](https://gemfury.com). Private dependencies hosted in our private
40 | PyPI index are _not_ required to build the community version of Skybrush Server.
41 |
42 | However, if you are working with the project on your own and make any changes to
43 | `pyproject.toml` that would necessitate the regeneration of the lockfile of
44 | `uv` (i.e. `uv.lock`), `uv` itself may attempt to connect to our private package
45 | index as it needs information about _all_ dependencies to generate a consistent
46 | lockfile. In this case, you should remove all the dependencies from
47 | `pyproject.toml` that are pinned to the `collmot` package index -- these are
48 | the ones in the `pro` or `collmot` extras.
49 |
50 | ## License
51 |
52 | Skybrush Server is free software: you can redistribute it and/or modify it under
53 | the terms of the GNU General Public License as published by the Free Software
54 | Foundation, either version 3 of the License, or (at your option) any later
55 | version.
56 |
57 | Skybrush Server is distributed in the hope that it will be useful, but WITHOUT
58 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
59 | FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
60 | more details.
61 |
62 | You should have received a copy of the GNU General Public License along with
63 | this program. If not, see .
64 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/mavlink/comm.py:
--------------------------------------------------------------------------------
1 | """Communication manager that facilitates communication between a MAVLink-based
2 | UAV and the ground station via some communication link.
3 | """
4 |
5 | from __future__ import annotations
6 |
7 | from compose import compose
8 | from functools import partial
9 | from typing import Any
10 |
11 | from flockwave.channels import (
12 | create_lossy_channel,
13 | )
14 | from flockwave.connections import Connection
15 | from flockwave.networking import format_socket_address
16 |
17 | from flockwave.server.comm import CommunicationManager
18 |
19 | from .channel import create_mavlink_message_channel
20 | from .signing import MAVLinkSigningConfiguration
21 | from .types import MAVLinkMessageSpecification
22 |
23 |
24 | __all__ = ("create_communication_manager",)
25 |
26 |
27 | def format_mavlink_channel_address(address: Any) -> str:
28 | """Returns a formatted representation of the address of a MAVLink message
29 | channel.
30 | """
31 | try:
32 | return format_socket_address(address)
33 | except ValueError:
34 | return str(address)
35 |
36 |
37 | def create_communication_manager(
38 | *,
39 | packet_loss: float = 0,
40 | network_id: str = "",
41 | system_id: int = 255,
42 | signing: MAVLinkSigningConfiguration = MAVLinkSigningConfiguration.DISABLED,
43 | use_broadcast_rate_limiting: bool = False,
44 | ) -> CommunicationManager[MAVLinkMessageSpecification, Any]:
45 | """Creates a communication manager instance for a single network managed
46 | by the extension.
47 |
48 | Parameters:
49 | packet_loss: simulated packet loss probability; zero means normal
50 | behaviour
51 | system_id: the system ID to use in MAVLink messages sent by this
52 | communication manager
53 | signing: specifies how to handle signed MAVLink messages in both the
54 | incoming and the outbound direction
55 | use_broadcast_rate_limiting: whether to apply a small delay after
56 | sending each broadcast packet; this can be used to counteract
57 | rate limiting problems if there are any. Typically you can leave
58 | this setting at `False` unless you see lots of lost broadcast
59 | packets.
60 | """
61 | # Create a dictionary to cache link IDs to existing connections so we can
62 | # keep on using the same link ID for the same connection even if it is
63 | # closed and re-opened later
64 | link_ids: dict[Connection, int] = {}
65 | channel_factory = partial(
66 | create_mavlink_message_channel,
67 | signing=signing,
68 | link_ids=link_ids,
69 | network_id=network_id,
70 | system_id=system_id,
71 | )
72 |
73 | if packet_loss > 0:
74 | channel_factory = compose(
75 | partial(create_lossy_channel, loss_probability=packet_loss), channel_factory
76 | )
77 |
78 | manager = CommunicationManager(
79 | channel_factory=channel_factory,
80 | format_address=format_mavlink_channel_address,
81 | )
82 |
83 | if use_broadcast_rate_limiting:
84 | manager.broadcast_delay = 0.005
85 |
86 | return manager
87 |
--------------------------------------------------------------------------------
/src/flockwave/server/ext/virtual_connections.py:
--------------------------------------------------------------------------------
1 | """Extension that creates one or more virtual connection objects in
2 | the server.
3 |
4 | The virtual connections stay alive for a given number of seconds when they
5 | are opened, then they close themselves and refuse to respond to further
6 | opening attempts for a given number of seconds. The length of both time
7 | intervals can be configured.
8 |
9 | Useful primarily for debugging purposes.
10 | """
11 |
12 | from flockwave.connections import Connection, ConnectionBase
13 | from flockwave.server.model import ConnectionPurpose
14 | from trio import current_time, open_nursery, sleep, sleep_until
15 |
16 | __all__ = ()
17 |
18 |
19 | class VirtualConnection(ConnectionBase):
20 | """Virtual connection class used by this extension.
21 |
22 | This connection class breaks the connection two seconds after it was
23 | opened. Subsequent attempts to open the connection will be blocked
24 | up to at least three seconds after the connection was closed the last
25 | time.
26 | """
27 |
28 | def __init__(self):
29 | """Constructor."""
30 | super().__init__()
31 | self._open_disallowed_until = None
32 |
33 | async def _open(self):
34 | """Opens the connection if it is currently allowed. Opening the
35 | connection will start a timer that closes the connection in
36 | two seconds.
37 | """
38 | if self._open_disallowed_until is not None:
39 | await sleep_until(self._open_disallowed_until)
40 |
41 | async def _close(self):
42 | """Closes the connection and blocks reopening attempts in the next
43 | three seconds.
44 | """
45 | self._open_disallowed_until = current_time() + 3
46 |
47 | async def close_soon(self):
48 | """Waits two seconds and then closes the connection."""
49 | await sleep(2)
50 | await self.close()
51 |
52 |
53 | async def worker(app, configuration, logger):
54 | """Runs the main worker task of the extension when at least one client
55 | is connected.
56 |
57 | The configuration object supports the following keys:
58 |
59 | ``count``
60 | The number of virtual connections to provide
61 |
62 | ``id_format``
63 | String template that defines how the names of the virtual
64 | connections should be generated; must be in the format accepted
65 | by the ``str.format()`` method in Python when given the
66 | connection index as its argument.
67 | """
68 | count = configuration.get("count", 0)
69 | id_format = configuration.get("id_format", "virtualConnection{0}")
70 |
71 | async with open_nursery() as nursery:
72 | for index in range(count):
73 | name = id_format.format(index)
74 | nursery.start_soon(
75 | _handle_single_connection, app, VirtualConnection(), name
76 | )
77 |
78 |
79 | async def _handle_single_connection(app, connection: Connection, name: str) -> None:
80 | with app.connection_registry.use(
81 | connection, name=name, purpose=ConnectionPurpose.debug
82 | ):
83 | await app.supervise(connection, task=VirtualConnection.close_soon)
84 |
--------------------------------------------------------------------------------
/src/flockwave/server/middleware/logging.py:
--------------------------------------------------------------------------------
1 | """Middleware that logs incoming and outgoing messages in the message hub."""
2 |
3 | from logging import Logger
4 |
5 | from flockwave.server.model import Client, FlockwaveMessage, FlockwaveNotification
6 | from typing import ClassVar, Iterable, Optional
7 |
8 | __all__ = ("RequestLogMiddleware",)
9 |
10 |
11 | class RequestLogMiddleware:
12 | """Middleware that logs incoming requests in the message hub."""
13 |
14 | DEFAULT_EXCLUDED_MESSAGES: ClassVar[tuple[str, ...]] = (
15 | "RTK-STAT",
16 | "UAV-PREFLT",
17 | "X-DBG-RESP",
18 | "X-RTK-STAT",
19 | )
20 | """Default set of excluded messages in this middleware."""
21 |
22 | exclude: set[str]
23 | """Set of message types to exclude from the log."""
24 |
25 | log: Logger
26 | """Logger to log the messages to."""
27 |
28 | def __init__(
29 | self, log: Logger, *, exclude: Iterable[str] = DEFAULT_EXCLUDED_MESSAGES
30 | ):
31 | self.log = log
32 | self.exclude = set(exclude)
33 |
34 | def __call__(self, message: FlockwaveMessage, sender: Client) -> FlockwaveMessage:
35 | type = message.get_type() or "untyped"
36 | if type not in self.exclude:
37 | self.log.info(
38 | f"Received {type} message",
39 | extra={"id": message.id, "semantics": "request"},
40 | )
41 | return message
42 |
43 |
44 | class ResponseLogMiddleware:
45 | """Middleware that logs outgoing responses, notifications and broadcasts
46 | in the message hub.
47 | """
48 |
49 | log: Logger
50 | """Logger to log the messages to."""
51 |
52 | def __init__(self, log: Logger):
53 | self.log = log
54 |
55 | def __call__(
56 | self,
57 | message: FlockwaveMessage,
58 | to: Optional[Client],
59 | in_response_to: Optional[FlockwaveMessage],
60 | ) -> FlockwaveMessage:
61 | type = message.get_type() or "untyped"
62 | if to is None:
63 | if type not in ("CONN-INF", "UAV-INF", "DEV-INF", "SYS-MSG", "X-DBG-REQ"):
64 | self.log.info(
65 | f"Broadcasting {type} notification",
66 | extra={"id": message.id, "semantics": "notification"},
67 | )
68 | elif in_response_to is not None:
69 | if type not in ("RTK-STAT", "X-RTK-STAT", "UAV-PREFLT"):
70 | self.log.info(
71 | f"Sending {type} response",
72 | extra={"id": in_response_to.id, "semantics": "response_success"},
73 | )
74 | elif isinstance(message, FlockwaveNotification):
75 | if type not in ("UAV-INF", "DEV-INF"):
76 | self.log.info(
77 | f"Sending {type} notification",
78 | extra={"id": message.id, "semantics": "notification"},
79 | )
80 | else:
81 | extra = {"semantics": "response_success"}
82 | if hasattr(message, "id"):
83 | extra["id"] = message.id
84 |
85 | self.log.info(f"Sending {type} message", extra=extra)
86 |
87 | return message
88 |
--------------------------------------------------------------------------------
/src/flockwave/server/model/authentication.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from dataclasses import dataclass
3 | from enum import Enum
4 | from typing import Optional
5 |
6 | from .client import Client
7 |
8 |
9 | class AuthenticationResultType(Enum):
10 | FAILURE = "failure"
11 | SUCCESS = "success"
12 | CHALLENGE = "challenge"
13 |
14 |
15 | @dataclass
16 | class AuthenticationResult:
17 | type: AuthenticationResultType
18 | data: Optional[str] = None
19 | reason: Optional[str] = None
20 | user: Optional[str] = None
21 |
22 | @classmethod
23 | def challenge(cls, data):
24 | return cls(type=AuthenticationResultType.CHALLENGE, data=data)
25 |
26 | @classmethod
27 | def success(cls, user):
28 | return cls(type=AuthenticationResultType.SUCCESS, user=user)
29 |
30 | @classmethod
31 | def failure(cls, reason=None):
32 | return cls(type=AuthenticationResultType.FAILURE, reason=reason)
33 |
34 | @property
35 | def json(self):
36 | """Converts the response into its JSON representation."""
37 | if self.type is AuthenticationResultType.SUCCESS:
38 | if self.user is None:
39 | raise ValueError("successful authentication responses need a username")
40 | result = {"result": True, "user": str(self.user)}
41 | elif self.type is AuthenticationResultType.FAILURE:
42 | result = {"result": False, "reason": self.reason or "Authentication failed"}
43 | else:
44 | if self.data is None:
45 | raise ValueError("authentication challenges need a data member")
46 | result = {"data": str(self.data)}
47 | result["type"] = "AUTH-RESP"
48 | return result
49 |
50 | @property
51 | def successful(self):
52 | """Returns whether the response represents a successful authentication
53 | attempt.
54 | """
55 | return self.type is AuthenticationResultType.SUCCESS and self.user is not None
56 |
57 |
58 | class AuthenticationMethod(ABC):
59 | """Interface specification for authentication methods."""
60 |
61 | @property
62 | @abstractmethod
63 | def id(self):
64 | """Identifier of the authentication method with which it will be
65 | registered in the server.
66 | """
67 | raise NotImplementedError
68 |
69 | @abstractmethod
70 | def authenticate(self, client: Client, data: str) -> AuthenticationResult:
71 | """Handles an authentication request from a client.
72 |
73 | Parameters:
74 | client: the client that sent the authentication request
75 | data: the data that was sent in this request
76 |
77 | Returns:
78 | response to the authentication request
79 | """
80 | raise NotImplementedError
81 |
82 | def cancel(self, client: Client) -> None: # noqa: B027
83 | """Cancels the current authentication session of the given client.
84 |
85 | This method is relevant only for multi-step authentication methods.
86 |
87 | Parameters:
88 | client: the client whose authentication attempt is cancelled
89 | """
90 | pass
91 |
--------------------------------------------------------------------------------