refactor(firmware): remove the retired simulation workflow
Remove the retired simulator workspace member, dependencies, workflow and documentation. Keep the shared flight and hardware crates. Validation: retirement prefix host check, command-dispatch tests and book build.
This commit is contained in:
1 parent
53e57c5d73
commit
3226ef97fe
40 files changed
+22
-4176
No files matched your search
@@ -1,41 +0,0 @@
|
||||
name: Host SITL smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Deterministic Gazebo command path
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Check host-SITL contracts
|
||||
working-directory: firmware
|
||||
run: |
|
||||
python3 -m unittest tools/sitl/test_kaptan_sim.py
|
||||
python3 tools/sitl/world-structure-test.py
|
||||
cargo check --locked -p kaptan-mavlink --no-default-features
|
||||
cargo check --locked -p kaptan-log --no-default-features
|
||||
cargo check --locked -p kaptan-diagnostics-mavlink
|
||||
cargo test --locked -p kaptan-log
|
||||
cargo test --locked -p kaptan-diagnostics-mavlink
|
||||
if rg -n '(^use log::|\blog::)' platforms/stm32f7/src/f722; then
|
||||
echo 'the standard log facade is forbidden in F722 IRQ/panic paths'
|
||||
exit 1
|
||||
fi
|
||||
if rg -n 'max_level_(off|error|warn|info|debug|trace)' --glob 'Cargo.toml' --glob '*.rs' .; then
|
||||
echo 'log/max_level_* removes records before Kaptan can observe them'
|
||||
exit 1
|
||||
fi
|
||||
cargo test --locked -p kaptan-sitl
|
||||
|
||||
- name: Run Docker closed-loop smoke
|
||||
run: firmware/tools/sitl/e2e-smoke.sh
|
||||
@@ -6,12 +6,11 @@ override MAKEFLAGS += --no-print-directory
|
||||
|
||||
override FIRMWARE_DIR := firmware
|
||||
override DOCS_DIR := docs
|
||||
override SITL_DIR := firmware/tools/sitl
|
||||
override CONFIGURATOR_DIR := firmware/tools/configurator
|
||||
override BOARD_DIRS := $(patsubst %/,%,$(wildcard firmware/boards/*/))
|
||||
|
||||
override COMPONENT_TARGETS := $(FIRMWARE_DIR) $(DOCS_DIR) firmware/boards $(BOARD_DIRS) \
|
||||
$(SITL_DIR) $(CONFIGURATOR_DIR)
|
||||
$(CONFIGURATOR_DIR)
|
||||
|
||||
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
|
||||
override ROUTED_GOAL := $(filter $(PRIMARY_GOAL),$(COMPONENT_TARGETS))
|
||||
@@ -57,7 +56,7 @@ help: ## Show the command interface (the default)
|
||||
' nix-shell Enter the Nix development shell' \
|
||||
' build Build all firmware and documentation' \
|
||||
' fmt Check Rust, shell, Python, Nix, and TOML formatting' \
|
||||
' test Run Rust, script, and SITL tests' \
|
||||
' test Run Rust and script tests' \
|
||||
' pre-commit Run fmt, firmware clippy/test/docs, and mdBook checks' \
|
||||
' check Run pre-commit plus whitespace checks' \
|
||||
' clean Remove generated build artifacts' \
|
||||
@@ -74,8 +73,6 @@ help: ## Show the command interface (the default)
|
||||
' docs -- <mdbook args...> Pass explicit arguments to mdbook' \
|
||||
' firmware/tools/configurator -- <args...>' \
|
||||
' Pass args to kaptan-configurator' \
|
||||
' firmware/tools/sitl -- <args...>' \
|
||||
' Pass args to kaptan-sim' \
|
||||
'' 'Examples' \
|
||||
' make firmware -- test --workspace --locked --offline' \
|
||||
' make firmware clippy' \
|
||||
@@ -83,8 +80,7 @@ help: ## Show the command interface (the default)
|
||||
' make firmware/boards/kakute-f7-mini-v3' \
|
||||
' make firmware/boards/kakute-f7-mini-v3 flash' \
|
||||
' make docs -- build' \
|
||||
' make firmware/tools/configurator' \
|
||||
' make firmware/tools/sitl -- move 5 3 1 --wait'
|
||||
' make firmware/tools/configurator'
|
||||
|
||||
nix-shell: ## Enter the reproducible Nix development shell
|
||||
nix develop
|
||||
@@ -96,14 +92,13 @@ build: ## Build all firmware boards and documentation
|
||||
fmt:
|
||||
+@$(MAKE) -C $(FIRMWARE_DIR) fmt
|
||||
shfmt -d $$(rg --files -g '*.sh')
|
||||
ruff format --check firmware/tools/sitl/kaptan-sim $$(rg --files -g '*.py')
|
||||
ruff format --check $$(rg --files -g '*.py')
|
||||
nixfmt --check $$(rg --files -g '*.nix')
|
||||
taplo format --check $$(rg --files -g '*.toml')
|
||||
|
||||
test:
|
||||
python3 -m unittest scripts/test-make-argv.py
|
||||
+@$(MAKE) -C $(FIRMWARE_DIR) test
|
||||
+@$(MAKE) -C $(SITL_DIR) _check
|
||||
|
||||
pre-commit: fmt test
|
||||
+@$(MAKE) -C $(FIRMWARE_DIR) clippy
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[book]
|
||||
title = "Kaptan documentation"
|
||||
description = "Architecture, board bring-up, simulation, and contributor guidance for Kaptan."
|
||||
description = "Architecture, board bring-up, and contributor guidance for Kaptan."
|
||||
authors = ["Kaptan contributors"]
|
||||
language = "en"
|
||||
src = "src"
|
||||
|
||||
@@ -12,5 +12,4 @@
|
||||
- [Boards](boards/index.md)
|
||||
- [Kakute F7 Mini V3](boards/kakute-f7-mini-v3.md)
|
||||
- [GEPRC GEP-F722-HD V2](boards/geprc-f722-v2.md)
|
||||
- [Host SITL](sitl.md)
|
||||
- [Reference](reference.md)
|
||||
@@ -55,10 +55,9 @@ one complete replacement candidate. The core distinguishes only immutable
|
||||
boot-active values from the latest committed values. A save cannot mutate
|
||||
controller or estimator state in flight.
|
||||
|
||||
Target-owned stores use private persistence formats. SITL atomically replaces a
|
||||
bounded CBOR snapshot. F722 alternates compact `KCSP` snapshots between two
|
||||
erase sectors, retaining only the old and replacement records across the commit
|
||||
boundary. Neither format is exposed as the public KCFG file.
|
||||
Target-owned stores use private persistence formats. F722 alternates compact
|
||||
`KCSP` snapshots between two erase sectors, retaining only the old and
|
||||
replacement records across the commit boundary. That format is not exposed as the public KCFG file.
|
||||
Motor allocation/mixer topology is immutable compiled vehicle capability data,
|
||||
never a scalar field or persistence member. F722 recovers operational settings
|
||||
before constructing consumers. Its local USB CDC-ACM and directly wired UART
|
||||
|
||||
@@ -10,8 +10,6 @@ parameter catalog.
|
||||
|
||||
| Deployment | Configuration consumed today | Remote writes |
|
||||
| --- | --- | --- |
|
||||
| `kaptan-sitl serve --config FILE.cbor` | One validated Quad-X configuration at process construction. | No live file endpoint is attached yet. |
|
||||
| `kaptan-sitl serve` | The validated factory Quad-X configuration. | No persistent store. |
|
||||
| STM32F722 image | Board factory settings, or the newest valid flash-journal snapshot. | `/config/user.kcfg` over local USB CDC-ACM or the directly wired UART while motor pins remain inhibited. |
|
||||
|
||||
## Web configurator
|
||||
@@ -171,16 +169,7 @@ be exposed safely.
|
||||
|
||||
## Private persistence
|
||||
|
||||
SITL and F722 use target-appropriate private persistence. Neither private
|
||||
format is the public KCFG artifact.
|
||||
|
||||
### SITL CBOR
|
||||
|
||||
SITL stores one bounded CBOR snapshot keyed by stable `FieldId`. Saving writes
|
||||
a same-directory temporary file, synchronizes it, and atomically renames it
|
||||
over the configured file. The exact map rejects unknown, duplicate, and missing
|
||||
fields, wrong schema or format, trailing input, invalid scalar encodings, and
|
||||
complete-model validation failures.
|
||||
F722 uses private persistence separate from the public KCFG artifact.
|
||||
|
||||
### F722 flash journal
|
||||
|
||||
|
||||
+2
-16
@@ -10,8 +10,7 @@ directory path to that directory's Makefile.
|
||||
The standard `--` ends Make's own options. Every following argument uses the
|
||||
selected application's native command line. For example,
|
||||
`make firmware -- test --workspace --locked --offline` runs `cargo test
|
||||
--workspace --locked --offline` from `firmware`, while
|
||||
`make firmware/tools/sitl -- ready` runs `kaptan-sim ready`. Board paths are
|
||||
--workspace --locked --offline` from `firmware`. Board paths are
|
||||
the complete build-and-package command, so they need no invented operation
|
||||
name.
|
||||
|
||||
@@ -37,7 +36,7 @@ make firmware -- clippy \
|
||||
`make firmware docs` builds Rust API documentation with warnings denied.
|
||||
`make docs` checks and builds the separate mdBook project. `make pre-commit`
|
||||
runs both documentation checks along with repository formatting, host and
|
||||
embedded Clippy, Rust and script tests, and SITL structure tests. `make check`
|
||||
embedded Clippy, Rust and script tests. `make check`
|
||||
adds a whitespace check. `make build` builds and packages release firmware and
|
||||
builds the mdBook project.
|
||||
|
||||
@@ -53,16 +52,3 @@ The strict gate requires 100% line and branch coverage for `kaptan-math`,
|
||||
core package's coverage artifacts, unions LCOV source-line records, and uses
|
||||
full LLVM source spans to merge generic branch instantiations without lowering
|
||||
the gate.
|
||||
|
||||
## Simulation acceptance
|
||||
|
||||
```sh
|
||||
cd firmware/tools/sitl
|
||||
python3 world-structure-test.py
|
||||
python3 -m unittest test_kaptan_sim.py
|
||||
./e2e-smoke.sh
|
||||
```
|
||||
|
||||
The Docker smoke is intentionally separate from host unit tests because it
|
||||
builds the pinned base images, Rust binary, Gazebo plugin, and full loopback
|
||||
plant.
|
||||
+1
-1
@@ -79,7 +79,7 @@ different reportable error is already pending.
|
||||
The message kind itself identifies an error; no level is duplicated in the
|
||||
payload. Dynamic human-readable context belongs in `LOG`, while
|
||||
structured protocol failures remain in their owning response message.
|
||||
- Control, navigation, safety, and simulator error types are derive-ready but
|
||||
- Control, navigation, and safety error types are derive-ready but
|
||||
their live owners are not yet attached to a MAVLink worker.
|
||||
|
||||
F722 still uses `panic-halt`; it does not claim a live panic frame or
|
||||
|
||||
@@ -33,7 +33,6 @@ target-specific commands in [Development and verification](development.md).
|
||||
| `firmware/boards/` | Board-owned user schemas, factory values, immutable hardware declarations, and image composition roots. |
|
||||
| `firmware/platforms/stm32f7/` | Reviewed F722 RTIC platform, peripheral ownership, and shared linker layout. |
|
||||
| `firmware/libs/` | Typed math, estimation, control, safety, protocol, and driver crates. |
|
||||
| `firmware/tools/sitl/` | Host-only closed-loop Gazebo simulation. |
|
||||
| `firmware/tools/configurator/` | Board-agnostic loopback web UI and serial bridge. |
|
||||
| `docs/` | This Markdown documentation site. |
|
||||
|
||||
|
||||
+4
-6
@@ -2,8 +2,7 @@
|
||||
|
||||
Kaptan is an embedded Rust flight-controller project. This manual is the
|
||||
single source of project documentation: board support, safety boundaries,
|
||||
simulation, development workflow, and design references live here as
|
||||
Markdown.
|
||||
development workflow, and design references live here as Markdown.
|
||||
|
||||
> **Status:** the checked-in firmware is telemetry-only. It does not expose
|
||||
> physical motor authority, and this site does not make a physical-flight
|
||||
@@ -14,7 +13,6 @@ Markdown.
|
||||
- New contributors should read [Getting started](getting-started.md).
|
||||
- Firmware source and its Rust workspace begin in `firmware/`.
|
||||
- Board work belongs under [Boards](boards/index.md).
|
||||
- The currently supported closed-loop simulation is [Host SITL](sitl.md).
|
||||
- [Configuration](configuration.md) explains target-described user settings
|
||||
and immutable F722 hardware policy.
|
||||
- [Logs](logs.md) and [Errors](errors.md) describe the custom MAVLink
|
||||
@@ -29,7 +27,7 @@ only defines the firmware workspace boundary. Keep pages in Markdown, add each
|
||||
page to `SUMMARY.md`, and place commands beside the assumptions and safety
|
||||
boundary they depend on.
|
||||
|
||||
The current configuration contract is target-described. A SITL Quad-X profile
|
||||
and each F722 board expose only values consumed by that compiled target;
|
||||
physical F722 hardware selection remains immutable board policy. No writable
|
||||
The current configuration contract is target-described. Each F722 board exposes
|
||||
only values consumed by that compiled target. Physical F722 hardware selection
|
||||
remains immutable board policy. No writable
|
||||
snapshot can bypass an immutable safety decision.
|
||||
+2
-7
@@ -78,11 +78,6 @@ named choices to the configurator, so the UI carries no hardcoded field list.
|
||||
Only the MAVLink adapter converts the five event levels to the typed Kaptan
|
||||
wire enum.
|
||||
|
||||
The log ingress is transport-neutral and unfiltered. The tested
|
||||
`SitlConfigRuntime` composition applies the configured MAVLink policy when
|
||||
fanning an event into that sink and proves `kaptan_log::info!` reaches a
|
||||
generated `LOG` payload.
|
||||
`kaptan-sitl serve` does not yet attach a file/diagnostic MAVLink link. F722
|
||||
images accept the unsigned Kaptan file service on local USB CDC-ACM and their
|
||||
directly wired UART; that physical-link assumption does not extend to a shared
|
||||
The log ingress is transport-neutral and unfiltered. F722 images accept the
|
||||
unsigned Kaptan file service on local USB CDC-ACM and their directly wired UART; that physical-link assumption does not extend to a shared
|
||||
or remote MAVLink network.
|
||||
@@ -4,7 +4,6 @@ This page collects stable limits and points to the detailed contracts.
|
||||
|
||||
| Surface | Current contract |
|
||||
| --- | --- |
|
||||
| SITL file | One raw bounded CBOR configuration snapshot, replaced through a same-directory temporary file and atomic rename. |
|
||||
| F722 persistence | One bounded native-width scalar snapshot inside each slot of a power-loss-safe two-slot flash journal. |
|
||||
| Public config artifact | One canonical self-describing KCFG file containing generated metadata, factory values, and the complete committed candidate. |
|
||||
| File transfer | Custom `FILE_CONTROL` (51025) and `FILE_DATA` (51026), one owner-bound stop-and-wait transfer, 246-byte data chunks, CRC-32C. |
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Host SITL
|
||||
|
||||
Host SITL is Kaptan's deterministic closed-loop simulation tier. Gazebo sends
|
||||
simulated IMU and declared noisy local position/velocity observations over a
|
||||
private loopback protocol. The Rust process runs the ESKF, safety supervisor,
|
||||
trajectory, controller, and allocator, then returns four virtual motor
|
||||
fractions to Gazebo.
|
||||
|
||||
It is a pre-bench software gate, not a physical-flight validation.
|
||||
|
||||
## Run the closed loop
|
||||
|
||||
```sh
|
||||
docker compose -f firmware/tools/sitl/compose.yaml up -d --build
|
||||
make firmware/tools/sitl -- ready
|
||||
make firmware/tools/sitl -- move 5 3 1 --wait
|
||||
make firmware/tools/sitl -- land 0 0 --wait
|
||||
docker compose -f firmware/tools/sitl/compose.yaml down
|
||||
```
|
||||
|
||||
For the reproducible command-path gate, use:
|
||||
|
||||
```sh
|
||||
firmware/tools/sitl/e2e-smoke.sh
|
||||
```
|
||||
|
||||
The smoke scenario verifies ascent, translation, descent, landing, flat-plane
|
||||
proximity, and zero applied motor fractions after landing. Its reference world
|
||||
contains only a visual landing pad; the pad has no collision geometry and
|
||||
never contributes a controller or truth observation.
|
||||
|
||||
Open <https://app.gazebosim.org/visualization>, connect to
|
||||
`ws://localhost:9002`, and leave authorization blank to view the world.
|
||||
|
||||
## Quad-X profile
|
||||
|
||||
SITL starts through the central `ConfigManager` lifecycle. Create and validate
|
||||
a private host profile file with:
|
||||
|
||||
```sh
|
||||
kaptan-sitl config write-default kaptan-sitl.cbor
|
||||
kaptan-sitl config validate kaptan-sitl.cbor
|
||||
kaptan-sitl serve --config kaptan-sitl.cbor
|
||||
```
|
||||
|
||||
The file selects the restart-only profile used to build the estimator,
|
||||
controller, allocator envelope, safety supervisor, and MAVLink log-sink policy at
|
||||
process start. It is local persistence and never appears on MAVLink.
|
||||
`kaptan-sitl serve` retains
|
||||
the central manager for that boot but does not yet open a MAVLink file/log
|
||||
endpoint; see [Configuration](configuration.md) for the KCFG, file-transfer,
|
||||
and safety boundaries.
|
||||
|
||||
## Limits
|
||||
|
||||
This model does not validate terrain, vision, flow/range sensing, wind,
|
||||
vibration, battery/ESC behavior, sensor latency/jitter, actuator faults, or
|
||||
physical touchdown sensing. Add those only as a reviewed sensor/contact model,
|
||||
not as a display-only shortcut.
|
||||
@@ -1,3 +1,2 @@
|
||||
target
|
||||
.git
|
||||
tools/hitl
|
||||
Generated
-19
@@ -374,25 +374,6 @@ dependencies = [
|
||||
"kaptan-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-sitl"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-config",
|
||||
"kaptan-control",
|
||||
"kaptan-diagnostics-mavlink",
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-flight-config",
|
||||
"kaptan-flight-runtime",
|
||||
"kaptan-log",
|
||||
"kaptan-math",
|
||||
"kaptan-mavlink",
|
||||
"kaptan-navigation",
|
||||
"kaptan-safety",
|
||||
"kaptan-time",
|
||||
"minicbor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-spi"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -26,7 +26,6 @@ members = [
|
||||
"libs/kaptan-time",
|
||||
"libs/kaptan-tx",
|
||||
"platforms/stm32f7",
|
||||
"tools/sitl",
|
||||
"tools/configurator",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
+3
-4
@@ -2,16 +2,15 @@
|
||||
|
||||
This directory is the Kaptan flight-controller firmware workspace. It owns the
|
||||
Rust workspace manifest, board declarations, platform implementation, reusable
|
||||
firmware crates, simulator, firmware scripts, and firmware test fixtures.
|
||||
firmware crates and firmware scripts.
|
||||
|
||||
```text
|
||||
firmware/
|
||||
├── boards/ immutable board declarations and linker layouts
|
||||
├── libs/ reusable no_std firmware crates
|
||||
├── platforms/ hardware platform composition
|
||||
├── tools/ SITL and firmware development tooling
|
||||
├── scripts/ packaging, analysis, and coverage helpers
|
||||
└── tests/ firmware integration-test fixtures
|
||||
├── tools/ firmware development tooling
|
||||
└── scripts/ packaging, analysis, and coverage helpers
|
||||
```
|
||||
|
||||
This workspace owns its Makefile. From this directory, list its commands with:
|
||||
|
||||
@@ -128,7 +128,6 @@ report_package kaptan-sensor /libs/kaptan-sensor/
|
||||
report_package kaptan-spi /libs/kaptan-spi/
|
||||
report_package kaptan-time /libs/kaptan-time/
|
||||
report_package kaptan-tx /libs/kaptan-tx/
|
||||
report_package kaptan-sitl /tools/sitl/
|
||||
|
||||
echo "platform-stm32f7 target-only/N/A (run cargo check -p platform-stm32f7 --lib)"
|
||||
echo "kaptan-geprc-f722-v2 target-only/N/A (run its thumbv7em-none-eabihf check)"
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -1,13 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.22 FATAL_ERROR)
|
||||
project(kaptan_sitl LANGUAGES CXX)
|
||||
|
||||
# Gazebo Jetty deliberately supplies unversioned CMake package targets. The
|
||||
# image installs only the Jetty (gz-sim 10) development ABI, so an accidental
|
||||
# build against another Gazebo major fails at configuration time.
|
||||
find_package(gz-cmake REQUIRED)
|
||||
gz_find_package(gz-sim REQUIRED)
|
||||
|
||||
add_library(kaptan_sitl_quad SHARED src/kaptan_sitl_quad.cc)
|
||||
target_compile_features(kaptan_sitl_quad PRIVATE cxx_std_20)
|
||||
target_compile_options(kaptan_sitl_quad PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(kaptan_sitl_quad PRIVATE gz-sim::gz-sim)
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "kaptan-sitl"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-err-reportable = { path = "../../libs/kaptan-err-reportable" }
|
||||
kaptan-control = { path = "../../libs/kaptan-control" }
|
||||
kaptan-config = { path = "../../libs/kaptan-config" }
|
||||
kaptan-flight-config = { path = "../../libs/kaptan-flight-config" }
|
||||
kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" }
|
||||
kaptan-log = { path = "../../libs/kaptan-log" }
|
||||
kaptan-math = { path = "../../libs/kaptan-math" }
|
||||
kaptan-navigation = { path = "../../libs/kaptan-navigation", features = [
|
||||
"sitl",
|
||||
] }
|
||||
kaptan-safety = { path = "../../libs/kaptan-safety" }
|
||||
kaptan-time = { path = "../../libs/kaptan-time" }
|
||||
minicbor = "0.25"
|
||||
|
||||
[dev-dependencies]
|
||||
kaptan-diagnostics-mavlink = { path = "../../libs/kaptan-diagnostics-mavlink" }
|
||||
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false }
|
||||
@@ -1,43 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM rust@sha256:3914072ca0c3b8aad871db9169a651ccfce30cf58303e5d6f2db16d1d8a7e58f AS sitl-build
|
||||
WORKDIR /opt/kaptan
|
||||
COPY . .
|
||||
# Select the pinned host compiler explicitly rather than inheriting the
|
||||
# repository's embedded-target toolchain components. `--locked` makes the
|
||||
# resolved source graph an explicit reviewable input to the image build.
|
||||
RUN cargo +1.90.0 build --locked --release --package kaptan-sitl
|
||||
|
||||
# Gazebo Jetty's supported amd64 runtime. The image is intentionally split so
|
||||
# the C++ plugin and host control process share no source-time build hack.
|
||||
FROM ubuntu@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gazebo-runtime
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y ca-certificates curl gnupg \
|
||||
&& curl --fail --location --silent --show-error \
|
||||
https://packages.osrfoundation.org/gazebo.gpg \
|
||||
--output /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] https://packages.osrfoundation.org/gazebo/ubuntu-stable noble main" \
|
||||
> /etc/apt/sources.list.d/gazebo-stable.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install --no-install-recommends -y gz-sim10-cli \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM gazebo-runtime AS gazebo-build
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y cmake g++ ninja-build pkg-config libgz-sim10-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY tools/sitl /opt/kaptan/sitl
|
||||
RUN cmake -G Ninja -S /opt/kaptan/sitl -B /opt/kaptan/sitl/plugin/build \
|
||||
&& cmake --build /opt/kaptan/sitl/plugin/build --parallel
|
||||
|
||||
FROM gazebo-runtime
|
||||
ENV HOME=/tmp \
|
||||
GZ_SIM_SYSTEM_PLUGIN_PATH=/opt/kaptan/sitl/plugin/build \
|
||||
GZ_SIM_RESOURCE_PATH=/opt/kaptan/sitl/worlds
|
||||
COPY tools/sitl /opt/kaptan/sitl
|
||||
COPY --from=gazebo-build /opt/kaptan/sitl/plugin/build/libkaptan_sitl_quad.so \
|
||||
/opt/kaptan/sitl/plugin/build/libkaptan_sitl_quad.so
|
||||
COPY --from=sitl-build /opt/kaptan/target/release/kaptan-sitl /usr/local/bin/kaptan-sitl
|
||||
RUN chmod 0555 /opt/kaptan/sitl/gazebo-entrypoint.sh /opt/kaptan/sitl/kaptan-sim /usr/local/bin/kaptan-sitl
|
||||
@@ -1,34 +0,0 @@
|
||||
override SHELL := /bin/sh
|
||||
override MAKEFLAGS += --no-print-directory
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.DELETE_ON_ERROR:
|
||||
|
||||
override PYTHON := python3
|
||||
|
||||
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
|
||||
|
||||
run: ## Run kaptan-sim with the remaining command-line arguments
|
||||
@exec python3 ../../../scripts/make-argv.py exec run ./kaptan-sim
|
||||
|
||||
ifeq ($(PRIMARY_GOAL),run)
|
||||
|
||||
.PHONY: run
|
||||
|
||||
.DEFAULT:
|
||||
@:
|
||||
|
||||
else
|
||||
|
||||
.PHONY: help run _check
|
||||
|
||||
help: ## Show simulation commands
|
||||
@printf '%s\n' 'Simulation' '' \
|
||||
'Application arguments are passed through the root Makefile:' \
|
||||
' make firmware/tools/sitl -- move 5 3 1 --wait'
|
||||
|
||||
_check:
|
||||
$(PYTHON) world-structure-test.py
|
||||
$(PYTHON) -m unittest test_kaptan_sim.py
|
||||
|
||||
endif
|
||||
@@ -1,33 +0,0 @@
|
||||
services:
|
||||
gazebo-sitl:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tools/sitl/Dockerfile
|
||||
command: ["/opt/kaptan/sitl/gazebo-entrypoint.sh"]
|
||||
depends_on:
|
||||
sitl:
|
||||
condition: service_started
|
||||
environment:
|
||||
HOME: /tmp
|
||||
network_mode: host
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:mode=1777,size=256m
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
init: true
|
||||
stop_grace_period: 5s
|
||||
|
||||
sitl:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tools/sitl/Dockerfile
|
||||
command: ["/usr/local/bin/kaptan-sitl", "serve"]
|
||||
network_mode: host
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:mode=1777,size=32m
|
||||
cap_drop: ["ALL"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
init: true
|
||||
stop_grace_period: 2s
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
# Host-only closed-loop acceptance scenario. It verifies the actual UDP lease,
|
||||
# estimator, safety, trajectory, controller, allocator, and Gazebo loop across
|
||||
# ascent, cross-axis translation, descent, and terminal landing. Every terminal
|
||||
# phase holds independent, noise-free Gazebo truth; that recorder cannot feed
|
||||
# the controller.
|
||||
set -eu
|
||||
|
||||
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
compose_file="$project_root/tools/sitl/compose.yaml"
|
||||
|
||||
python3 "$project_root/tools/sitl/world-structure-test.py"
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
trap - EXIT
|
||||
# Preserve the first failing command's result, but retain enough evidence
|
||||
# to distinguish a client/lease failure from a Gazebo or container start
|
||||
# failure in CI. `down` otherwise removes the only useful diagnostics.
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "SITL smoke failed (exit $status); compose state and recent logs follow:" >&2
|
||||
docker compose -f "$compose_file" ps >&2 || true
|
||||
docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true
|
||||
fi
|
||||
docker compose -f "$compose_file" down --remove-orphans || true
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
docker compose -f "$compose_file" up -d --build
|
||||
"$project_root/tools/sitl/kaptan-sim" ready --timeout 30
|
||||
"$project_root/tools/sitl/kaptan-sim" move 0 0 1.5 --wait --verify-truth --timeout 60
|
||||
"$project_root/tools/sitl/kaptan-sim" move 5 3 5 --wait --verify-truth --timeout 90
|
||||
"$project_root/tools/sitl/kaptan-sim" move -4 -2 2 --wait --verify-truth --timeout 90
|
||||
# Start the terminal operation while airborne and off the landing point. A
|
||||
# `move ... 1` is an armed hover, not evidence of landing or zero motors.
|
||||
"$project_root/tools/sitl/kaptan-sim" land 0 0 --wait --verify-truth --timeout 60
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
exec gz sim --force-version 10 -s -r -v 3 /opt/kaptan/sitl/worlds/kaptan-sitl-quad.sdf.in
|
||||
@@ -1,589 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small user client for the local Kaptan host-SITL process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
CONTROL = ("127.0.0.1", 14662)
|
||||
TRUTH = ("127.0.0.1", 14663)
|
||||
# The simulator lease identifies its one local user by UDP source address.
|
||||
# Keep this source stable across separate CLI invocations so a completed move
|
||||
# can be followed by the next command without a second, competing user.
|
||||
USER_CONTROL = ("127.0.0.1", 14665)
|
||||
USER_CLOCK = ("127.0.0.1", 14664)
|
||||
VERSION = 1
|
||||
STATUS_QUERY = struct.Struct("<4sB3x")
|
||||
STATUS = struct.Struct("<4sB3xQH2x")
|
||||
GOAL = struct.Struct("<4sB3xI3f")
|
||||
GOAL_KEEPALIVE = struct.Struct("<4sB3xI")
|
||||
GOAL_STATUS = struct.Struct("<4sB3xIB3x")
|
||||
CLOCK_PACKET = struct.Struct("<4sB3xQ")
|
||||
# KST2 carries pose/velocity, launch-relative attitude, and applied motor
|
||||
# fractions. It is a test-only stream; the Rust controller never binds TRUTH.
|
||||
TRUTH_PACKET = struct.Struct("<4sB3xQI14f")
|
||||
|
||||
ESTIMATOR_READY = 1 << 0
|
||||
IMU_FRESH = 1 << 1
|
||||
LOCAL_AIDING_FRESH = 1 << 2
|
||||
OUTPUT_PERMITTED = 1 << 3
|
||||
ACTUATOR_TRANSPORT = 1 << 4
|
||||
READY = ESTIMATOR_READY | IMU_FRESH | LOCAL_AIDING_FRESH | ACTUATOR_TRANSPORT
|
||||
GOAL_KEEPALIVE_MICROSECONDS = 100_000
|
||||
GOAL_REJECTED = 0
|
||||
GOAL_ACTIVE = 1
|
||||
GOAL_SETTLED = 2
|
||||
GOAL_LANDED = 3
|
||||
# Independent truth-recorder assertion for the single, flat ground plane
|
||||
# declared by the supplied world. This is not a physical contact detector and
|
||||
# must not be carried into a flight target.
|
||||
GROUND_PLANE_TOLERANCE_METRES = 0.10
|
||||
|
||||
|
||||
def arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
||||
ready = subcommands.add_parser(
|
||||
"ready", help="wait for estimator and simulated sensors"
|
||||
)
|
||||
ready.add_argument("--timeout", type=float, default=20.0)
|
||||
move = subcommands.add_parser(
|
||||
"move", help="command absolute local north/east/up metres"
|
||||
)
|
||||
move.add_argument("north", type=float)
|
||||
move.add_argument("east", type=float)
|
||||
move.add_argument("up", type=float)
|
||||
move.add_argument("--wait", action="store_true", help="wait for the settled result")
|
||||
move.add_argument(
|
||||
"--verify-truth",
|
||||
action="store_true",
|
||||
help="verify noise-free Gazebo truth on the test-only recorder port after settling",
|
||||
)
|
||||
move.add_argument("--truth-position-tolerance", type=float, default=0.75)
|
||||
move.add_argument("--truth-velocity-tolerance", type=float, default=0.50)
|
||||
move.add_argument("--truth-attitude-tolerance-rad", type=float, default=0.35)
|
||||
move.add_argument(
|
||||
"--truth-hold-seconds",
|
||||
type=float,
|
||||
default=0.75,
|
||||
help="require every independent truth sample to remain within tolerance for this interval",
|
||||
)
|
||||
# A distant, bounded goal is intentionally slow: the default must not turn
|
||||
# a valid long trajectory into a spurious client-side test failure.
|
||||
move.add_argument("--timeout", type=float, default=180.0)
|
||||
land = subcommands.add_parser(
|
||||
"land",
|
||||
help="descend to an explicit local landing point and revoke virtual motor authority",
|
||||
)
|
||||
land.add_argument(
|
||||
"north", type=float, help="absolute local north landing coordinate in metres"
|
||||
)
|
||||
land.add_argument(
|
||||
"east", type=float, help="absolute local east landing coordinate in metres"
|
||||
)
|
||||
land.add_argument(
|
||||
"--wait", action="store_true", help="wait for the terminal landed result"
|
||||
)
|
||||
land.add_argument(
|
||||
"--verify-truth",
|
||||
action="store_true",
|
||||
help="verify independent Gazebo ground truth and zero applied motor fractions after landing",
|
||||
)
|
||||
land.add_argument("--truth-position-tolerance", type=float, default=0.75)
|
||||
land.add_argument("--truth-velocity-tolerance", type=float, default=0.25)
|
||||
land.add_argument("--truth-attitude-tolerance-rad", type=float, default=0.35)
|
||||
land.add_argument("--truth-hold-seconds", type=float, default=0.75)
|
||||
land.add_argument("--timeout", type=float, default=180.0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def recv(socket_: socket.socket, deadline: float) -> bytes | None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
socket_.settimeout(remaining)
|
||||
try:
|
||||
payload, sender = socket_.recvfrom(256)
|
||||
except TimeoutError:
|
||||
return None
|
||||
return payload if sender[0] == "127.0.0.1" else None
|
||||
|
||||
|
||||
def latest_simulation_time(
|
||||
clock_socket: socket.socket, previous: int | None
|
||||
) -> int | None:
|
||||
"""Drains Gazebo's test-user clock without trusting host elapsed time."""
|
||||
latest = previous
|
||||
while True:
|
||||
try:
|
||||
payload, sender = clock_socket.recvfrom(64)
|
||||
except BlockingIOError:
|
||||
return latest
|
||||
if sender[0] != "127.0.0.1" or len(payload) != CLOCK_PACKET.size:
|
||||
continue
|
||||
magic, version, simulation_time = CLOCK_PACKET.unpack(payload)
|
||||
if magic != b"KSC1" or version != VERSION:
|
||||
continue
|
||||
if latest is not None and simulation_time < latest:
|
||||
raise RuntimeError(
|
||||
"Gazebo simulation clock reset; restart the command in the new epoch"
|
||||
)
|
||||
latest = simulation_time
|
||||
|
||||
|
||||
def status(socket_: socket.socket, deadline: float) -> int | None:
|
||||
socket_.sendto(STATUS_QUERY.pack(b"KSQ1", VERSION), CONTROL)
|
||||
payload = recv(socket_, deadline)
|
||||
if payload is None or len(payload) != STATUS.size:
|
||||
return None
|
||||
magic, version, _, flags = STATUS.unpack(payload)
|
||||
if magic != b"KSS1" or version != VERSION:
|
||||
return None
|
||||
return flags
|
||||
|
||||
|
||||
def wait_ready(socket_: socket.socket, timeout: float) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
latest = None
|
||||
while time.monotonic() < deadline:
|
||||
latest = status(socket_, min(deadline, time.monotonic() + 0.5))
|
||||
if latest is not None and latest & READY == READY:
|
||||
return
|
||||
if latest is None:
|
||||
raise RuntimeError(
|
||||
"host SITL is not running; run docker compose -f "
|
||||
"firmware/tools/sitl/compose.yaml up -d --build from the repository root"
|
||||
)
|
||||
names = (
|
||||
(ESTIMATOR_READY, "estimator initialization"),
|
||||
(IMU_FRESH, "fresh simulator IMU"),
|
||||
(LOCAL_AIDING_FRESH, "accepted simulator local-state aiding"),
|
||||
(ACTUATOR_TRANSPORT, "virtual actuator transport"),
|
||||
)
|
||||
missing = [name for bit, name in names if latest & bit != bit]
|
||||
raise RuntimeError(f"SITL is waiting for: {', '.join(missing)}")
|
||||
|
||||
|
||||
def truth_sample_within_tolerance(
|
||||
position_error: float,
|
||||
velocity: float,
|
||||
attitude_error: float,
|
||||
motors_stopped: bool,
|
||||
ground_plane_error: float,
|
||||
position_tolerance: float,
|
||||
velocity_tolerance: float,
|
||||
attitude_tolerance: float,
|
||||
require_stopped_motors: bool,
|
||||
require_ground_plane: bool,
|
||||
) -> bool:
|
||||
"""Checks one independent truth sample against its declared contract."""
|
||||
return not (
|
||||
position_error > position_tolerance
|
||||
or velocity > velocity_tolerance
|
||||
or attitude_error > attitude_tolerance
|
||||
or (require_stopped_motors and not motors_stopped)
|
||||
or (require_ground_plane and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES)
|
||||
)
|
||||
|
||||
|
||||
def verify_truth(
|
||||
north: float,
|
||||
east: float,
|
||||
up: float,
|
||||
timeout: float,
|
||||
position_tolerance: float,
|
||||
velocity_tolerance: float,
|
||||
attitude_tolerance: float,
|
||||
hold_seconds: float,
|
||||
require_stopped_motors: bool,
|
||||
require_ground_plane: bool,
|
||||
control_socket: socket.socket | None = None,
|
||||
clock_socket: socket.socket | None = None,
|
||||
keepalive: bytes | None = None,
|
||||
) -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as truth_socket:
|
||||
truth_socket.bind(TRUTH)
|
||||
acquisition_deadline = time.monotonic() + timeout
|
||||
hold_deadline: int | None = None
|
||||
samples = 0
|
||||
worst_position_error = 0.0
|
||||
worst_velocity = 0.0
|
||||
worst_attitude_error = 0.0
|
||||
worst_ground_plane_error = 0.0
|
||||
worst_motor_fraction = 0.0
|
||||
last_out_of_tolerance: str | None = None
|
||||
simulation_time: int | None = None
|
||||
next_keepalive_time: int | None = None
|
||||
last_truth_time: int | None = None
|
||||
while time.monotonic() < acquisition_deadline:
|
||||
if clock_socket is not None:
|
||||
simulation_time = latest_simulation_time(clock_socket, simulation_time)
|
||||
if (
|
||||
keepalive is not None
|
||||
and control_socket is not None
|
||||
and simulation_time is not None
|
||||
and (
|
||||
next_keepalive_time is None
|
||||
or simulation_time >= next_keepalive_time
|
||||
)
|
||||
):
|
||||
control_socket.sendto(keepalive, CONTROL)
|
||||
next_keepalive_time = simulation_time + GOAL_KEEPALIVE_MICROSECONDS
|
||||
payload = recv(
|
||||
truth_socket, min(acquisition_deadline, time.monotonic() + 0.01)
|
||||
)
|
||||
if payload is None or len(payload) != TRUTH_PACKET.size:
|
||||
continue
|
||||
(
|
||||
magic,
|
||||
version,
|
||||
truth_time,
|
||||
_,
|
||||
north_m,
|
||||
east_m,
|
||||
down_m,
|
||||
north_v,
|
||||
east_v,
|
||||
down_v,
|
||||
attitude_w,
|
||||
attitude_x,
|
||||
attitude_y,
|
||||
attitude_z,
|
||||
motor_1,
|
||||
motor_2,
|
||||
motor_3,
|
||||
motor_4,
|
||||
) = TRUTH_PACKET.unpack(payload)
|
||||
if magic != b"KST2" or version != VERSION:
|
||||
continue
|
||||
if last_truth_time is not None and truth_time < last_truth_time:
|
||||
raise RuntimeError("Gazebo truth time regressed")
|
||||
last_truth_time = truth_time
|
||||
position_error = math.sqrt(
|
||||
(north_m - north) ** 2 + (east_m - east) ** 2 + (down_m + up) ** 2
|
||||
)
|
||||
# `up` is converted to local-NED down above. A terminal land
|
||||
# request explicitly sets it to zero, so this distinguishes the
|
||||
# declared ground plane from a low-velocity armed hover.
|
||||
ground_plane_error = abs(down_m + up)
|
||||
velocity = math.sqrt(north_v**2 + east_v**2 + down_v**2)
|
||||
quaternion_norm = math.sqrt(
|
||||
attitude_w**2 + attitude_x**2 + attitude_y**2 + attitude_z**2
|
||||
)
|
||||
if not math.isfinite(quaternion_norm) or abs(quaternion_norm - 1.0) > 1e-3:
|
||||
raise RuntimeError(
|
||||
"Gazebo truth contained a non-unit attitude quaternion"
|
||||
)
|
||||
attitude_error = 2.0 * math.acos(
|
||||
min(1.0, abs(attitude_w) / quaternion_norm)
|
||||
)
|
||||
motors = (motor_1, motor_2, motor_3, motor_4)
|
||||
maximum_motor_fraction = max(abs(motor) for motor in motors)
|
||||
motors_stopped = all(abs(motor) <= 0.01 for motor in motors)
|
||||
if not truth_sample_within_tolerance(
|
||||
position_error,
|
||||
velocity,
|
||||
attitude_error,
|
||||
motors_stopped,
|
||||
ground_plane_error,
|
||||
position_tolerance,
|
||||
velocity_tolerance,
|
||||
attitude_tolerance,
|
||||
require_stopped_motors,
|
||||
require_ground_plane,
|
||||
):
|
||||
# A lifecycle result says the controller's own settle rule was
|
||||
# met. The independent plant may still be damping. Require one
|
||||
# uninterrupted truth hold, rather than rejecting the first
|
||||
# transient sample after that result.
|
||||
hold_deadline = None
|
||||
samples = 0
|
||||
last_out_of_tolerance = (
|
||||
"position_error="
|
||||
f"{position_error:.3f} m, velocity={velocity:.3f} m/s, "
|
||||
f"attitude_error={attitude_error:.3f} rad, "
|
||||
f"ground_plane_error={ground_plane_error:.3f} m, motors={motors}"
|
||||
)
|
||||
continue
|
||||
if hold_deadline is None:
|
||||
# Use the truth record's own simulated timestamp. A separately
|
||||
# drained clock record can be ahead of buffered truth packets,
|
||||
# so it cannot prove an uninterrupted physical hold.
|
||||
hold_deadline = truth_time + int(hold_seconds * 1_000_000)
|
||||
worst_position_error = position_error
|
||||
worst_velocity = velocity
|
||||
worst_attitude_error = attitude_error
|
||||
worst_ground_plane_error = ground_plane_error
|
||||
worst_motor_fraction = maximum_motor_fraction
|
||||
else:
|
||||
worst_position_error = max(worst_position_error, position_error)
|
||||
worst_velocity = max(worst_velocity, velocity)
|
||||
worst_attitude_error = max(worst_attitude_error, attitude_error)
|
||||
worst_ground_plane_error = max(
|
||||
worst_ground_plane_error, ground_plane_error
|
||||
)
|
||||
worst_motor_fraction = max(worst_motor_fraction, maximum_motor_fraction)
|
||||
samples += 1
|
||||
if truth_time >= hold_deadline:
|
||||
summary = (
|
||||
"truth verified: "
|
||||
f"samples={samples}, position_error<={worst_position_error:.3f} m, "
|
||||
f"velocity<={worst_velocity:.3f} m/s, "
|
||||
f"attitude_error<={worst_attitude_error:.3f} rad"
|
||||
)
|
||||
if require_ground_plane:
|
||||
summary += f", ground_plane_error<={worst_ground_plane_error:.3f} m"
|
||||
if require_stopped_motors:
|
||||
summary += f", motor_fraction<={worst_motor_fraction:.3f}"
|
||||
print(summary)
|
||||
return
|
||||
if last_out_of_tolerance is not None:
|
||||
raise RuntimeError(
|
||||
"Gazebo truth never held within tolerance before timeout: "
|
||||
+ last_out_of_tolerance
|
||||
)
|
||||
raise RuntimeError("did not receive a Gazebo truth record")
|
||||
|
||||
|
||||
def move(
|
||||
socket_: socket.socket,
|
||||
clock_socket: socket.socket,
|
||||
north: float,
|
||||
east: float,
|
||||
up: float,
|
||||
timeout: float,
|
||||
wait: bool,
|
||||
verify: bool,
|
||||
position_tolerance: float,
|
||||
velocity_tolerance: float,
|
||||
attitude_tolerance: float,
|
||||
hold_seconds: float,
|
||||
) -> None:
|
||||
if not all(math.isfinite(value) for value in (north, east, up)):
|
||||
raise ValueError("goal coordinates must be finite")
|
||||
wait_ready(socket_, timeout)
|
||||
goal_id = secrets.randbelow(0xFFFF_FFFF) + 1
|
||||
goal = GOAL.pack(b"KSG1", VERSION, goal_id, north, east, up)
|
||||
command_goal(
|
||||
socket_,
|
||||
clock_socket,
|
||||
goal,
|
||||
goal_id,
|
||||
north,
|
||||
east,
|
||||
up,
|
||||
timeout,
|
||||
wait,
|
||||
verify,
|
||||
position_tolerance,
|
||||
velocity_tolerance,
|
||||
attitude_tolerance,
|
||||
hold_seconds,
|
||||
GOAL_SETTLED,
|
||||
False,
|
||||
"move",
|
||||
)
|
||||
|
||||
|
||||
def land(
|
||||
socket_: socket.socket,
|
||||
clock_socket: socket.socket,
|
||||
north: float,
|
||||
east: float,
|
||||
timeout: float,
|
||||
wait: bool,
|
||||
verify: bool,
|
||||
position_tolerance: float,
|
||||
velocity_tolerance: float,
|
||||
attitude_tolerance: float,
|
||||
hold_seconds: float,
|
||||
) -> None:
|
||||
if not all(math.isfinite(value) for value in (north, east)):
|
||||
raise ValueError("landing coordinates must be finite")
|
||||
wait_ready(socket_, timeout)
|
||||
goal_id = secrets.randbelow(0xFFFF_FFFF) + 1
|
||||
landing = GOAL.pack(b"KSD1", VERSION, goal_id, north, east, 0.0)
|
||||
command_goal(
|
||||
socket_,
|
||||
clock_socket,
|
||||
landing,
|
||||
goal_id,
|
||||
north,
|
||||
east,
|
||||
0.0,
|
||||
timeout,
|
||||
wait,
|
||||
verify,
|
||||
position_tolerance,
|
||||
velocity_tolerance,
|
||||
attitude_tolerance,
|
||||
hold_seconds,
|
||||
GOAL_LANDED,
|
||||
True,
|
||||
"land",
|
||||
)
|
||||
|
||||
|
||||
def command_goal(
|
||||
socket_: socket.socket,
|
||||
clock_socket: socket.socket,
|
||||
goal: bytes,
|
||||
goal_id: int,
|
||||
north: float,
|
||||
east: float,
|
||||
up: float,
|
||||
timeout: float,
|
||||
wait: bool,
|
||||
verify: bool,
|
||||
position_tolerance: float,
|
||||
velocity_tolerance: float,
|
||||
attitude_tolerance: float,
|
||||
hold_seconds: float,
|
||||
terminal_lifecycle: int,
|
||||
require_stopped_motors: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
keepalive = GOAL_KEEPALIVE.pack(b"KSK1", VERSION, goal_id)
|
||||
deadline = time.monotonic() + timeout
|
||||
simulation_time: int | None = None
|
||||
next_send_time: int | None = None
|
||||
active = False
|
||||
while time.monotonic() < deadline:
|
||||
simulation_time = latest_simulation_time(clock_socket, simulation_time)
|
||||
if next_send_time is None or (
|
||||
simulation_time is not None and simulation_time >= next_send_time
|
||||
):
|
||||
socket_.sendto(keepalive if active else goal, CONTROL)
|
||||
if simulation_time is not None:
|
||||
next_send_time = simulation_time + GOAL_KEEPALIVE_MICROSECONDS
|
||||
payload = recv(socket_, min(deadline, time.monotonic() + 0.01))
|
||||
if payload is None or len(payload) != GOAL_STATUS.size:
|
||||
continue
|
||||
magic, version, received_id, lifecycle = GOAL_STATUS.unpack(payload)
|
||||
if magic != b"KSL1" or version != VERSION or received_id != goal_id:
|
||||
continue
|
||||
if lifecycle == GOAL_REJECTED:
|
||||
raise RuntimeError(
|
||||
f"{action} was rejected because SITL was not estimator-ready"
|
||||
)
|
||||
if lifecycle == GOAL_ACTIVE:
|
||||
active = True
|
||||
print(f"{action} active: north={north:g} m, east={east:g} m, up={up:g} m")
|
||||
break
|
||||
if not active:
|
||||
raise RuntimeError("goal was not activated")
|
||||
if not wait:
|
||||
return
|
||||
while time.monotonic() < deadline:
|
||||
simulation_time = latest_simulation_time(clock_socket, simulation_time)
|
||||
if simulation_time is not None and (
|
||||
next_send_time is None or simulation_time >= next_send_time
|
||||
):
|
||||
socket_.sendto(keepalive, CONTROL)
|
||||
next_send_time = simulation_time + GOAL_KEEPALIVE_MICROSECONDS
|
||||
payload = recv(socket_, min(deadline, time.monotonic() + 0.01))
|
||||
if payload is None or len(payload) != GOAL_STATUS.size:
|
||||
continue
|
||||
magic, version, received_id, lifecycle = GOAL_STATUS.unpack(payload)
|
||||
if (
|
||||
magic == b"KSL1"
|
||||
and version == VERSION
|
||||
and received_id == goal_id
|
||||
and lifecycle == terminal_lifecycle
|
||||
):
|
||||
print("landed" if terminal_lifecycle == GOAL_LANDED else "settled")
|
||||
if verify:
|
||||
verify_truth(
|
||||
north,
|
||||
east,
|
||||
up,
|
||||
min(5.0, timeout),
|
||||
position_tolerance,
|
||||
velocity_tolerance,
|
||||
attitude_tolerance,
|
||||
hold_seconds,
|
||||
require_stopped_motors,
|
||||
terminal_lifecycle == GOAL_LANDED,
|
||||
None if require_stopped_motors else socket_,
|
||||
clock_socket,
|
||||
None if require_stopped_motors else keepalive,
|
||||
)
|
||||
return
|
||||
raise RuntimeError(f"{action} did not reach its terminal state before timeout")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
values = arguments()
|
||||
if values.timeout <= 0:
|
||||
raise ValueError("timeout must be positive")
|
||||
if values.command in ("move", "land"):
|
||||
tolerances = (
|
||||
values.truth_position_tolerance <= 0
|
||||
or values.truth_velocity_tolerance <= 0
|
||||
or values.truth_attitude_tolerance_rad <= 0
|
||||
or values.truth_hold_seconds <= 0
|
||||
or not math.isfinite(values.truth_position_tolerance)
|
||||
or not math.isfinite(values.truth_velocity_tolerance)
|
||||
or not math.isfinite(values.truth_attitude_tolerance_rad)
|
||||
or not math.isfinite(values.truth_hold_seconds)
|
||||
)
|
||||
if tolerances:
|
||||
raise ValueError(
|
||||
"truth tolerances and hold duration must be finite and positive"
|
||||
)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||
client.bind(USER_CONTROL)
|
||||
if values.command == "ready":
|
||||
wait_ready(client, values.timeout)
|
||||
print("ready")
|
||||
elif values.command == "move":
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as clock:
|
||||
clock.bind(USER_CLOCK)
|
||||
clock.setblocking(False)
|
||||
move(
|
||||
client,
|
||||
clock,
|
||||
values.north,
|
||||
values.east,
|
||||
values.up,
|
||||
values.timeout,
|
||||
values.wait,
|
||||
values.verify_truth,
|
||||
values.truth_position_tolerance,
|
||||
values.truth_velocity_tolerance,
|
||||
values.truth_attitude_tolerance_rad,
|
||||
values.truth_hold_seconds,
|
||||
)
|
||||
else:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as clock:
|
||||
clock.bind(USER_CLOCK)
|
||||
clock.setblocking(False)
|
||||
land(
|
||||
client,
|
||||
clock,
|
||||
values.north,
|
||||
values.east,
|
||||
values.timeout,
|
||||
values.wait,
|
||||
values.verify_truth,
|
||||
values.truth_position_tolerance,
|
||||
values.truth_velocity_tolerance,
|
||||
values.truth_attitude_tolerance_rad,
|
||||
values.truth_hold_seconds,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, RuntimeError, ValueError, struct.error) as error:
|
||||
print(f"kaptan-sim: {error}", file=sys.stderr)
|
||||
raise SystemExit(2) from error
|
||||
@@ -1,8 +0,0 @@
|
||||
//! Host-side access to the common bounded log ingress.
|
||||
|
||||
use kaptan_log::LogRecord;
|
||||
|
||||
/// Visits one transport-neutral event for composition-level sink fanout.
|
||||
pub(super) fn drain_one<R>(consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||
kaptan_log::drain_one(consume)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//! Host-only configuration composition for SITL.
|
||||
//!
|
||||
//! This module is deliberately not a firmware storage backend. Flight targets
|
||||
//! need a reviewed A/B journal and authority path; SITL uses a same-directory
|
||||
//! file replacement to make startup and configuration tests reproducible.
|
||||
|
||||
#[cfg(test)]
|
||||
mod logging;
|
||||
mod runtime;
|
||||
mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) use runtime::{SitlConfigRuntime, configuration_service};
|
||||
pub use store::{
|
||||
FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile,
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Central SITL configuration composition.
|
||||
|
||||
#[cfg(test)]
|
||||
use kaptan_diagnostics_mavlink::encode_log;
|
||||
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile};
|
||||
use kaptan_flight_runtime::FlightRuntime;
|
||||
#[cfg(test)]
|
||||
use kaptan_log::TransportAdmission;
|
||||
#[cfg(test)]
|
||||
use kaptan_mavlink::generated::kaptan::LOG_DATA;
|
||||
|
||||
use super::FileConfigStore;
|
||||
#[cfg(test)]
|
||||
use super::logging;
|
||||
|
||||
/// SITL's immutable compiled capability contract.
|
||||
pub(super) const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
|
||||
|
||||
/// SITL composition root retaining the one profile manager.
|
||||
pub struct SitlConfigRuntime {
|
||||
runtime: FlightRuntime<FileConfigStore>,
|
||||
}
|
||||
|
||||
impl SitlConfigRuntime {
|
||||
/// Returns the immutable profile selected by the central manager at boot.
|
||||
pub const fn active_profile(&self) -> VehicleProfile {
|
||||
*self.runtime.configuration().active()
|
||||
}
|
||||
|
||||
/// Drains one standard `log` facade record and exposes the generated
|
||||
/// diagnostic payload for the host's sole MAVLink link owner. This
|
||||
/// host-only harness accepts the bounded handoff synchronously.
|
||||
#[cfg(test)]
|
||||
pub(super) fn pending_log_payload(&mut self) -> Option<LOG_DATA> {
|
||||
let runtime = &mut self.runtime;
|
||||
let _ = logging::drain_one(|record| runtime.publish_log_event(*record));
|
||||
self.runtime
|
||||
.submit_next_mavlink(|record| (TransportAdmission::Accepted, encode_log(record)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the host-only central configuration composition.
|
||||
pub fn configuration_service(store: FileConfigStore) -> Result<SitlConfigRuntime, String> {
|
||||
let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store)
|
||||
.map_err(|error| format!("configuration recovery failed: {error:?}"))?;
|
||||
Ok(SitlConfigRuntime { runtime })
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//! Host-only persistence for a validated SITL profile.
|
||||
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use kaptan_config::ConfigStore;
|
||||
use kaptan_err_reportable::KaptanError;
|
||||
use kaptan_flight_config::{
|
||||
CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile,
|
||||
decode_candidate, encode_candidate,
|
||||
};
|
||||
|
||||
use super::{configuration_service, runtime::CAPABILITIES};
|
||||
|
||||
/// Host file-store error. It intentionally makes no flash power-loss claim.
|
||||
#[derive(Debug, KaptanError)]
|
||||
pub enum FileStoreError {
|
||||
/// Host filesystem operation failed.
|
||||
#[kaptan_error(id = 12101)]
|
||||
Io(std::io::Error),
|
||||
/// The bounded file record did not decode and validate.
|
||||
#[kaptan_error(id = 12102)]
|
||||
Cbor(CborError),
|
||||
/// The fixed snapshot buffer could not encode the complete candidate.
|
||||
#[kaptan_error(id = 12103)]
|
||||
Encode,
|
||||
/// Factory-only startup deliberately has no durable configuration path.
|
||||
#[kaptan_error(id = 12104)]
|
||||
FactoryOnly,
|
||||
}
|
||||
|
||||
/// Same-directory atomic-replacement store for SITL only.
|
||||
pub struct FileConfigStore {
|
||||
path: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl FileConfigStore {
|
||||
/// Creates a host-only store at a fixed profile path.
|
||||
pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
path: Some(path.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a factory-only store for `kaptan-sitl serve` without
|
||||
/// `--config`. Commits are rejected rather than silently discarded.
|
||||
pub const fn factory_only() -> Self {
|
||||
Self { path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigStore<CandidateProfile> for FileConfigStore {
|
||||
type Error = FileStoreError;
|
||||
|
||||
fn load(&mut self, _: &CandidateProfile) -> Result<Option<CandidateProfile>, Self::Error> {
|
||||
let Some(path) = self.path.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let file = match File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(FileStoreError::Io(error)),
|
||||
};
|
||||
const MAXIMUM_FILE_BYTES: u64 = MAXIMUM_CANDIDATE_BYTES as u64;
|
||||
if file.metadata().map_err(FileStoreError::Io)?.len() > MAXIMUM_FILE_BYTES {
|
||||
return Err(FileStoreError::Cbor(CborError::TooLarge));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAXIMUM_FILE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(FileStoreError::Io)?;
|
||||
if bytes.len() as u64 > MAXIMUM_FILE_BYTES {
|
||||
return Err(FileStoreError::Cbor(CborError::TooLarge));
|
||||
}
|
||||
decode_candidate(&bytes, &CAPABILITIES)
|
||||
.map(Some)
|
||||
.map_err(FileStoreError::Cbor)
|
||||
}
|
||||
|
||||
fn persist(&mut self, candidate: &CandidateProfile) -> Result<(), Self::Error> {
|
||||
let path = self.path.clone().ok_or(FileStoreError::FactoryOnly)?;
|
||||
write_candidate(&path, *candidate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the fully validated factory-active profile.
|
||||
pub fn default_profile() -> Result<VehicleProfile, String> {
|
||||
Ok(configuration_service(FileConfigStore::factory_only())?.active_profile())
|
||||
}
|
||||
|
||||
/// Loads one bounded, complete CBOR candidate and returns its active view.
|
||||
pub fn load_profile(path: impl AsRef<Path>) -> Result<VehicleProfile, String> {
|
||||
if !path.as_ref().is_file() {
|
||||
return Err("configuration file does not exist".to_owned());
|
||||
}
|
||||
Ok(configuration_service(FileConfigStore::new(path.as_ref()))?.active_profile())
|
||||
}
|
||||
|
||||
/// Writes one validated factory candidate using a same-directory replacement.
|
||||
pub fn write_default_profile(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
let mut store = FileConfigStore::new(path.as_ref());
|
||||
store
|
||||
.persist(&CandidateProfile::factory())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn write_candidate(path: &Path, candidate: CandidateProfile) -> Result<(), FileStoreError> {
|
||||
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
|
||||
encode_candidate(candidate, &CAPABILITIES, &mut writer).map_err(|error| match error {
|
||||
CborEncodeError::InvalidProfile(violation) => {
|
||||
FileStoreError::Cbor(CborError::InvalidProfile(violation))
|
||||
}
|
||||
CborEncodeError::Encode(_) => FileStoreError::Encode,
|
||||
})?;
|
||||
let length = writer.position();
|
||||
let bytes = writer.into_inner();
|
||||
let temporary = path.with_extension("cbor.tmp");
|
||||
let mut file = File::create(&temporary).map_err(FileStoreError::Io)?;
|
||||
file.write_all(&bytes[..length])
|
||||
.map_err(FileStoreError::Io)?;
|
||||
file.sync_all().map_err(FileStoreError::Io)?;
|
||||
fs::rename(&temporary, path).map_err(FileStoreError::Io)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
use std::fs;
|
||||
|
||||
use kaptan_config::{ConfigModel, ConfigStore};
|
||||
use kaptan_flight_config::{CandidateProfile, decode_candidate};
|
||||
use kaptan_mavlink::{
|
||||
MavlinkFrame,
|
||||
encoder::MavlinkEncoder,
|
||||
generated::kaptan::{LogLevel, MavMessage},
|
||||
receiver::IntegrityMavlinkReceiver,
|
||||
};
|
||||
|
||||
use super::{FileConfigStore, configuration_service, runtime::CAPABILITIES};
|
||||
|
||||
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
|
||||
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
|
||||
for byte in frame.raw_bytes() {
|
||||
receiver.receive_byte(*byte);
|
||||
}
|
||||
receiver.take_received()?.decode()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_store_round_trips_a_complete_validated_snapshot() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kaptan-config-store-{}-{}.cbor",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let temporary = path.with_extension("cbor.tmp");
|
||||
let _ = fs::remove_file(&path);
|
||||
let _ = fs::remove_file(&temporary);
|
||||
|
||||
let factory = CandidateProfile::factory();
|
||||
let mut store = FileConfigStore::new(&path);
|
||||
assert!(store.load(&factory).unwrap().is_none());
|
||||
store.persist(&factory).unwrap();
|
||||
let snapshot = fs::read(&path).unwrap();
|
||||
let decoded = decode_candidate(&snapshot, &CAPABILITIES).unwrap();
|
||||
assert!(decoded.validate(&CAPABILITIES).is_ok());
|
||||
let candidate = store.load(&factory).unwrap().expect("persisted candidate");
|
||||
assert!(candidate.validate(&CAPABILITIES).is_ok());
|
||||
|
||||
store.persist(&factory).unwrap();
|
||||
let mut reopened = FileConfigStore::new(&path);
|
||||
assert!(reopened.load(&factory).unwrap().is_some());
|
||||
let mut fresh = FileConfigStore::new(&path);
|
||||
fresh.persist(&factory).unwrap();
|
||||
|
||||
fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"kaptan-sitl-log-{}-{}.cbor",
|
||||
std::process::id(),
|
||||
std::thread::current().name().unwrap_or("test")
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
let mut service = configuration_service(FileConfigStore::new(&path)).unwrap();
|
||||
|
||||
kaptan_log::info!("SITL configuration service ready");
|
||||
let payload = service
|
||||
.pending_log_payload()
|
||||
.expect("info facade record must reach LOG");
|
||||
assert_eq!(payload.level, LogLevel::LOG_LEVEL_INFO);
|
||||
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
|
||||
.expect("facade text remains UTF-8");
|
||||
assert_eq!(message, "SITL configuration service ready");
|
||||
let mut encoder = MavlinkEncoder::new(1, 1);
|
||||
let frame = encoder.encode(&payload);
|
||||
assert_eq!(frame.message_id(), 51_020);
|
||||
assert!(matches!(
|
||||
parse_test_message(&frame),
|
||||
Some(MavMessage::LOG(data))
|
||||
if data.level == payload.level
|
||||
&& data.message_length == payload.message_length
|
||||
&& data.message == payload.message
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
// Deterministic, deliberately narrow Gazebo-side adapter for the Kaptan
|
||||
// quad-X host-SITL transport contract. It is simulator tooling, not flight code.
|
||||
|
||||
#include <gz/common/Console.hh>
|
||||
#include <gz/math/Pose3.hh>
|
||||
#include <gz/math/Vector3.hh>
|
||||
#include <gz/msgs/imu.pb.h>
|
||||
#include <gz/plugin/Register.hh>
|
||||
#include <gz/sim/EntityComponentManager.hh>
|
||||
#include <gz/sim/Link.hh>
|
||||
#include <gz/sim/Model.hh>
|
||||
#include <gz/sim/System.hh>
|
||||
#include <gz/transport/Node.hh>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace kaptan::sitl
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr std::uint8_t kPacketVersion = 1;
|
||||
constexpr std::array<std::uint8_t, 4> kImuMagic{'K', 'S', 'I', '1'};
|
||||
constexpr std::array<std::uint8_t, 4> kLocalStateMagic{'K', 'S', 'V', '1'};
|
||||
constexpr std::array<std::uint8_t, 4> kClockMagic{'K', 'S', 'C', '1'};
|
||||
constexpr std::array<std::uint8_t, 4> kTruthMagic{'K', 'S', 'T', '2'};
|
||||
constexpr std::array<std::uint8_t, 4> kOutputMagic{'K', 'S', 'O', '1'};
|
||||
constexpr std::size_t kImuPacketSize = 44;
|
||||
constexpr std::size_t kClockPacketSize = 16;
|
||||
constexpr std::size_t kTruthPacketSize = 76;
|
||||
constexpr std::size_t kOutputPacketSize = 40;
|
||||
constexpr std::size_t kMaximumPacketsPerStep = 4;
|
||||
constexpr std::size_t kQuadMotorCount = 4;
|
||||
|
||||
struct QuadMotorGeometry
|
||||
{
|
||||
gz::math::Vector3d offset_flu;
|
||||
double reaction_torque_sign;
|
||||
};
|
||||
|
||||
// This test model is FLU / ENU. It is not an electrical Kakute timer map:
|
||||
// M1 front-right CW, M2 rear-right CCW, M3 rear-left CW, M4 front-left CCW.
|
||||
constexpr std::array<QuadMotorGeometry, kQuadMotorCount> kQuadXGeometry{{
|
||||
{{0.17, -0.17, 0.0}, -1.0},
|
||||
{{-0.17, -0.17, 0.0}, 1.0},
|
||||
{{-0.17, 0.17, 0.0}, -1.0},
|
||||
{{0.17, 0.17, 0.0}, 1.0},
|
||||
}};
|
||||
|
||||
bool IsFiniteFraction(const float value)
|
||||
{
|
||||
return std::isfinite(value) && value >= 0.0F && value <= 1.0F;
|
||||
}
|
||||
|
||||
bool IsZero(const float value)
|
||||
{
|
||||
return value == 0.0F;
|
||||
}
|
||||
|
||||
void WriteU32(std::uint8_t *destination, const std::uint32_t value)
|
||||
{
|
||||
for (std::size_t index = 0; index < sizeof(value); ++index)
|
||||
{
|
||||
destination[index] = static_cast<std::uint8_t>(value >> (index * 8));
|
||||
}
|
||||
}
|
||||
|
||||
void WriteU64(std::uint8_t *destination, const std::uint64_t value)
|
||||
{
|
||||
for (std::size_t index = 0; index < sizeof(value); ++index)
|
||||
{
|
||||
destination[index] = static_cast<std::uint8_t>(value >> (index * 8));
|
||||
}
|
||||
}
|
||||
|
||||
std::uint32_t ReadU32(const std::uint8_t *source)
|
||||
{
|
||||
std::uint32_t value = 0;
|
||||
for (std::size_t index = 0; index < sizeof(value); ++index)
|
||||
{
|
||||
value |= static_cast<std::uint32_t>(source[index]) << (index * 8);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::uint64_t ReadU64(const std::uint8_t *source)
|
||||
{
|
||||
std::uint64_t value = 0;
|
||||
for (std::size_t index = 0; index < sizeof(value); ++index)
|
||||
{
|
||||
value |= static_cast<std::uint64_t>(source[index]) << (index * 8);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
void WriteF32(std::uint8_t *destination, const float value)
|
||||
{
|
||||
WriteU32(destination, std::bit_cast<std::uint32_t>(value));
|
||||
}
|
||||
|
||||
float ReadF32(const std::uint8_t *source)
|
||||
{
|
||||
return std::bit_cast<float>(ReadU32(source));
|
||||
}
|
||||
|
||||
bool SequenceAfter(const std::uint32_t next, const std::uint32_t previous)
|
||||
{
|
||||
const std::uint32_t distance = next - previous;
|
||||
return distance != 0 && distance <= UINT32_MAX / 2;
|
||||
}
|
||||
|
||||
class UdpSocket
|
||||
{
|
||||
public:
|
||||
UdpSocket() = default;
|
||||
~UdpSocket()
|
||||
{
|
||||
if (this->descriptor_ >= 0)
|
||||
{
|
||||
close(this->descriptor_);
|
||||
}
|
||||
}
|
||||
|
||||
UdpSocket(const UdpSocket &) = delete;
|
||||
UdpSocket &operator=(const UdpSocket &) = delete;
|
||||
|
||||
bool BindLoopback(const std::uint16_t port)
|
||||
{
|
||||
this->descriptor_ = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (this->descriptor_ < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int flags = fcntl(this->descriptor_, F_GETFL, 0);
|
||||
if (flags < 0 || fcntl(this->descriptor_, F_SETFL, flags | O_NONBLOCK) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons(port);
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
return bind(
|
||||
this->descriptor_,
|
||||
reinterpret_cast<const sockaddr *>(&address),
|
||||
sizeof(address)) == 0;
|
||||
}
|
||||
|
||||
bool SendToLoopback(
|
||||
const std::uint16_t port, const std::uint8_t *data, const std::size_t size) const
|
||||
{
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons(port);
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
return sendto(
|
||||
this->descriptor_, data, size, MSG_DONTWAIT,
|
||||
reinterpret_cast<const sockaddr *>(&address), sizeof(address)) ==
|
||||
static_cast<ssize_t>(size);
|
||||
}
|
||||
|
||||
ssize_t Receive(std::uint8_t *data, const std::size_t size) const
|
||||
{
|
||||
sockaddr_in sender{};
|
||||
socklen_t sender_size = sizeof(sender);
|
||||
const ssize_t received = recvfrom(
|
||||
this->descriptor_, data, size, MSG_DONTWAIT,
|
||||
reinterpret_cast<sockaddr *>(&sender), &sender_size);
|
||||
if (received < 0 || sender.sin_addr.s_addr != htonl(INADDR_LOOPBACK))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
return received;
|
||||
}
|
||||
|
||||
private:
|
||||
int descriptor_{-1};
|
||||
};
|
||||
|
||||
struct OutputCommand
|
||||
{
|
||||
std::uint64_t controller_time_usec{};
|
||||
std::uint32_t control_sequence{};
|
||||
bool permitted{};
|
||||
std::array<float, kQuadMotorCount> motors{};
|
||||
};
|
||||
|
||||
bool DecodeOutput(const std::array<std::uint8_t, kOutputPacketSize> &packet,
|
||||
OutputCommand &output)
|
||||
{
|
||||
if (!std::equal(kOutputMagic.begin(), kOutputMagic.end(), packet.begin()) ||
|
||||
packet[4] != kPacketVersion || packet[5] != 0 || packet[6] != 0 || packet[7] != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
output.controller_time_usec = ReadU64(packet.data() + 8);
|
||||
output.control_sequence = ReadU32(packet.data() + 16);
|
||||
if (packet[20] > 1 || packet[21] != 0 || packet[22] != 0 || packet[23] != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
output.permitted = packet[20] == 1;
|
||||
for (std::size_t index = 0; index < output.motors.size(); ++index)
|
||||
{
|
||||
output.motors[index] = ReadF32(packet.data() + 24 + index * sizeof(float));
|
||||
if (!IsFiniteFraction(output.motors[index]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return output.permitted || std::all_of(output.motors.begin(), output.motors.end(), IsZero);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class Quad final : public gz::sim::System,
|
||||
public gz::sim::ISystemConfigure,
|
||||
public gz::sim::ISystemPreUpdate,
|
||||
public gz::sim::ISystemPostUpdate,
|
||||
public gz::sim::ISystemReset
|
||||
{
|
||||
public:
|
||||
Quad() : data_(std::make_unique<Data>()) {}
|
||||
|
||||
void Configure(const gz::sim::Entity &entity,
|
||||
const std::shared_ptr<const sdf::Element> &sdf,
|
||||
gz::sim::EntityComponentManager &ecm,
|
||||
gz::sim::EventManager &) override;
|
||||
|
||||
void PreUpdate(const gz::sim::UpdateInfo &info,
|
||||
gz::sim::EntityComponentManager &ecm) override;
|
||||
|
||||
void PostUpdate(const gz::sim::UpdateInfo &info,
|
||||
const gz::sim::EntityComponentManager &) override;
|
||||
|
||||
void Reset(const gz::sim::UpdateInfo &,
|
||||
gz::sim::EntityComponentManager &) override;
|
||||
|
||||
private:
|
||||
struct Data
|
||||
{
|
||||
void OnImu(const gz::msgs::IMU &message)
|
||||
{
|
||||
std::lock_guard lock(this->imu_mutex);
|
||||
this->latest_imu = message;
|
||||
++this->imu_generation;
|
||||
if (!this->received_first_imu)
|
||||
{
|
||||
gzmsg << "Kaptan SITL quad received its first Gazebo IMU sample.\n";
|
||||
this->received_first_imu = true;
|
||||
}
|
||||
}
|
||||
|
||||
gz::sim::Link body;
|
||||
gz::transport::Node node;
|
||||
UdpSocket socket;
|
||||
std::uint16_t sensor_port{};
|
||||
std::uint16_t actuator_port{};
|
||||
std::uint16_t truth_port{};
|
||||
std::uint16_t user_clock_port{};
|
||||
std::chrono::microseconds actuator_timeout{};
|
||||
double maximum_motor_thrust_newtons{};
|
||||
double motor_time_constant_seconds{};
|
||||
std::normal_distribution<double> local_position_noise{0.0, 0.0};
|
||||
std::normal_distribution<double> local_velocity_noise{0.0, 0.0};
|
||||
std::mt19937 local_state_random{};
|
||||
double reaction_torque_per_newton_metre{};
|
||||
std::optional<gz::math::Vector3d> launch_origin_enu;
|
||||
std::optional<gz::math::Quaterniond> launch_attitude_enu;
|
||||
std::mutex imu_mutex;
|
||||
gz::msgs::IMU latest_imu;
|
||||
std::uint64_t imu_generation{};
|
||||
std::uint64_t published_imu_generation{};
|
||||
bool received_first_imu{};
|
||||
std::optional<OutputCommand> output;
|
||||
std::array<double, kQuadMotorCount> applied_motor_fractions{};
|
||||
std::optional<std::pair<std::uint64_t, std::uint32_t>> previous_output_order;
|
||||
// Actuator freshness belongs to the simulated vehicle timeline, never
|
||||
// host scheduling time. That keeps timeout behavior deterministic when
|
||||
// Gazebo is paused, stepped, or runs at a non-real-time factor.
|
||||
std::optional<std::chrono::microseconds> last_output_sim_time;
|
||||
};
|
||||
|
||||
std::unique_ptr<Data> data_;
|
||||
};
|
||||
|
||||
void Quad::Configure(const gz::sim::Entity &entity,
|
||||
const std::shared_ptr<const sdf::Element> &sdf,
|
||||
gz::sim::EntityComponentManager &ecm,
|
||||
gz::sim::EventManager &)
|
||||
{
|
||||
const gz::sim::Model model(entity);
|
||||
if (!model.Valid(ecm))
|
||||
{
|
||||
gzerr << "Kaptan SITL quad must be attached to a model.\n";
|
||||
return;
|
||||
}
|
||||
const auto body_entity = model.LinkByName(ecm, "base_link");
|
||||
if (body_entity == gz::sim::kNullEntity)
|
||||
{
|
||||
gzerr << "Kaptan SITL quad requires a base_link.\n";
|
||||
return;
|
||||
}
|
||||
this->data_->body = gz::sim::Link(body_entity);
|
||||
this->data_->body.EnableVelocityChecks(ecm);
|
||||
|
||||
const std::string imu_topic = sdf->Get<std::string>("imu_topic", "").first;
|
||||
const unsigned int sensor_port = sdf->Get<unsigned int>("sensor_udp_port", 0U).first;
|
||||
const unsigned int actuator_port =
|
||||
sdf->Get<unsigned int>("actuator_udp_port", 0U).first;
|
||||
const unsigned int truth_port = sdf->Get<unsigned int>("truth_udp_port", 0U).first;
|
||||
const unsigned int user_clock_port =
|
||||
sdf->Get<unsigned int>("user_clock_udp_port", 0U).first;
|
||||
const std::uint64_t timeout_usec =
|
||||
sdf->Get<std::uint64_t>("actuator_timeout_usec", 0).first;
|
||||
this->data_->maximum_motor_thrust_newtons =
|
||||
sdf->Get<double>("maximum_motor_thrust_newtons", 0.0).first;
|
||||
this->data_->motor_time_constant_seconds =
|
||||
sdf->Get<double>("motor_time_constant_seconds", 0.0).first;
|
||||
const double local_position_noise_stddev =
|
||||
sdf->Get<double>("local_position_noise_stddev_m", -1.0).first;
|
||||
const double local_velocity_noise_stddev =
|
||||
sdf->Get<double>("local_velocity_noise_stddev_mps", -1.0).first;
|
||||
const unsigned int local_state_noise_seed =
|
||||
sdf->Get<unsigned int>("local_state_noise_seed", 0U).first;
|
||||
this->data_->reaction_torque_per_newton_metre =
|
||||
sdf->Get<double>("reaction_torque_per_newton_metre", 0.0).first;
|
||||
if (imu_topic.empty() || sensor_port == 0 || actuator_port == 0 || truth_port == 0 ||
|
||||
user_clock_port == 0 ||
|
||||
sensor_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
actuator_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
truth_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
user_clock_port > std::numeric_limits<std::uint16_t>::max() ||
|
||||
timeout_usec == 0 || !std::isfinite(this->data_->maximum_motor_thrust_newtons) ||
|
||||
this->data_->maximum_motor_thrust_newtons <= 0.0 ||
|
||||
!std::isfinite(this->data_->motor_time_constant_seconds) ||
|
||||
this->data_->motor_time_constant_seconds <= 0.0 ||
|
||||
!std::isfinite(local_position_noise_stddev) || local_position_noise_stddev < 0.0 ||
|
||||
!std::isfinite(local_velocity_noise_stddev) || local_velocity_noise_stddev < 0.0 ||
|
||||
!std::isfinite(this->data_->reaction_torque_per_newton_metre) ||
|
||||
this->data_->reaction_torque_per_newton_metre < 0.0)
|
||||
{
|
||||
gzerr << "Kaptan SITL quad has an invalid static transport or vehicle configuration.\n";
|
||||
return;
|
||||
}
|
||||
this->data_->sensor_port = static_cast<std::uint16_t>(sensor_port);
|
||||
this->data_->actuator_port = static_cast<std::uint16_t>(actuator_port);
|
||||
this->data_->truth_port = static_cast<std::uint16_t>(truth_port);
|
||||
this->data_->user_clock_port = static_cast<std::uint16_t>(user_clock_port);
|
||||
this->data_->actuator_timeout = std::chrono::microseconds(timeout_usec);
|
||||
this->data_->local_position_noise =
|
||||
std::normal_distribution<double>(0.0, local_position_noise_stddev);
|
||||
this->data_->local_velocity_noise =
|
||||
std::normal_distribution<double>(0.0, local_velocity_noise_stddev);
|
||||
this->data_->local_state_random.seed(local_state_noise_seed);
|
||||
if (!this->data_->socket.BindLoopback(this->data_->actuator_port))
|
||||
{
|
||||
gzerr << "Kaptan SITL quad could not bind its loopback actuator socket.\n";
|
||||
return;
|
||||
}
|
||||
if (!this->data_->node.Subscribe(imu_topic, &Data::OnImu, this->data_.get()))
|
||||
{
|
||||
gzerr << "Kaptan SITL quad could not subscribe to its configured IMU topic.\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
gzmsg << "Kaptan SITL quad subscribed to Gazebo IMU topic [" << imu_topic << "].\n";
|
||||
}
|
||||
}
|
||||
|
||||
void Quad::PreUpdate(const gz::sim::UpdateInfo &info,
|
||||
gz::sim::EntityComponentManager &ecm)
|
||||
{
|
||||
if (info.paused || info.simTime.count() < 0 || !this->data_->body.Valid(ecm))
|
||||
{
|
||||
return;
|
||||
}
|
||||
const auto simulation_time =
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(info.simTime);
|
||||
for (std::size_t attempt = 0; attempt < kMaximumPacketsPerStep; ++attempt)
|
||||
{
|
||||
std::array<std::uint8_t, kOutputPacketSize> packet{};
|
||||
if (this->data_->socket.Receive(packet.data(), packet.size()) !=
|
||||
static_cast<ssize_t>(packet.size()))
|
||||
{
|
||||
break;
|
||||
}
|
||||
OutputCommand command{};
|
||||
if (!DecodeOutput(packet, command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!command.permitted)
|
||||
{
|
||||
this->data_->output = command;
|
||||
continue;
|
||||
}
|
||||
if (this->data_->previous_output_order.has_value() &&
|
||||
(command.controller_time_usec <= this->data_->previous_output_order->first ||
|
||||
!SequenceAfter(command.control_sequence,
|
||||
this->data_->previous_output_order->second)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
this->data_->previous_output_order =
|
||||
std::pair{command.controller_time_usec, command.control_sequence};
|
||||
this->data_->last_output_sim_time = simulation_time;
|
||||
this->data_->output = command;
|
||||
}
|
||||
|
||||
const bool command_is_fresh =
|
||||
this->data_->output.has_value() && this->data_->output->permitted &&
|
||||
this->data_->last_output_sim_time.has_value() &&
|
||||
simulation_time >= *this->data_->last_output_sim_time &&
|
||||
simulation_time - *this->data_->last_output_sim_time <= this->data_->actuator_timeout;
|
||||
const auto pose = this->data_->body.WorldPose(ecm);
|
||||
if (!pose.has_value())
|
||||
{
|
||||
return;
|
||||
}
|
||||
const double dt_seconds = std::chrono::duration<double>(info.dt).count();
|
||||
if (!std::isfinite(dt_seconds) || dt_seconds <= 0.0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const double alpha = 1.0 - std::exp(-dt_seconds / this->data_->motor_time_constant_seconds);
|
||||
const auto desired_motors = command_is_fresh
|
||||
? this->data_->output->motors
|
||||
: std::array<float, kQuadMotorCount>{};
|
||||
const gz::math::Vector3d thrust_axis_world = pose->Rot().RotateVector({0.0, 0.0, 1.0});
|
||||
for (std::size_t index = 0; index < kQuadMotorCount; ++index)
|
||||
{
|
||||
this->data_->applied_motor_fractions[index] +=
|
||||
alpha * (static_cast<double>(desired_motors[index]) -
|
||||
this->data_->applied_motor_fractions[index]);
|
||||
const double thrust =
|
||||
this->data_->applied_motor_fractions[index] *
|
||||
this->data_->maximum_motor_thrust_newtons;
|
||||
this->data_->body.AddWorldWrench(
|
||||
ecm, thrust_axis_world * thrust,
|
||||
thrust_axis_world *
|
||||
(kQuadXGeometry[index].reaction_torque_sign * thrust *
|
||||
this->data_->reaction_torque_per_newton_metre),
|
||||
kQuadXGeometry[index].offset_flu);
|
||||
}
|
||||
}
|
||||
|
||||
void Quad::PostUpdate(const gz::sim::UpdateInfo &info,
|
||||
const gz::sim::EntityComponentManager &_ecm)
|
||||
{
|
||||
if (info.paused || info.simTime.count() < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// This is deliberately emitted for every unpaused Gazebo step, even when
|
||||
// an IMU sample is absent. The host runtime uses it as its sole freshness
|
||||
// and lease time base; host scheduling cannot advance safety state.
|
||||
const auto send_clock = [this, &info]() {
|
||||
std::array<std::uint8_t, kClockPacketSize> clock_packet{};
|
||||
std::copy(kClockMagic.begin(), kClockMagic.end(), clock_packet.begin());
|
||||
clock_packet[4] = kPacketVersion;
|
||||
WriteU64(clock_packet.data() + 8, static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
info.simTime)
|
||||
.count()));
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->sensor_port, clock_packet.data(), clock_packet.size());
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->user_clock_port, clock_packet.data(), clock_packet.size());
|
||||
};
|
||||
gz::msgs::IMU imu;
|
||||
{
|
||||
std::lock_guard lock(this->data_->imu_mutex);
|
||||
if (this->data_->imu_generation == this->data_->published_imu_generation)
|
||||
{
|
||||
send_clock();
|
||||
return;
|
||||
}
|
||||
imu = this->data_->latest_imu;
|
||||
this->data_->published_imu_generation = this->data_->imu_generation;
|
||||
}
|
||||
|
||||
// Gazebo's standard body sensor convention is FLU. Kaptan's is FRD, so the
|
||||
// single explicit boundary conversion is x, -y, -z for both vectors.
|
||||
std::array<std::uint8_t, kImuPacketSize> packet{};
|
||||
std::copy(kImuMagic.begin(), kImuMagic.end(), packet.begin());
|
||||
packet[4] = kPacketVersion;
|
||||
WriteU64(packet.data() + 8, static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
info.simTime)
|
||||
.count()));
|
||||
WriteU32(packet.data() + 16, static_cast<std::uint32_t>(this->data_->imu_generation));
|
||||
const std::array<float, 6> frd{
|
||||
static_cast<float>(imu.linear_acceleration().x()),
|
||||
-static_cast<float>(imu.linear_acceleration().y()),
|
||||
-static_cast<float>(imu.linear_acceleration().z()),
|
||||
static_cast<float>(imu.angular_velocity().x()),
|
||||
-static_cast<float>(imu.angular_velocity().y()),
|
||||
-static_cast<float>(imu.angular_velocity().z()),
|
||||
};
|
||||
if (!std::all_of(frd.begin(), frd.end(), [](const float value) { return std::isfinite(value); }))
|
||||
{
|
||||
send_clock();
|
||||
return;
|
||||
}
|
||||
for (std::size_t index = 0; index < frd.size(); ++index)
|
||||
{
|
||||
WriteF32(packet.data() + 20 + index * sizeof(float), frd[index]);
|
||||
}
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->sensor_port, packet.data(), packet.size());
|
||||
|
||||
const auto pose = this->data_->body.WorldPose(_ecm);
|
||||
const auto velocity = this->data_->body.WorldLinearVelocity(_ecm);
|
||||
if (!pose.has_value() || !velocity.has_value())
|
||||
{
|
||||
send_clock();
|
||||
return;
|
||||
}
|
||||
if (!this->data_->launch_origin_enu.has_value())
|
||||
{
|
||||
this->data_->launch_origin_enu = pose->Pos();
|
||||
this->data_->launch_attitude_enu = pose->Rot();
|
||||
}
|
||||
// This fixed test vehicle is yawed so ENU world +Y/+X/-Z are respectively
|
||||
// its launch-local NED north/east/down axes. The conversion happens once at
|
||||
// the Gazebo boundary; all flight code receives only explicit NED SI data.
|
||||
const auto &origin = *this->data_->launch_origin_enu;
|
||||
const std::array<float, 6> local_ned{
|
||||
static_cast<float>(pose->Pos().Y() - origin.Y() +
|
||||
this->data_->local_position_noise(this->data_->local_state_random)),
|
||||
static_cast<float>(pose->Pos().X() - origin.X() +
|
||||
this->data_->local_position_noise(this->data_->local_state_random)),
|
||||
static_cast<float>(origin.Z() - pose->Pos().Z() +
|
||||
this->data_->local_position_noise(this->data_->local_state_random)),
|
||||
static_cast<float>(velocity->Y() +
|
||||
this->data_->local_velocity_noise(this->data_->local_state_random)),
|
||||
static_cast<float>(velocity->X() +
|
||||
this->data_->local_velocity_noise(this->data_->local_state_random)),
|
||||
static_cast<float>(-velocity->Z() +
|
||||
this->data_->local_velocity_noise(this->data_->local_state_random)),
|
||||
};
|
||||
if (!std::all_of(
|
||||
local_ned.begin(), local_ned.end(),
|
||||
[](const float value) { return std::isfinite(value); }))
|
||||
{
|
||||
send_clock();
|
||||
return;
|
||||
}
|
||||
std::array<std::uint8_t, kImuPacketSize> state_packet{};
|
||||
std::copy(kLocalStateMagic.begin(), kLocalStateMagic.end(), state_packet.begin());
|
||||
state_packet[4] = kPacketVersion;
|
||||
WriteU64(state_packet.data() + 8, static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
info.simTime)
|
||||
.count()));
|
||||
WriteU32(state_packet.data() + 16, static_cast<std::uint32_t>(this->data_->imu_generation));
|
||||
for (std::size_t index = 0; index < local_ned.size(); ++index)
|
||||
{
|
||||
WriteF32(state_packet.data() + 20 + index * sizeof(float), local_ned[index]);
|
||||
}
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->sensor_port, state_packet.data(), state_packet.size());
|
||||
|
||||
// This packet is intentionally sent to a separate test-only port. The
|
||||
// controller never binds that port, so these noise-free values cannot form a
|
||||
// parallel control path or mask estimator behavior. The quaternion is the
|
||||
// body attitude relative to launch; its rotation magnitude is frame
|
||||
// invariant, which permits the test client to verify attitude without
|
||||
// duplicating the ENU/FLU-to-NED/FRD conversion path.
|
||||
const auto launch_attitude = *this->data_->launch_attitude_enu;
|
||||
const auto relative_attitude = launch_attitude.Inverse() * pose->Rot();
|
||||
const std::array<float, 14> truth_ned{
|
||||
static_cast<float>(pose->Pos().Y() - origin.Y()),
|
||||
static_cast<float>(pose->Pos().X() - origin.X()),
|
||||
static_cast<float>(origin.Z() - pose->Pos().Z()),
|
||||
static_cast<float>(velocity->Y()),
|
||||
static_cast<float>(velocity->X()),
|
||||
static_cast<float>(-velocity->Z()),
|
||||
static_cast<float>(relative_attitude.W()),
|
||||
static_cast<float>(relative_attitude.X()),
|
||||
static_cast<float>(relative_attitude.Y()),
|
||||
static_cast<float>(relative_attitude.Z()),
|
||||
static_cast<float>(this->data_->applied_motor_fractions[0]),
|
||||
static_cast<float>(this->data_->applied_motor_fractions[1]),
|
||||
static_cast<float>(this->data_->applied_motor_fractions[2]),
|
||||
static_cast<float>(this->data_->applied_motor_fractions[3]),
|
||||
};
|
||||
if (!std::all_of(
|
||||
truth_ned.begin(), truth_ned.end(),
|
||||
[](const float value) { return std::isfinite(value); }))
|
||||
{
|
||||
send_clock();
|
||||
return;
|
||||
}
|
||||
std::array<std::uint8_t, kTruthPacketSize> truth_packet{};
|
||||
std::copy(kTruthMagic.begin(), kTruthMagic.end(), truth_packet.begin());
|
||||
truth_packet[4] = kPacketVersion;
|
||||
WriteU64(truth_packet.data() + 8, static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
info.simTime)
|
||||
.count()));
|
||||
WriteU32(truth_packet.data() + 16, static_cast<std::uint32_t>(this->data_->imu_generation));
|
||||
for (std::size_t index = 0; index < truth_ned.size(); ++index)
|
||||
{
|
||||
WriteF32(truth_packet.data() + 20 + index * sizeof(float), truth_ned[index]);
|
||||
}
|
||||
(void)this->data_->socket.SendToLoopback(
|
||||
this->data_->truth_port, truth_packet.data(), truth_packet.size());
|
||||
send_clock();
|
||||
}
|
||||
|
||||
void Quad::Reset(const gz::sim::UpdateInfo &,
|
||||
gz::sim::EntityComponentManager &)
|
||||
{
|
||||
this->data_->output.reset();
|
||||
this->data_->applied_motor_fractions = {};
|
||||
this->data_->previous_output_order.reset();
|
||||
this->data_->last_output_sim_time.reset();
|
||||
this->data_->launch_origin_enu.reset();
|
||||
this->data_->launch_attitude_enu.reset();
|
||||
std::lock_guard lock(this->data_->imu_mutex);
|
||||
this->data_->published_imu_generation = this->data_->imu_generation;
|
||||
}
|
||||
|
||||
} // namespace kaptan::sitl
|
||||
|
||||
GZ_ADD_PLUGIN(kaptan::sitl::Quad,
|
||||
gz::sim::System,
|
||||
kaptan::sitl::Quad::ISystemConfigure,
|
||||
kaptan::sitl::Quad::ISystemPreUpdate,
|
||||
kaptan::sitl::Quad::ISystemPostUpdate,
|
||||
kaptan::sitl::Quad::ISystemReset)
|
||||
GZ_ADD_PLUGIN_ALIAS(kaptan::sitl::Quad, "kaptan::sitl::Quad")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,363 +0,0 @@
|
||||
//! Fixed-size local UDP records for Kaptan's host-only simulator harness.
|
||||
//!
|
||||
//! This transport is deliberately not MAVLink and not part of Kaptan's flight
|
||||
//! dialect. It is confined to one host network namespace and is versioned so
|
||||
//! malformed simulator traffic never becomes estimator or motor authority.
|
||||
|
||||
use kaptan_err_reportable::KaptanError;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
pub const SENSOR_PORT: u16 = 14_660;
|
||||
pub const ACTUATOR_PORT: u16 = 14_661;
|
||||
pub const CONTROL_PORT: u16 = 14_662;
|
||||
pub const VERSION: u8 = 1;
|
||||
pub const IMU_MAGIC: [u8; 4] = *b"KSI1";
|
||||
pub const LOCAL_STATE_MAGIC: [u8; 4] = *b"KSV1";
|
||||
/// Gazebo simulation time, sent once for every unpaused simulation step.
|
||||
/// This is the sole time base for host-SITL freshness and command leases.
|
||||
pub const CLOCK_MAGIC: [u8; 4] = *b"KSC1";
|
||||
pub const OUTPUT_MAGIC: [u8; 4] = *b"KSO1";
|
||||
pub const GOAL_MAGIC: [u8; 4] = *b"KSG1";
|
||||
/// A host-SITL-only request to descend to a declared local landing point and
|
||||
/// revoke motor authority once the landing point has settled.
|
||||
pub const LAND_MAGIC: [u8; 4] = *b"KSD1";
|
||||
pub const GOAL_KEEPALIVE_MAGIC: [u8; 4] = *b"KSK1";
|
||||
pub const STATUS_QUERY_MAGIC: [u8; 4] = *b"KSQ1";
|
||||
pub const STATUS_MAGIC: [u8; 4] = *b"KSS1";
|
||||
pub const GOAL_STATUS_MAGIC: [u8; 4] = *b"KSL1";
|
||||
|
||||
pub const SENSOR_PACKET_SIZE: usize = 44;
|
||||
pub const CLOCK_PACKET_SIZE: usize = 16;
|
||||
pub const OUTPUT_PACKET_SIZE: usize = 40;
|
||||
pub const GOAL_PACKET_SIZE: usize = 24;
|
||||
pub const GOAL_KEEPALIVE_PACKET_SIZE: usize = 12;
|
||||
pub const STATUS_QUERY_PACKET_SIZE: usize = 8;
|
||||
pub const STATUS_PACKET_SIZE: usize = 20;
|
||||
pub const GOAL_STATUS_PACKET_SIZE: usize = 16;
|
||||
|
||||
pub const STATUS_ESTIMATOR_READY: u16 = 1 << 0;
|
||||
pub const STATUS_IMU_FRESH: u16 = 1 << 1;
|
||||
pub const STATUS_LOCAL_AIDING_FRESH: u16 = 1 << 2;
|
||||
pub const STATUS_OUTPUT_PERMITTED: u16 = 1 << 3;
|
||||
pub const STATUS_ACTUATOR_TRANSPORT: u16 = 1 << 4;
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||
pub enum PacketError {
|
||||
#[kaptan_error(id = 12001)]
|
||||
Length,
|
||||
#[kaptan_error(id = 12002)]
|
||||
Version,
|
||||
#[kaptan_error(id = 12003)]
|
||||
NonFinite,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ImuPacket {
|
||||
pub time_microseconds: u64,
|
||||
pub sequence: u32,
|
||||
pub acceleration_frd_mps2: [f32; 3],
|
||||
pub angular_rate_frd_rad_s: [f32; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LocalStatePacket {
|
||||
pub time_microseconds: u64,
|
||||
pub sequence: u32,
|
||||
pub position_ned_metres: [f32; 3],
|
||||
pub velocity_ned_mps: [f32; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ClockPacket {
|
||||
pub time_microseconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GoalPacket {
|
||||
pub goal_id: u32,
|
||||
pub north_metres: f32,
|
||||
pub east_metres: f32,
|
||||
pub up_metres: f32,
|
||||
}
|
||||
|
||||
/// Renews the command lease for one accepted goal without modifying it.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GoalKeepalivePacket {
|
||||
pub goal_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[repr(u8)]
|
||||
pub enum GoalLifecycle {
|
||||
Rejected = 0,
|
||||
Active = 1,
|
||||
Settled = 2,
|
||||
Landed = 3,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GoalStatusPacket {
|
||||
pub goal_id: u32,
|
||||
pub lifecycle: GoalLifecycle,
|
||||
}
|
||||
|
||||
pub fn decode_imu(bytes: &[u8]) -> Result<ImuPacket, PacketError> {
|
||||
let (time_microseconds, sequence, values) = decode_sensor(bytes, IMU_MAGIC)?;
|
||||
Ok(ImuPacket {
|
||||
time_microseconds,
|
||||
sequence,
|
||||
acceleration_frd_mps2: [values[0], values[1], values[2]],
|
||||
angular_rate_frd_rad_s: [values[3], values[4], values[5]],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_local_state(bytes: &[u8]) -> Result<LocalStatePacket, PacketError> {
|
||||
let (time_microseconds, sequence, values) = decode_sensor(bytes, LOCAL_STATE_MAGIC)?;
|
||||
Ok(LocalStatePacket {
|
||||
time_microseconds,
|
||||
sequence,
|
||||
position_ned_metres: [values[0], values[1], values[2]],
|
||||
velocity_ned_mps: [values[3], values[4], values[5]],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_clock(bytes: &[u8]) -> Result<ClockPacket, PacketError> {
|
||||
if bytes.len() != CLOCK_PACKET_SIZE {
|
||||
return Err(PacketError::Length);
|
||||
}
|
||||
if bytes[0..4] != CLOCK_MAGIC || bytes[4] != VERSION || bytes[5..8] != [0; 3] {
|
||||
return Err(PacketError::Version);
|
||||
}
|
||||
Ok(ClockPacket {
|
||||
time_microseconds: read_u64(&bytes[8..16]).ok_or(PacketError::Length)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_goal(bytes: &[u8]) -> Result<GoalPacket, PacketError> {
|
||||
decode_goal_with_magic(bytes, GOAL_MAGIC, false)
|
||||
}
|
||||
|
||||
/// Decodes a host-SITL landing request. Landing always targets the declared
|
||||
/// local ground plane (`up == 0`), rather than silently treating an arbitrary
|
||||
/// move goal as a landing operation.
|
||||
pub fn decode_landing(bytes: &[u8]) -> Result<GoalPacket, PacketError> {
|
||||
decode_goal_with_magic(bytes, LAND_MAGIC, true)
|
||||
}
|
||||
|
||||
fn decode_goal_with_magic(
|
||||
bytes: &[u8],
|
||||
magic: [u8; 4],
|
||||
require_ground_plane: bool,
|
||||
) -> Result<GoalPacket, PacketError> {
|
||||
if bytes.len() != GOAL_PACKET_SIZE {
|
||||
return Err(PacketError::Length);
|
||||
}
|
||||
if bytes[0..4] != magic || bytes[4] != VERSION || bytes[5..8] != [0; 3] {
|
||||
return Err(PacketError::Version);
|
||||
}
|
||||
let goal = GoalPacket {
|
||||
goal_id: read_u32(&bytes[8..12]).ok_or(PacketError::Length)?,
|
||||
north_metres: read_f32(&bytes[12..16]).ok_or(PacketError::Length)?,
|
||||
east_metres: read_f32(&bytes[16..20]).ok_or(PacketError::Length)?,
|
||||
up_metres: read_f32(&bytes[20..24]).ok_or(PacketError::Length)?,
|
||||
};
|
||||
if goal.goal_id == 0
|
||||
|| !goal.north_metres.is_finite()
|
||||
|| !goal.east_metres.is_finite()
|
||||
|| !goal.up_metres.is_finite()
|
||||
|| (require_ground_plane && goal.up_metres != 0.0)
|
||||
{
|
||||
return Err(PacketError::NonFinite);
|
||||
}
|
||||
Ok(goal)
|
||||
}
|
||||
|
||||
pub fn decode_goal_keepalive(bytes: &[u8]) -> Result<GoalKeepalivePacket, PacketError> {
|
||||
if bytes.len() != GOAL_KEEPALIVE_PACKET_SIZE {
|
||||
return Err(PacketError::Length);
|
||||
}
|
||||
if bytes[0..4] != GOAL_KEEPALIVE_MAGIC || bytes[4] != VERSION || bytes[5..8] != [0; 3] {
|
||||
return Err(PacketError::Version);
|
||||
}
|
||||
let goal_id = read_u32(&bytes[8..12]).ok_or(PacketError::Length)?;
|
||||
if goal_id == 0 {
|
||||
return Err(PacketError::NonFinite);
|
||||
}
|
||||
Ok(GoalKeepalivePacket { goal_id })
|
||||
}
|
||||
|
||||
pub fn is_status_query(bytes: &[u8]) -> bool {
|
||||
bytes.len() == STATUS_QUERY_PACKET_SIZE
|
||||
&& bytes[0..4] == STATUS_QUERY_MAGIC
|
||||
&& bytes[4] == VERSION
|
||||
&& bytes[5..8] == [0; 3]
|
||||
}
|
||||
|
||||
pub fn encode_output(
|
||||
time_microseconds: u64,
|
||||
sequence: u32,
|
||||
permitted: bool,
|
||||
motors: [f32; 4],
|
||||
) -> Result<[u8; OUTPUT_PACKET_SIZE], PacketError> {
|
||||
if motors
|
||||
.iter()
|
||||
.any(|motor| !motor.is_finite() || !(0.0..=1.0).contains(motor))
|
||||
|| (!permitted && motors.iter().any(|motor| *motor != 0.0))
|
||||
{
|
||||
return Err(PacketError::NonFinite);
|
||||
}
|
||||
let mut bytes = [0_u8; OUTPUT_PACKET_SIZE];
|
||||
bytes[0..4].copy_from_slice(&OUTPUT_MAGIC);
|
||||
bytes[4] = VERSION;
|
||||
bytes[8..16].copy_from_slice(&time_microseconds.to_le_bytes());
|
||||
bytes[16..20].copy_from_slice(&sequence.to_le_bytes());
|
||||
bytes[20] = u8::from(permitted);
|
||||
for (index, motor) in motors.iter().enumerate() {
|
||||
bytes[24 + index * 4..28 + index * 4].copy_from_slice(&motor.to_le_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn encode_status(time_microseconds: u64, flags: u16) -> [u8; STATUS_PACKET_SIZE] {
|
||||
let mut bytes = [0_u8; STATUS_PACKET_SIZE];
|
||||
bytes[0..4].copy_from_slice(&STATUS_MAGIC);
|
||||
bytes[4] = VERSION;
|
||||
bytes[8..16].copy_from_slice(&time_microseconds.to_le_bytes());
|
||||
bytes[16..18].copy_from_slice(&flags.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn encode_goal_status(status: GoalStatusPacket) -> [u8; GOAL_STATUS_PACKET_SIZE] {
|
||||
let mut bytes = [0_u8; GOAL_STATUS_PACKET_SIZE];
|
||||
bytes[0..4].copy_from_slice(&GOAL_STATUS_MAGIC);
|
||||
bytes[4] = VERSION;
|
||||
bytes[8..12].copy_from_slice(&status.goal_id.to_le_bytes());
|
||||
bytes[12] = status.lifecycle as u8;
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn localhost(sender: SocketAddr) -> bool {
|
||||
sender.ip().is_loopback()
|
||||
}
|
||||
|
||||
fn decode_sensor(bytes: &[u8], magic: [u8; 4]) -> Result<(u64, u32, [f32; 6]), PacketError> {
|
||||
if bytes.len() != SENSOR_PACKET_SIZE {
|
||||
return Err(PacketError::Length);
|
||||
}
|
||||
if bytes[0..4] != magic || bytes[4] != VERSION || bytes[5..8] != [0; 3] {
|
||||
return Err(PacketError::Version);
|
||||
}
|
||||
let mut values = [0.0; 6];
|
||||
for (index, value) in values.iter_mut().enumerate() {
|
||||
*value = read_f32(&bytes[20 + index * 4..24 + index * 4]).ok_or(PacketError::Length)?;
|
||||
}
|
||||
if values.iter().any(|value| !value.is_finite()) {
|
||||
return Err(PacketError::NonFinite);
|
||||
}
|
||||
Ok((
|
||||
read_u64(&bytes[8..16]).ok_or(PacketError::Length)?,
|
||||
read_u32(&bytes[16..20]).ok_or(PacketError::Length)?,
|
||||
values,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8]) -> Option<u32> {
|
||||
Some(u32::from_le_bytes(bytes.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn read_u64(bytes: &[u8]) -> Option<u64> {
|
||||
Some(u64::from_le_bytes(bytes.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn read_f32(bytes: &[u8]) -> Option<f32> {
|
||||
Some(f32::from_le_bytes(bytes.try_into().ok()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sensor(magic: [u8; 4]) -> [u8; SENSOR_PACKET_SIZE] {
|
||||
let mut bytes = [0_u8; SENSOR_PACKET_SIZE];
|
||||
bytes[..4].copy_from_slice(&magic);
|
||||
bytes[4] = VERSION;
|
||||
bytes[8..16].copy_from_slice(&123_u64.to_le_bytes());
|
||||
bytes[16..20].copy_from_slice(&9_u32.to_le_bytes());
|
||||
for index in 0..6 {
|
||||
bytes[20 + index * 4..24 + index * 4]
|
||||
.copy_from_slice(&(index as f32 + 0.5).to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensor_records_are_finite() {
|
||||
let packet = decode_imu(&sensor(IMU_MAGIC)).unwrap();
|
||||
assert_eq!(packet.time_microseconds, 123);
|
||||
assert_eq!(packet.acceleration_frd_mps2, [0.5, 1.5, 2.5]);
|
||||
assert_eq!(packet.angular_rate_frd_rad_s, [3.5, 4.5, 5.5]);
|
||||
assert_eq!(
|
||||
decode_imu(&sensor(LOCAL_STATE_MAGIC)),
|
||||
Err(PacketError::Version)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulator_clock_has_no_host_receipt_time() {
|
||||
let mut packet = [0_u8; CLOCK_PACKET_SIZE];
|
||||
packet[..4].copy_from_slice(&CLOCK_MAGIC);
|
||||
packet[4] = VERSION;
|
||||
packet[8..16].copy_from_slice(&987_654_u64.to_le_bytes());
|
||||
assert_eq!(
|
||||
decode_clock(&packet),
|
||||
Ok(ClockPacket {
|
||||
time_microseconds: 987_654,
|
||||
})
|
||||
);
|
||||
packet[4] = VERSION + 1;
|
||||
assert_eq!(decode_clock(&packet), Err(PacketError::Version));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inhibited_output() {
|
||||
assert!(encode_output(1, 2, true, [0.1, 0.2, 0.3, 0.4]).is_ok());
|
||||
assert_eq!(
|
||||
encode_output(1, 2, false, [0.1, 0.0, 0.0, 0.0]),
|
||||
Err(PacketError::NonFinite)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_keepalive_validation() {
|
||||
let mut packet = [0_u8; GOAL_KEEPALIVE_PACKET_SIZE];
|
||||
packet[..4].copy_from_slice(&GOAL_KEEPALIVE_MAGIC);
|
||||
packet[4] = VERSION;
|
||||
packet[8..12].copy_from_slice(&7_u32.to_le_bytes());
|
||||
assert_eq!(
|
||||
decode_goal_keepalive(&packet),
|
||||
Ok(GoalKeepalivePacket { goal_id: 7 })
|
||||
);
|
||||
packet[8..12].copy_from_slice(&0_u32.to_le_bytes());
|
||||
assert_eq!(decode_goal_keepalive(&packet), Err(PacketError::NonFinite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landing_is_an_explicit_ground_plane_command() {
|
||||
let mut packet = [0_u8; GOAL_PACKET_SIZE];
|
||||
packet[..4].copy_from_slice(&LAND_MAGIC);
|
||||
packet[4] = VERSION;
|
||||
packet[8..12].copy_from_slice(&7_u32.to_le_bytes());
|
||||
packet[12..16].copy_from_slice(&1.0_f32.to_le_bytes());
|
||||
packet[16..20].copy_from_slice(&(-2.0_f32).to_le_bytes());
|
||||
assert_eq!(
|
||||
decode_landing(&packet),
|
||||
Ok(GoalPacket {
|
||||
goal_id: 7,
|
||||
north_metres: 1.0,
|
||||
east_metres: -2.0,
|
||||
up_metres: 0.0,
|
||||
})
|
||||
);
|
||||
packet[20..24].copy_from_slice(&0.1_f32.to_le_bytes());
|
||||
assert_eq!(decode_landing(&packet), Err(PacketError::NonFinite));
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Regression tests for host-SITL command-path truth assertions."""
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
def load_client_module():
|
||||
path = Path(__file__).with_name("kaptan-sim")
|
||||
loader = importlib.machinery.SourceFileLoader("kaptan_sim", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
assert spec is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TruthAcceptanceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.client = load_client_module()
|
||||
|
||||
def acceptable(self, *, motors_stopped=True, ground_plane_error=0.0, landing=True):
|
||||
return self.client.truth_sample_within_tolerance(
|
||||
position_error=0.01,
|
||||
velocity=0.01,
|
||||
attitude_error=0.01,
|
||||
motors_stopped=motors_stopped,
|
||||
ground_plane_error=ground_plane_error,
|
||||
position_tolerance=0.75,
|
||||
velocity_tolerance=0.25,
|
||||
attitude_tolerance=0.35,
|
||||
require_stopped_motors=landing,
|
||||
require_ground_plane=landing,
|
||||
)
|
||||
|
||||
def test_move_truth_contract_allows_an_armed_hover(self):
|
||||
self.assertTrue(
|
||||
self.acceptable(motors_stopped=False, ground_plane_error=1.0, landing=False)
|
||||
)
|
||||
|
||||
def test_landing_truth_contract_requires_zero_motors_and_ground_plane(self):
|
||||
self.assertFalse(self.acceptable(motors_stopped=False))
|
||||
self.assertFalse(
|
||||
self.acceptable(
|
||||
ground_plane_error=self.client.GROUND_PLANE_TOLERANCE_METRES + 0.001
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
self.acceptable(
|
||||
ground_plane_error=self.client.GROUND_PLANE_TOLERANCE_METRES
|
||||
)
|
||||
)
|
||||
|
||||
def test_land_builds_the_terminal_ground_plane_command(self):
|
||||
control_socket = object()
|
||||
clock_socket = object()
|
||||
with (
|
||||
mock.patch.object(self.client, "wait_ready") as wait_ready,
|
||||
mock.patch.object(self.client, "command_goal") as command_goal,
|
||||
):
|
||||
self.client.land(
|
||||
control_socket,
|
||||
clock_socket,
|
||||
1.0,
|
||||
-2.0,
|
||||
60.0,
|
||||
True,
|
||||
True,
|
||||
0.75,
|
||||
0.25,
|
||||
0.35,
|
||||
0.75,
|
||||
)
|
||||
|
||||
wait_ready.assert_called_once_with(control_socket, 60.0)
|
||||
arguments = command_goal.call_args.args
|
||||
self.assertEqual(arguments[0:2], (control_socket, clock_socket))
|
||||
magic, version, goal_id, north, east, up = self.client.GOAL.unpack(arguments[2])
|
||||
self.assertEqual(
|
||||
(magic, version, north, east, up), (b"KSD1", 1, 1.0, -2.0, 0.0)
|
||||
)
|
||||
self.assertEqual(arguments[3], goal_id)
|
||||
self.assertEqual(
|
||||
arguments[4:],
|
||||
(
|
||||
1.0,
|
||||
-2.0,
|
||||
0.0,
|
||||
60.0,
|
||||
True,
|
||||
True,
|
||||
0.75,
|
||||
0.25,
|
||||
0.35,
|
||||
0.75,
|
||||
self.client.GOAL_LANDED,
|
||||
True,
|
||||
"land",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural regression checks for Kaptan's deterministic Gazebo SITL world.
|
||||
|
||||
This is deliberately an SDF/model-contract check, not a dynamics substitute.
|
||||
It protects the reviewed boundary that the map is visual-only while the flat
|
||||
ground and four landing feet provide the sole touchdown geometry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import xml.etree.ElementTree as xml
|
||||
|
||||
|
||||
WORLD = Path(__file__).with_name("worlds") / "kaptan-sitl-quad.sdf.in"
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def model(root: xml.Element, name: str) -> xml.Element:
|
||||
found = root.find(f"./world/model[@name='{name}']")
|
||||
if found is None:
|
||||
fail(f"missing model {name!r}")
|
||||
return found
|
||||
|
||||
|
||||
def named(parent: xml.Element, tag: str, name: str) -> xml.Element:
|
||||
found = parent.find(f"./{tag}[@name='{name}']")
|
||||
if found is None:
|
||||
fail(f"missing {tag} {name!r}")
|
||||
return found
|
||||
|
||||
|
||||
def pose(element: xml.Element) -> tuple[float, ...]:
|
||||
text = element.findtext("pose")
|
||||
if text is None:
|
||||
fail(f"{element.tag} {element.get('name')!r} has no pose")
|
||||
values = tuple(float(value) for value in text.split())
|
||||
if len(values) != 6:
|
||||
fail(f"{element.tag} {element.get('name')!r} pose does not have six fields")
|
||||
return values
|
||||
|
||||
|
||||
def cylinder(element: xml.Element) -> tuple[float, float]:
|
||||
radius = element.findtext("geometry/cylinder/radius")
|
||||
length = element.findtext("geometry/cylinder/length")
|
||||
if radius is None or length is None:
|
||||
fail(f"{element.tag} {element.get('name')!r} is not a cylinder")
|
||||
return float(radius), float(length)
|
||||
|
||||
|
||||
def vector(element: xml.Element, path: str, size: int) -> tuple[float, ...]:
|
||||
text = element.findtext(path)
|
||||
if text is None:
|
||||
fail(f"{element.tag} {element.get('name')!r} has no {path!r}")
|
||||
values = tuple(float(value) for value in text.split())
|
||||
if len(values) != size:
|
||||
fail(f"{element.tag} {element.get('name')!r} {path!r} has {len(values)} fields")
|
||||
return values
|
||||
|
||||
|
||||
def check_world() -> None:
|
||||
root = xml.parse(WORLD).getroot()
|
||||
world = root.find("./world")
|
||||
if world is None:
|
||||
fail("missing world")
|
||||
model_names = {
|
||||
model_element.get("name") for model_element in world.findall("model")
|
||||
}
|
||||
if model_names != {"ground", "sitl_reference_map", "kaptan_sitl_quad"}:
|
||||
fail(
|
||||
"the declared flat-world model may contain only ground, the visual reference map, "
|
||||
f"and the quad; found {sorted(model_names)!r}"
|
||||
)
|
||||
|
||||
ground = model(root, "ground")
|
||||
ground_collisions = ground.findall(".//collision")
|
||||
if [collision.get("name") for collision in ground_collisions] != [
|
||||
"ground_collision"
|
||||
]:
|
||||
fail("ground must retain exactly one flat collision plane")
|
||||
if ground_collisions[0].find("geometry/plane") is None:
|
||||
fail("ground collision must remain a flat plane")
|
||||
if vector(ground_collisions[0], "geometry/plane/normal", 3) != (0.0, 0.0, 1.0):
|
||||
fail("ground plane must remain horizontal with world +Z normal")
|
||||
if vector(ground_collisions[0], "geometry/plane/size", 2) != (100.0, 100.0):
|
||||
fail("ground plane must retain its declared 100 m square contact area")
|
||||
|
||||
reference_map = model(root, "sitl_reference_map")
|
||||
if reference_map.findall(".//collision"):
|
||||
fail("reference map must remain visual-only and cannot add obstacle physics")
|
||||
if reference_map.findall(".//transparency"):
|
||||
fail(
|
||||
"reference map must not use unsupported SDF transparency material children"
|
||||
)
|
||||
reference_link = named(reference_map, "link", "reference_link")
|
||||
actual_marks = {visual.get("name") for visual in reference_link.findall("visual")}
|
||||
if actual_marks != {"landing_pad"}:
|
||||
fail(
|
||||
f"reference map may contain only the landing pad; found {sorted(actual_marks)!r}"
|
||||
)
|
||||
|
||||
quad = model(root, "kaptan_sitl_quad")
|
||||
if pose(quad)[:3] != (0.0, 0.0, 0.14):
|
||||
fail("quad base_link must start in its reviewed landing-foot rest pose")
|
||||
base_link = named(quad, "link", "base_link")
|
||||
body_collision = named(base_link, "collision", "body_collision")
|
||||
if vector(body_collision, "geometry/box/size", 3) != (0.30, 0.30, 0.08):
|
||||
fail("quad body collision must retain its reviewed clearance geometry")
|
||||
foot_positions = {
|
||||
"front_right": (0.12, -0.12),
|
||||
"rear_right": (-0.12, -0.12),
|
||||
"rear_left": (-0.12, 0.12),
|
||||
"front_left": (0.12, 0.12),
|
||||
}
|
||||
for foot, expected_xy in foot_positions.items():
|
||||
collision = named(base_link, "collision", f"landing_foot_{foot}_collision")
|
||||
visual = named(base_link, "visual", f"landing_foot_{foot}_visual")
|
||||
if pose(collision)[:3] != (*expected_xy, -0.10):
|
||||
fail(f"landing foot {foot!r} has an unexpected contact pose")
|
||||
if pose(visual) != pose(collision):
|
||||
fail(f"landing foot {foot!r} visual must match its collision geometry")
|
||||
if cylinder(collision) != (0.022, 0.08) or cylinder(visual) != (0.022, 0.08):
|
||||
fail(f"landing foot {foot!r} must retain its reviewed cylinder dimensions")
|
||||
|
||||
expected_quad_collisions = {
|
||||
"body_collision",
|
||||
*(f"landing_foot_{foot}_collision" for foot in foot_positions),
|
||||
}
|
||||
actual_quad_collisions = {
|
||||
collision.get("name") for collision in base_link.findall("collision")
|
||||
}
|
||||
if actual_quad_collisions != expected_quad_collisions:
|
||||
fail(
|
||||
"quad collision geometry must remain its body and four declared landing feet; "
|
||||
f"found {sorted(actual_quad_collisions)!r}"
|
||||
)
|
||||
|
||||
sensors = quad.findall(".//sensor")
|
||||
if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [
|
||||
("imu", "imu")
|
||||
]:
|
||||
fail(
|
||||
"this local-state SITL world must not imply unmodeled visual/range/flow sensors"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
check_world()
|
||||
except (AssertionError, OSError, xml.ParseError, ValueError) as error:
|
||||
print(f"SITL world structure check failed: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("SITL world structure check passed")
|
||||
@@ -1,204 +0,0 @@
|
||||
<?xml version="1.0" ?>
|
||||
<sdf version="1.10">
|
||||
<world name="kaptan_sitl">
|
||||
<gravity>0 0 -9.80665</gravity>
|
||||
<physics name="dart" type="dart">
|
||||
<max_step_size>0.0025</max_step_size>
|
||||
<real_time_factor>1.0</real_time_factor>
|
||||
<real_time_update_rate>400</real_time_update_rate>
|
||||
</physics>
|
||||
|
||||
<plugin filename="gz-sim-physics-system" name="gz::sim::systems::Physics"/>
|
||||
<plugin filename="gz-sim-user-commands-system" name="gz::sim::systems::UserCommands"/>
|
||||
<plugin filename="gz-sim-scene-broadcaster-system" name="gz::sim::systems::SceneBroadcaster"/>
|
||||
<plugin filename="gz-sim-sensors-system" name="gz::sim::systems::Sensors">
|
||||
<render_engine>ogre2</render_engine>
|
||||
</plugin>
|
||||
<plugin filename="gz-sim-imu-system" name="gz::sim::systems::Imu"/>
|
||||
<plugin filename="gz-sim-websocket-server-system" name="gz::sim::systems::WebsocketServer">
|
||||
<port>9002</port>
|
||||
<publication_hz>30</publication_hz>
|
||||
</plugin>
|
||||
|
||||
<light type="directional" name="sun">
|
||||
<cast_shadows>true</cast_shadows>
|
||||
<pose>0 0 10 0 0 0</pose>
|
||||
<diffuse>0.8 0.8 0.8 1</diffuse>
|
||||
<specular>0.2 0.2 0.2 1</specular>
|
||||
<direction>-0.5 0.1 -0.9</direction>
|
||||
</light>
|
||||
<scene>
|
||||
<ambient>0.35 0.38 0.42 1</ambient>
|
||||
<background>0.08 0.11 0.16 1</background>
|
||||
<shadows>true</shadows>
|
||||
</scene>
|
||||
|
||||
<!-- Visual-only launch-local reference map. It deliberately has no
|
||||
collision geometry: the closed-loop model receives IMU plus a direct
|
||||
noisy local-state sensor, not vision, terrain, range, or flow. The
|
||||
pad makes the landing point legible without claiming a terrain or
|
||||
obstacle model that the estimator does not observe. -->
|
||||
<model name="sitl_reference_map">
|
||||
<static>true</static>
|
||||
<link name="reference_link">
|
||||
<visual name="landing_pad">
|
||||
<pose>0 0 0.002 0 0 0</pose>
|
||||
<geometry><cylinder><radius>1.25</radius><length>0.004</length></cylinder></geometry>
|
||||
<material>
|
||||
<ambient>0.03 0.20 0.36 1</ambient><diffuse>0.05 0.36 0.65 1</diffuse>
|
||||
<emissive>0.01 0.08 0.14 1</emissive>
|
||||
</material>
|
||||
</visual>
|
||||
</link>
|
||||
</model>
|
||||
|
||||
<model name="ground">
|
||||
<static>true</static>
|
||||
<link name="ground_link">
|
||||
<collision name="ground_collision">
|
||||
<geometry><plane><normal>0 0 1</normal><size>100 100</size></plane></geometry>
|
||||
</collision>
|
||||
<visual name="ground_visual">
|
||||
<geometry><plane><normal>0 0 1</normal><size>100 100</size></plane></geometry>
|
||||
<material><ambient>0.2 0.2 0.2 1</ambient><diffuse>0.4 0.4 0.4 1</diffuse></material>
|
||||
</visual>
|
||||
</link>
|
||||
</model>
|
||||
|
||||
<!-- This is a fixed simulated vehicle. Its M1-M4 mapping is defined in
|
||||
the plugin, not inferred from any board timer. Parameters below are a
|
||||
declared SITL model and require vehicle-specific validation before a
|
||||
physical-flight claim.
|
||||
|
||||
TODO(configuration): replace these fixed values with a reviewed,
|
||||
versioned vehicle profile selected by the ground configuration flow.
|
||||
Until then this world is only the declared Kaptan test vehicle. -->
|
||||
<model name="kaptan_sitl_quad">
|
||||
<!-- ENU/FLU Gazebo is yawed once so Kaptan's FRD body axes align with
|
||||
its NED local frame at the stationary-initializer origin. -->
|
||||
<!-- Four 80 mm landing feet support the base_link at z=0.14 m. The
|
||||
launch-local NED origin is captured only after this physical rest
|
||||
pose is established; it is not a hidden airborne hover point. -->
|
||||
<pose>0 0 0.14 0 0 1.5707963268</pose>
|
||||
<link name="base_link">
|
||||
<inertial>
|
||||
<mass>1.2</mass>
|
||||
<inertia>
|
||||
<ixx>0.021</ixx><iyy>0.021</iyy><izz>0.039</izz>
|
||||
<ixy>0</ixy><ixz>0</ixz><iyz>0</iyz>
|
||||
</inertia>
|
||||
</inertial>
|
||||
<collision name="body_collision">
|
||||
<geometry><box><size>0.30 0.30 0.08</size></box></geometry>
|
||||
</collision>
|
||||
<visual name="body_visual">
|
||||
<geometry><box><size>0.30 0.30 0.08</size></box></geometry>
|
||||
<material><ambient>0.05 0.1 0.2 1</ambient><diffuse>0.1 0.2 0.5 1</diffuse></material>
|
||||
</visual>
|
||||
<collision name="landing_foot_front_right_collision">
|
||||
<pose>0.12 -0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
</collision>
|
||||
<visual name="landing_foot_front_right_visual">
|
||||
<pose>0.12 -0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
<material><ambient>0.07 0.07 0.08 1</ambient><diffuse>0.12 0.12 0.14 1</diffuse></material>
|
||||
</visual>
|
||||
<collision name="landing_foot_rear_right_collision">
|
||||
<pose>-0.12 -0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
</collision>
|
||||
<visual name="landing_foot_rear_right_visual">
|
||||
<pose>-0.12 -0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
<material><ambient>0.07 0.07 0.08 1</ambient><diffuse>0.12 0.12 0.14 1</diffuse></material>
|
||||
</visual>
|
||||
<collision name="landing_foot_rear_left_collision">
|
||||
<pose>-0.12 0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
</collision>
|
||||
<visual name="landing_foot_rear_left_visual">
|
||||
<pose>-0.12 0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
<material><ambient>0.07 0.07 0.08 1</ambient><diffuse>0.12 0.12 0.14 1</diffuse></material>
|
||||
</visual>
|
||||
<collision name="landing_foot_front_left_collision">
|
||||
<pose>0.12 0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
</collision>
|
||||
<visual name="landing_foot_front_left_visual">
|
||||
<pose>0.12 0.12 -0.10 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.022</radius><length>0.08</length></cylinder></geometry>
|
||||
<material><ambient>0.07 0.07 0.08 1</ambient><diffuse>0.12 0.12 0.14 1</diffuse></material>
|
||||
</visual>
|
||||
<visual name="arm_front_right">
|
||||
<pose>0.085 -0.085 0 0 0 -0.7853981634</pose>
|
||||
<geometry><box><size>0.30 0.025 0.02</size></box></geometry>
|
||||
</visual>
|
||||
<visual name="arm_rear_right">
|
||||
<pose>-0.085 -0.085 0 0 0 0.7853981634</pose>
|
||||
<geometry><box><size>0.30 0.025 0.02</size></box></geometry>
|
||||
</visual>
|
||||
<visual name="arm_rear_left">
|
||||
<pose>-0.085 0.085 0 0 0 -0.7853981634</pose>
|
||||
<geometry><box><size>0.30 0.025 0.02</size></box></geometry>
|
||||
</visual>
|
||||
<visual name="arm_front_left">
|
||||
<pose>0.085 0.085 0 0 0 0.7853981634</pose>
|
||||
<geometry><box><size>0.30 0.025 0.02</size></box></geometry>
|
||||
</visual>
|
||||
<visual name="m1">
|
||||
<pose>0.17 -0.17 0.05 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.10</radius><length>0.01</length></cylinder></geometry>
|
||||
<material><ambient>0.7 0.1 0.1 1</ambient><diffuse>0.7 0.1 0.1 1</diffuse></material>
|
||||
</visual>
|
||||
<visual name="m2">
|
||||
<pose>-0.17 -0.17 0.05 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.10</radius><length>0.01</length></cylinder></geometry>
|
||||
<material><ambient>0.1 0.7 0.1 1</ambient><diffuse>0.1 0.7 0.1 1</diffuse></material>
|
||||
</visual>
|
||||
<visual name="m3">
|
||||
<pose>-0.17 0.17 0.05 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.10</radius><length>0.01</length></cylinder></geometry>
|
||||
<material><ambient>0.1 0.1 0.7 1</ambient><diffuse>0.1 0.1 0.7 1</diffuse></material>
|
||||
</visual>
|
||||
<visual name="m4">
|
||||
<pose>0.17 0.17 0.05 0 0 0</pose>
|
||||
<geometry><cylinder><radius>0.10</radius><length>0.01</length></cylinder></geometry>
|
||||
<material><ambient>0.7 0.7 0.1 1</ambient><diffuse>0.7 0.7 0.1 1</diffuse></material>
|
||||
</visual>
|
||||
<sensor name="imu" type="imu">
|
||||
<always_on>true</always_on>
|
||||
<update_rate>100</update_rate>
|
||||
<topic>/kaptan/sitl/imu</topic>
|
||||
<imu>
|
||||
<angular_velocity>
|
||||
<x><noise type="gaussian"><mean>0</mean><stddev>0.002</stddev></noise></x>
|
||||
<y><noise type="gaussian"><mean>0</mean><stddev>0.002</stddev></noise></y>
|
||||
<z><noise type="gaussian"><mean>0</mean><stddev>0.002</stddev></noise></z>
|
||||
</angular_velocity>
|
||||
<linear_acceleration>
|
||||
<x><noise type="gaussian"><mean>0</mean><stddev>0.03</stddev></noise></x>
|
||||
<y><noise type="gaussian"><mean>0</mean><stddev>0.03</stddev></noise></y>
|
||||
<z><noise type="gaussian"><mean>0</mean><stddev>0.03</stddev></noise></z>
|
||||
</linear_acceleration>
|
||||
</imu>
|
||||
</sensor>
|
||||
</link>
|
||||
<plugin filename="libkaptan_sitl_quad.so" name="kaptan::sitl::Quad">
|
||||
<imu_topic>/kaptan/sitl/imu</imu_topic>
|
||||
<sensor_udp_port>14660</sensor_udp_port>
|
||||
<actuator_udp_port>14661</actuator_udp_port>
|
||||
<truth_udp_port>14663</truth_udp_port>
|
||||
<user_clock_udp_port>14664</user_clock_udp_port>
|
||||
<actuator_timeout_usec>25000</actuator_timeout_usec>
|
||||
<maximum_motor_thrust_newtons>4.5</maximum_motor_thrust_newtons>
|
||||
<motor_time_constant_seconds>0.03</motor_time_constant_seconds>
|
||||
<local_position_noise_stddev_m>0.10</local_position_noise_stddev_m>
|
||||
<local_velocity_noise_stddev_mps>0.05</local_velocity_noise_stddev_mps>
|
||||
<local_state_noise_seed>18181</local_state_noise_seed>
|
||||
<reaction_torque_per_newton_metre>0.012</reaction_torque_per_newton_metre>
|
||||
</plugin>
|
||||
</model>
|
||||
</world>
|
||||
</sdf>
|
||||
@@ -17,12 +17,6 @@
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
python = pkgs.python3.withPackages (
|
||||
pythonPackages: with pythonPackages; [
|
||||
pymavlink
|
||||
pyserial
|
||||
]
|
||||
);
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
@@ -35,7 +29,7 @@
|
||||
pkgs.shfmt
|
||||
pkgs.nixfmt
|
||||
pkgs.taplo
|
||||
python
|
||||
pkgs.python3
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user