build: add directory-oriented make workflows
Host SITL smoke / Deterministic Gazebo command path (push) Has been cancelled

This commit is contained in:
Onur Özkan committed 2026-08-30 07:05:01 +03:00
1 parent 4bc98adbc6
commit 7dc3ccb619
40 files changed
+1367 -278

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
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)
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
override ROUTED_GOAL := $(filter $(PRIMARY_GOAL),$(COMPONENT_TARGETS))
ifneq ($(ROUTED_GOAL),)
# The dispatcher reads the original argv so application arguments retain their
# ordering and boundaries even when Make interprets them as goals or variables.
override ROUTE_DEFAULT_TARGET := run
override ROUTE_LOCAL_TARGETS := help
ifeq ($(PRIMARY_GOAL),$(FIRMWARE_DIR))
override ROUTE_LOCAL_TARGETS += fmt clippy test docs pre-commit check clean
endif
ifeq ($(PRIMARY_GOAL),$(DOCS_DIR))
override ROUTE_DEFAULT_TARGET := check
override ROUTE_LOCAL_TARGETS += check
endif
ifneq ($(filter $(PRIMARY_GOAL),$(BOARD_DIRS)),)
override ROUTE_LOCAL_TARGETS += flash
endif
.PHONY: $(PRIMARY_GOAL)
$(PRIMARY_GOAL):
+@exec python3 scripts/make-argv.py route "$(PRIMARY_GOAL)" make \
$(ROUTE_DEFAULT_TARGET) $(ROUTE_LOCAL_TARGETS)
# Additional command-line words are application arguments, not outer targets.
.DEFAULT:
@:
else
.PHONY: help nix-shell build fmt test pre-commit check clean
help: ## Show the command interface (the default)
@printf '%s\n' 'Kaptan developer commands' '' \
'Usage: make <directory> -- [arguments...]' \
' make <directory> help' \
'' 'The -- is the standard end of Make options; application flags follow it.' \
'' 'Repository workflows' \
' help Show this help' \
' 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' \
' pre-commit Run fmt, firmware clippy/test/docs, and mdBook checks' \
' check Run pre-commit plus whitespace checks' \
' clean Remove generated build artifacts' \
'' 'Directory targets' \
' firmware <target> fmt, clippy, test, docs, pre-commit, check, or clean' \
' firmware -- <cargo args...> Pass other arguments to cargo' \
' firmware/boards -- <args...>' \
' Build and package every board; args go to cargo build' \
' firmware/boards/<board> -- <args...>' \
' Build and package one board; args go to cargo build' \
' firmware/boards/<board> flash -- <dfu-util args...>' \
' Build, package, and flash one board over DFU' \
' docs Test and build the mdBook project' \
' 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' \
' make firmware docs' \
' make firmware/boards/kakute-f7-mini-v3' \
' make firmware/boards/kakute-f7-mini-v3 flash' \
' make docs -- build' \
' make firmware/tools/configurator -- --source-system 255 --serial /dev/ttyUSB0 --baud 115200' \
' make firmware/tools/sitl -- move 5 3 1 --wait'
nix-shell: ## Enter the reproducible Nix development shell
nix develop
build: ## Build all firmware boards and documentation
+@$(MAKE) -C firmware/boards run
+@$(MAKE) -C $(DOCS_DIR) run -- build
fmt:
+@$(MAKE) -C $(FIRMWARE_DIR) fmt
shfmt -d $$(rg --files -g '*.sh')
ruff format --check firmware/tools/sitl/kaptan-sim $$(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
+@$(MAKE) -C $(FIRMWARE_DIR) docs
+@$(MAKE) -C $(DOCS_DIR) check
check: pre-commit ## Run pre-commit plus whitespace checks
git diff HEAD --check
clean: ## Remove firmware and documentation build artifacts
+@$(MAKE) -C $(FIRMWARE_DIR) clean
+@$(MAKE) -C $(DOCS_DIR) run -- clean
endif
+34 -8
View File
@@ -1,12 +1,38 @@
MDBOOK ?= mdbook
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.PHONY: build serve test
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
build:
override MDBOOK := mdbook
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
ifeq ($(PRIMARY_GOAL),run)
.PHONY: run
# Application arguments may also look like Make goals. The run recipe recovers
# their exact boundaries from the original invocation.
.DEFAULT:
@:
else
.PHONY: help run check
help:
@printf '%s\n' 'Usage: make docs -- <mdbook arguments...>' '' \
'With no arguments, mdbook test and mdbook build are run.' \
'Example: make docs -- build' '' \
'Project target:' \
' check Run mdbook test and mdbook build'
endif
check:
$(MDBOOK) test
$(MDBOOK) build
serve:
$(MDBOOK) serve --open
test:
$(MDBOOK) test
run:
@exec python3 ../scripts/make-argv.py exec run $(MDBOOK)
+5 -10
View File
@@ -17,13 +17,10 @@ the board's immutable typestate literal.
## Build and package
From the firmware workspace:
From the repository root:
```sh
cd firmware
cargo build --release --locked -p kaptan-geprc-f722-v2 \
--target thumbv7em-none-eabihf
./scripts/create-dfu-image.sh target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2
make firmware/boards/geprc-f722-v2
```
The packaging script validates the ELF against the shared F722 linker layout
@@ -40,9 +37,7 @@ listed `0483:df11` target before flashing:
```sh
dfu-util --list
dfu-util --device 0483:df11 --alt 0 \
--dfuse-address 0x08000000:leave \
--download target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2.bin
make firmware/boards/geprc-f722-v2 flash
```
## Telemetry wiring
@@ -58,8 +53,8 @@ Connect a 3.3 V USB-UART adapter with crossed data lines and common ground:
Then run:
```sh
cargo run --locked -p kaptan-configurator -- \
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
make firmware/tools/configurator -- \
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
```
Open `http://127.0.0.1:4861`, Save the discovered fields, and manually
+5 -10
View File
@@ -17,13 +17,10 @@ typestate literal.
## Build and package
From the firmware workspace:
From the repository root:
```sh
cd firmware
cargo build --release --locked -p kaptan-kakute-f7-mini-v3 \
--target thumbv7em-none-eabihf
./scripts/create-dfu-image.sh target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3
make firmware/boards/kakute-f7-mini-v3
```
The packaging script uses the shared F722 linker layout and prints the flash
@@ -45,9 +42,7 @@ application offset.
```sh
dfu-util --list
dfu-util --device 0483:df11 --alt 0 \
--dfuse-address 0x08000000:leave \
--download target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3.bin
make firmware/boards/kakute-f7-mini-v3 flash
```
## Telemetry wiring
@@ -63,8 +58,8 @@ Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground:
Launch the target-described web UI:
```sh
cargo run --locked -p kaptan-configurator -- \
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
make firmware/tools/configurator -- \
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
```
Open `http://127.0.0.1:4861`, edit the discovered fields, press Save, and
+3 -3
View File
@@ -17,11 +17,11 @@ parameter catalog.
## Web configurator
Build and flash the board image, connect its MAVLink UART directly to a 3.3 V
USB-UART adapter, then run from `firmware`:
USB-UART adapter, then run from the repository root:
```sh
cargo run --locked -p kaptan-configurator -- \
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
make firmware/tools/configurator -- \
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
```
`--source-system` is the configurator's exact MAVLink source identity. Every
+34 -15
View File
@@ -1,27 +1,46 @@
# Development and verification
## Command layout
Each component owns its Makefile: the Cargo workspace, all-board workflows,
individual boards, tools, and documentation each define commands in their own
directory. The root Makefile contains repository-wide workflows and routes a
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
the complete build-and-package command, so they need no invented operation
name.
## Fast host checks
```sh
cd firmware
cargo test --workspace --offline
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
cargo clippy -p platform-stm32f7 --all-targets --offline -- -D warnings
cargo fmt --all --check
git diff --check
make firmware -- fmt --all --check
make firmware -- test --workspace --locked --offline
make firmware -- clippy --workspace --all-targets \
--exclude kaptan-geprc-f722-v2 \
--exclude kaptan-kakute-f7-mini-v3 --locked --offline -- -D warnings
```
## Target checks
```sh
cd firmware
cargo clippy -p kaptan-geprc-f722-v2 -p kaptan-kakute-f7-mini-v3 \
--target thumbv7em-none-eabihf --offline -- -D warnings
cargo build --release --locked \
make firmware/boards
make firmware -- clippy \
-p kaptan-geprc-f722-v2 -p kaptan-kakute-f7-mini-v3 \
--target thumbv7em-none-eabihf --offline
--target thumbv7em-none-eabihf --locked --offline -- -D warnings
```
`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`
adds a whitespace check. `make build` builds and packages release firmware and
builds the mdBook project.
## Strict core coverage
```sh
@@ -38,10 +57,10 @@ the gate.
## Simulation acceptance
```sh
cd firmware
python3 tools/sitl/world-structure-test.py
python3 -m unittest tools/sitl/test_kaptan_sim.py
tools/sitl/e2e-smoke.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
+1 -1
View File
@@ -2,7 +2,7 @@
Public reportable Kaptan error enums derive `KaptanError`:
```rust
```rust,ignore
#[derive(Debug, kaptan_err_reportable::KaptanError)]
enum SensorError {
#[kaptan_error(id = 12345)]
+9 -5
View File
@@ -11,12 +11,16 @@ The repository development shell also supplies `dfu-util` and the checked
nix develop
```
For host development, run:
From the repository root, discover the developer commands with:
```sh
cd firmware
cargo test --workspace --offline
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
make help
```
Run the repository checks with:
```sh
make pre-commit
```
Bare-metal board packages are not host-test targets. Check them with the
@@ -42,7 +46,7 @@ The manual is an [mdBook](https://rust-lang.github.io/mdBook/) project. The
repository's Nix development shell provides the checked tool version:
```sh
nix develop -c make -C docs build
nix develop -c make docs -- build
```
The generated static site is written to `target/docs/`.
+1 -1
View File
@@ -2,7 +2,7 @@
Kaptan code uses the familiar logging macros on both host and flight targets:
```rust
```rust,ignore
kaptan_log::debug!("estimator waiting for IMU");
kaptan_log::info!("configuration loaded");
kaptan_log::warn!("range aid stale");
+2 -18
View File
@@ -21,24 +21,8 @@ and file contracts, [Logs](logs.md) for receiver decoding and filtering, and
Before review, run:
```sh
cd firmware
cargo fmt --all --check
cargo test --workspace --locked --offline
cargo clippy --workspace --all-targets \
--exclude kaptan-kakute-f7-mini-v3 \
--exclude kaptan-geprc-f722-v2 --locked --offline -- -D warnings
cargo clippy -p kaptan-kakute-f7-mini-v3 -p kaptan-geprc-f722-v2 \
--target thumbv7em-none-eabihf --locked --offline -- -D warnings
cargo build --release --locked --offline \
-p kaptan-kakute-f7-mini-v3 -p kaptan-geprc-f722-v2 \
--target thumbv7em-none-eabihf
python3 -m unittest scripts/test-analyze-fw.py
scripts/analyze-fw.py \
target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3 \
platforms/stm32f7/link/f722/memory.x
scripts/analyze-fw.py \
target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2 \
platforms/stm32f7/link/f722/memory.x
make pre-commit
make build
```
The target-specific lint verifies the local configuration receiver and journal
+2 -2
View File
@@ -26,8 +26,8 @@ authority.
Run the structural guard after any platform or board change:
```sh
cd firmware
cargo test -p platform-stm32f7 --test platform_safety_boundary --offline
make firmware -- test -p platform-stm32f7 \
--test platform_safety_boundary --locked --offline
```
This boundary is deliberately stronger than an application-level disarm flag:
+6 -8
View File
@@ -11,19 +11,17 @@ It is a pre-bench software gate, not a physical-flight validation.
## Run the closed loop
```sh
cd firmware
docker compose -f tools/sitl/compose.yaml up -d --build
tools/sitl/kaptan-sim ready
tools/sitl/kaptan-sim move 5 3 1 --wait
tools/sitl/kaptan-sim land 0 0 --wait
docker compose -f tools/sitl/compose.yaml down
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
cd firmware
tools/sitl/e2e-smoke.sh
firmware/tools/sitl/e2e-smoke.sh
```
The smoke scenario verifies ascent, translation, descent, landing, flat-plane
+28 -28
View File
@@ -1,33 +1,33 @@
[workspace]
members = [
"boards/geprc-f722-v2",
"boards/kakute-f7-mini-v3",
"libs/kaptan-actuator",
"libs/kaptan-control",
"libs/kaptan-config",
"libs/kaptan-config-derive",
"libs/kaptan-file-mavlink",
"libs/kaptan-data-channel",
"libs/kaptan-dma",
"libs/kaptan-drivers",
"libs/kaptan-err-reportable",
"libs/kaptan-err-reportable-macros",
"libs/kaptan-flight-config",
"libs/kaptan-flight-runtime",
"libs/kaptan-i2c",
"libs/kaptan-math",
"libs/kaptan-mavlink",
"libs/kaptan-log",
"libs/kaptan-diagnostics-mavlink",
"libs/kaptan-navigation",
"libs/kaptan-safety",
"libs/kaptan-sensor",
"libs/kaptan-spi",
"libs/kaptan-time",
"libs/kaptan-tx",
"platforms/stm32f7",
"tools/sitl",
"tools/configurator",
"boards/geprc-f722-v2",
"boards/kakute-f7-mini-v3",
"libs/kaptan-actuator",
"libs/kaptan-control",
"libs/kaptan-config",
"libs/kaptan-config-derive",
"libs/kaptan-file-mavlink",
"libs/kaptan-data-channel",
"libs/kaptan-dma",
"libs/kaptan-drivers",
"libs/kaptan-err-reportable",
"libs/kaptan-err-reportable-macros",
"libs/kaptan-flight-config",
"libs/kaptan-flight-runtime",
"libs/kaptan-i2c",
"libs/kaptan-math",
"libs/kaptan-mavlink",
"libs/kaptan-log",
"libs/kaptan-diagnostics-mavlink",
"libs/kaptan-navigation",
"libs/kaptan-safety",
"libs/kaptan-sensor",
"libs/kaptan-spi",
"libs/kaptan-time",
"libs/kaptan-tx",
"platforms/stm32f7",
"tools/sitl",
"tools/configurator",
]
resolver = "2"
+73
View File
@@ -0,0 +1,73 @@
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override CARGO := cargo
override PYTHON := python3
override CARGO_FLAGS := --locked --offline
override BOARDS := geprc-f722-v2 kakute-f7-mini-v3
override BOARD_PACKAGES := $(addprefix kaptan-,$(BOARDS))
override BOARD_PACKAGE_ARGS := $(addprefix -p ,$(BOARD_PACKAGES))
override HOST_EXCLUDES := $(addprefix --exclude ,$(BOARD_PACKAGES))
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
run:
@exec python3 ../scripts/make-argv.py exec run $(CARGO)
ifeq ($(PRIMARY_GOAL),run)
.PHONY: run
# Application arguments may also look like Make goals. The run recipe recovers
# their exact boundaries from the original invocation.
.DEFAULT:
@:
else
.PHONY: help run fmt clippy test docs pre-commit check clean
help:
@printf '%s\n' 'Usage: make firmware -- <cargo arguments...>' '' \
'Example: make firmware -- test --workspace --locked --offline' '' \
'Workspace targets:' \
' fmt Check Rust formatting' \
' clippy Run host and embedded Clippy checks' \
' test Run Rust and firmware script tests' \
' docs Build Rust API documentation with warnings denied' \
' pre-commit Run fmt, clippy, test, and docs' \
' check Alias for pre-commit' \
' clean Remove Cargo build artifacts'
fmt:
$(CARGO) fmt --all --check
clippy:
$(CARGO) clippy --workspace --all-targets $(HOST_EXCLUDES) \
$(CARGO_FLAGS) -- -D warnings
$(CARGO) clippy -p platform-stm32f7 --all-targets \
$(CARGO_FLAGS) -- -D warnings
$(CARGO) clippy $(BOARD_PACKAGE_ARGS) --target thumbv7em-none-eabihf \
$(CARGO_FLAGS) -- -D warnings
test:
$(CARGO) test --workspace $(CARGO_FLAGS)
$(PYTHON) -m unittest \
scripts/test-analyze-fw.py scripts/test-summarize-test-coverage.py
docs:
RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps \
$(HOST_EXCLUDES) $(CARGO_FLAGS)
pre-commit: fmt clippy test docs
check: pre-commit
clean:
$(CARGO) clean
endif
+9 -3
View File
@@ -14,9 +14,15 @@ firmware/
└── tests/ firmware integration-test fixtures
```
Run Cargo commands from this directory:
This workspace owns its Makefile. From this directory, list its commands with:
```sh
cd firmware
cargo test --workspace --locked --offline
make help
```
From the repository root, the firmware path is a direct Cargo proxy. For
example, `make firmware -- test --workspace --locked --offline` runs the host
test suite. Build and package every supported board with
`make firmware/boards`, or select one board by its directory path.
Run `make firmware clippy` for Rust lints and `make firmware docs` for API
documentation with warnings denied.
+44
View File
@@ -0,0 +1,44 @@
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override CARGO := cargo
override CARGO_FLAGS := --locked --offline
override EMBEDDED_TARGET := thumbv7em-none-eabihf
override TARGET_DIR := ../target/$(EMBEDDED_TARGET)/release
override SCRIPTS_DIR := ../scripts
override BOARDS := geprc-f722-v2 kakute-f7-mini-v3
override BOARD_PACKAGES := $(addprefix kaptan-,$(BOARDS))
override BOARD_PACKAGE_ARGS := $(addprefix -p ,$(BOARD_PACKAGES))
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
run:
@exec python3 ../../scripts/make-argv.py exec run $(CARGO) build --release \
$(CARGO_FLAGS) $(BOARD_PACKAGE_ARGS) --target $(EMBEDDED_TARGET)
@set -e; for board in $(BOARD_PACKAGES); do \
$(SCRIPTS_DIR)/create-dfu-image.sh $(TARGET_DIR)/$$board; \
done
ifeq ($(PRIMARY_GOAL),run)
.PHONY: run
# Application arguments may also look like Make goals. The run recipe recovers
# their exact boundaries from the original invocation.
.DEFAULT:
@:
else
.PHONY: help run
help:
@printf '%s\n' 'Usage: make firmware/boards -- <cargo build arguments...>' '' \
'With no arguments, every board is built and packaged.'
endif
+69
View File
@@ -0,0 +1,69 @@
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override CARGO := cargo
override CARGO_FLAGS := --locked --offline
override DFU_UTIL := dfu-util
override EMBEDDED_TARGET := thumbv7em-none-eabihf
override PACKAGE := kaptan-$(BOARD)
override TARGET_DIR := ../../target/$(EMBEDDED_TARGET)/release
override CREATE_IMAGE := ../../scripts/create-dfu-image.sh
override DFU_DEVICE := 0483:df11
override DFU_ALT := 0
override DFU_ADDRESS := 0x08000000:leave
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
run:
@exec python3 ../../../scripts/make-argv.py exec run $(CARGO) build --release \
$(CARGO_FLAGS) -p $(PACKAGE) --target $(EMBEDDED_TARGET)
$(CREATE_IMAGE) $(TARGET_DIR)/$(PACKAGE)
_build:
$(CARGO) build --release $(CARGO_FLAGS) -p $(PACKAGE) \
--target $(EMBEDDED_TARGET)
$(CREATE_IMAGE) $(TARGET_DIR)/$(PACKAGE)
flash: _build
@printf '%s\n' 'Flashing $(TARGET_DIR)/$(PACKAGE).bin to $(DFU_DEVICE)'
@exec python3 ../../../scripts/make-argv.py exec flash \
$(DFU_UTIL) --device $(DFU_DEVICE) --alt $(DFU_ALT) \
--dfuse-address $(DFU_ADDRESS) \
--download $(TARGET_DIR)/$(PACKAGE).bin
ifeq ($(PRIMARY_GOAL),run)
.PHONY: run
# Application arguments may also look like Make goals. The run recipe recovers
# their exact boundaries from the original invocation.
.DEFAULT:
@:
else
ifeq ($(PRIMARY_GOAL),flash)
.PHONY: flash _build
.DEFAULT:
@:
else
.PHONY: help run flash _build
help:
@printf '%s\n' 'Usage: make firmware/boards/$(BOARD) -- <cargo build arguments...>' '' \
' make firmware/boards/$(BOARD) flash -- <dfu-util arguments...>' '' \
'With no arguments, this board is built and packaged.' \
'The flash target builds, packages, and writes this board over DFU.'
endif
endif
+3 -1
View File
@@ -14,7 +14,9 @@ kaptan-config = { path = "../../libs/kaptan-config" }
platform-stm32f7 = { path = "../../platforms/stm32f7" }
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
# board crate; the task graph and all RTIC source remain platform-owned.
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
rtic = { version = "2.3", default-features = false, features = [
"thumbv7-backend",
] }
[[bin]]
name = "kaptan-geprc-f722-v2"
+3
View File
@@ -0,0 +1,3 @@
override BOARD := geprc-f722-v2
include ../board.mk
+3 -1
View File
@@ -13,7 +13,9 @@ kaptan-config = { path = "../../libs/kaptan-config" }
platform-stm32f7 = { path = "../../platforms/stm32f7" }
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
# board crate; the task graph and all RTIC source remain platform-owned.
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
rtic = { version = "2.3", default-features = false, features = [
"thumbv7-backend",
] }
[[bin]]
name = "kaptan-kakute-f7-mini-v3"
@@ -0,0 +1,3 @@
override BOARD := kakute-f7-mini-v3
include ../board.mk
+3 -1
View File
@@ -11,7 +11,9 @@ workspace = true
[dependencies]
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["sensor-telemetry"] }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = [
"sensor-telemetry",
] }
kaptan-sensor = { path = "../kaptan-sensor" }
kaptan-time = { path = "../kaptan-time" }
mavlink-core = { version = "0.18", default-features = false }
+3 -1
View File
@@ -17,7 +17,9 @@ kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
kaptan-tx = { path = "../kaptan-tx" }
mavlink-core = { version = "0.18", default-features = false, features = ["embedded"] }
mavlink-core = { version = "0.18", default-features = false, features = [
"embedded",
] }
bitflags = "2"
num-derive = "0.4"
num-traits = { version = "0.2", default-features = false }
+7 -2
View File
@@ -30,6 +30,11 @@ kaptan-spi = { path = "../../libs/kaptan-spi" }
kaptan-time = { path = "../../libs/kaptan-time" }
cortex-m = "0.7"
cortex-m-rt = "0.7"
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
stm32f7 = { version = "0.16", default-features = false, features = ["rt", "stm32f722"] }
rtic = { version = "2.3", default-features = false, features = [
"thumbv7-backend",
] }
stm32f7 = { version = "0.16", default-features = false, features = [
"rt",
"stm32f722",
] }
panic-halt = "1"
+10 -4
View File
@@ -1,8 +1,14 @@
[toolchain]
channel = "1.90.0"
components = ["cargo", "clippy", "rustfmt", "rust-analyzer", "llvm-tools-preview"]
components = [
"cargo",
"clippy",
"rustfmt",
"rust-analyzer",
"llvm-tools-preview",
]
targets = [
"thumbv7em-none-eabihf",
"thumbv7em-none-eabi",
"x86_64-unknown-linux-gnu",
"thumbv7em-none-eabihf",
"thumbv7em-none-eabi",
"x86_64-unknown-linux-gnu",
]
+17 -11
View File
@@ -166,18 +166,22 @@ def validation_errors(layout, section_list, load_segments):
)
for index, segment in enumerate(load_segments):
if segment.memory_size and region_for_span(
segment.virtual_address, segment.memory_size, layout
) is None:
if (
segment.memory_size
and region_for_span(segment.virtual_address, segment.memory_size, layout)
is None
):
errors.append(
f"PT_LOAD[{index}] virtual span "
f"0x{segment.virtual_address:08X}.."
f"0x{segment.virtual_address + segment.memory_size:08X} "
"is outside declared memory"
)
if segment.file_size and region_for_span(
segment.physical_address, segment.file_size, layout
) is None:
if (
segment.file_size
and region_for_span(segment.physical_address, segment.file_size, layout)
is None
):
errors.append(
f"PT_LOAD[{index}] physical span "
f"0x{segment.physical_address:08X}.."
@@ -200,9 +204,7 @@ def flash_operation_section_errors(layout, section_list):
return []
INSTRUCTION = re.compile(
r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$"
)
INSTRUCTION = re.compile(r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$")
DIRECT_BRANCH = re.compile(
r"^(?:b(?:eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)?"
r"(?:\.[nw])?|blx?|bx|cbz|cbnz)$"
@@ -321,7 +323,9 @@ def main():
errors = flash_operation_section_errors(layout, section_list)
if errors:
raise ValueError("Flash-operation placement validation failed:\n " + "\n ".join(errors))
raise ValueError(
"Flash-operation placement validation failed:\n " + "\n ".join(errors)
)
data = next(section for section in section_list if section.name == ".data")
objdump = llvm_tool("llvm-objdump")
errors = sram_control_flow_errors(
@@ -337,7 +341,9 @@ def main():
),
)
if errors:
raise ValueError("Flash-operation control-flow validation failed:\n " + "\n ".join(errors))
raise ValueError(
"Flash-operation control-flow validation failed:\n " + "\n ".join(errors)
)
if __name__ == "__main__":
+8 -8
View File
@@ -2,8 +2,8 @@
set -euo pipefail
if [[ $# -lt 1 || $# -gt 2 ]]; then
printf 'Usage: %s <firmware.elf> [memory.x]\n' "$0" >&2
exit 2
printf 'Usage: %s <firmware.elf> [memory.x]\n' "$0" >&2
exit 2
fi
readonly input_elf="$1"
@@ -15,18 +15,18 @@ readonly host="$(rustc -vV | sed -n 's/^host: //p')"
readonly llvm_objcopy="$(rustc --print sysroot)/lib/rustlib/$host/bin/llvm-objcopy"
if [[ ! -f "$input_elf" ]]; then
printf 'firmware ELF does not exist: %s\n' "$input_elf" >&2
exit 1
printf 'firmware ELF does not exist: %s\n' "$input_elf" >&2
exit 1
fi
if [[ ! -f "$memory_x" ]]; then
printf 'cannot find linker layout: %s\n' "$memory_x" >&2
exit 1
printf 'cannot find linker layout: %s\n' "$memory_x" >&2
exit 1
fi
if [[ ! -x "$llvm_objcopy" ]]; then
printf 'llvm-objcopy is unavailable; install it with: rustup component add llvm-tools-preview\n' >&2
exit 1
printf 'llvm-objcopy is unavailable; install it with: rustup component add llvm-tools-preview\n' >&2
exit 1
fi
"$script_directory/analyze-fw.py" "$input_elf" "$memory_x"
+62 -62
View File
@@ -14,18 +14,18 @@
set -eu
if ! cargo llvm-cov --version >/dev/null 2>&1; then
echo "cargo-llvm-cov is required; install it with: cargo install cargo-llvm-cov --locked" >&2
exit 127
echo "cargo-llvm-cov is required; install it with: cargo install cargo-llvm-cov --locked" >&2
exit 127
fi
if ! rustup toolchain list | grep -q '^nightly'; then
echo "nightly with llvm-tools-preview is required for branch coverage" >&2
exit 127
echo "nightly with llvm-tools-preview is required for branch coverage" >&2
exit 127
fi
if ! python3 --version >/dev/null 2>&1; then
echo "python3 is required for strict source-level coverage accounting" >&2
exit 127
echo "python3 is required for strict source-level coverage accounting" >&2
exit 127
fi
python3 scripts/test-summarize-test-coverage.py
@@ -46,50 +46,50 @@ coverage_root=$CARGO_TARGET_DIR
core_incomplete=0
report_package() {
package=$1
source_path=$2
shift 2
case "$package" in
kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety)
package_target_dir="$coverage_root/$package"
lcov_report="$coverage_root/$package.lcov"
json_report="$coverage_root/$package.json"
report_format=strict-core
;;
*)
package_target_dir=$coverage_root
lcov_report="$coverage_root/$package.lcov"
report_format=lcov
;;
esac
package=$1
source_path=$2
shift 2
case "$package" in
kaptan-math | kaptan-navigation | kaptan-control | kaptan-safety)
package_target_dir="$coverage_root/$package"
lcov_report="$coverage_root/$package.lcov"
json_report="$coverage_root/$package.json"
report_format=strict-core
;;
*)
package_target_dir=$coverage_root
lcov_report="$coverage_root/$package.lcov"
report_format=lcov
;;
esac
case "$report_format" in
strict-core)
# Keep the strict core isolated, then export both representations
# from the same test run. LCOV `DA` defines source lines; JSON
# coordinates define branch identities across generic instances.
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
--branch \
--no-report \
-p "$package" \
"$@"
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
--lcov \
--output-path "$lcov_report"
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
--json \
--output-path "$json_report"
totals=$(python3 scripts/summarize-test-coverage.py \
"$lcov_report" "$json_report" "$source_path")
;;
lcov)
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
--branch \
--lcov \
--output-path "$lcov_report" \
-p "$package" \
"$@"
totals=$(awk -v source_path="$source_path" '
case "$report_format" in
strict-core)
# Keep the strict core isolated, then export both representations
# from the same test run. LCOV `DA` defines source lines; JSON
# coordinates define branch identities across generic instances.
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
--branch \
--no-report \
-p "$package" \
"$@"
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
--lcov \
--output-path "$lcov_report"
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
--json \
--output-path "$json_report"
totals=$(python3 scripts/summarize-test-coverage.py \
"$lcov_report" "$json_report" "$source_path")
;;
lcov)
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
--branch \
--lcov \
--output-path "$lcov_report" \
-p "$package" \
"$@"
totals=$(awk -v source_path="$source_path" '
/^SF:/ { selected = index($0, source_path) != 0 }
selected && /^LF:/ { lines_found += substr($0, 4) }
selected && /^LH:/ { lines_hit += substr($0, 4) }
@@ -99,18 +99,18 @@ report_package() {
printf "%d %d %d %d", lines_hit, lines_found, branches_hit, branches_found
}
' "$lcov_report")
;;
esac
set -- $totals
printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4"
;;
esac
set -- $totals
printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4"
case "$package" in
kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety)
if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then
core_incomplete=1
fi
;;
esac
case "$package" in
kaptan-math | kaptan-navigation | kaptan-control | kaptan-safety)
if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then
core_incomplete=1
fi
;;
esac
}
echo "Host test coverage (reports: $CARGO_TARGET_DIR; strict core unions LCOV DA lines and JSON branches)"
@@ -135,6 +135,6 @@ echo "kaptan-geprc-f722-v2 target-only/N/A (run its thumbv7em-none-eabihf c
echo "kaptan-kakute-f7-mini-v3 target-only/N/A (run its thumbv7em-none-eabihf check)"
if [ "$core_incomplete" -ne 0 ]; then
echo "Strict core coverage is incomplete; reports above identify each crate." >&2
exit 1
echo "Strict core coverage is incomplete; reports above identify each crate." >&2
exit 1
fi
+26 -8
View File
@@ -34,7 +34,9 @@ class CoverageFormatError(ValueError):
"""Coverage records cannot be safely summarized by source location."""
def selected_files(report: dict[object, object], source_path: str) -> Iterable[dict[object, object]]:
def selected_files(
report: dict[object, object], source_path: str
) -> Iterable[dict[object, object]]:
"""Yield selected file records after rejecting ambiguous macro expansion."""
exports = report.get("data")
if not isinstance(exports, list):
@@ -78,7 +80,11 @@ def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]]
if record == "end_of_record":
filename = None
continue
if filename is None or source_path not in filename or not record.startswith("DA:"):
if (
filename is None
or source_path not in filename
or not record.startswith("DA:")
):
continue
fields = record[3:].split(",", 2)
if len(fields) < 2:
@@ -87,7 +93,9 @@ def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]]
line = int(fields[0])
count = int(fields[1])
except ValueError as error:
raise CoverageFormatError(f"{filename}: non-integer LCOV DA record") from error
raise CoverageFormatError(
f"{filename}: non-integer LCOV DA record"
) from error
if line <= 0 or count < 0:
raise CoverageFormatError(f"{filename}: invalid LCOV DA location or count")
key = (filename, line)
@@ -100,7 +108,9 @@ def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]]
return sum(lines.values()), len(lines), files
def branch_totals(report: dict[object, object], source_path: str) -> tuple[int, int, set[str]]:
def branch_totals(
report: dict[object, object], source_path: str
) -> tuple[int, int, set[str]]:
"""Return the union of selected JSON branch outcomes and source files."""
branches: dict[tuple[str, int, int, int, int], list[bool]] = {}
files: set[str] = set()
@@ -142,7 +152,9 @@ def branch_totals(report: dict[object, object], source_path: str) -> tuple[int,
or file_id < 0
or expanded_file_id < 0
):
raise CoverageFormatError(f"{filename}: invalid branch location or count")
raise CoverageFormatError(
f"{filename}: invalid branch location or count"
)
if file_id != expanded_file_id:
raise CoverageFormatError(
f"{filename}:{start_line}:{start_column}: macro-expanded branch is present; "
@@ -175,9 +187,15 @@ def coverage_totals(
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("lcov_report", type=Path, help="LCOV report from cargo llvm-cov")
parser.add_argument("json_report", type=Path, help="LLVM JSON report from cargo llvm-cov")
parser.add_argument("source_path", help="workspace-relative path fragment to include")
parser.add_argument(
"lcov_report", type=Path, help="LCOV report from cargo llvm-cov"
)
parser.add_argument(
"json_report", type=Path, help="LLVM JSON report from cargo llvm-cov"
)
parser.add_argument(
"source_path", help="workspace-relative path fragment to include"
)
arguments = parser.parse_args()
try:
+2 -6
View File
@@ -122,9 +122,7 @@ class FirmwareMemoryValidationTests(unittest.TestCase):
self.assertEqual(
ANALYZER.region_usage("FLASH", LAYOUT, sections, segments), 0x50
)
self.assertEqual(
ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10
)
self.assertEqual(ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10)
def test_rejects_loadable_virtual_and_physical_spans_outside_memory(self):
segments = [
@@ -138,9 +136,7 @@ class FirmwareMemoryValidationTests(unittest.TestCase):
def test_requires_one_executable_data_section_in_ram(self):
valid = [ANALYZER.Section(".data", 0x20, 0x20000000, True, True)]
non_executable = [
ANALYZER.Section(".data", 0x20, 0x20000000, True, False)
]
non_executable = [ANALYZER.Section(".data", 0x20, 0x20000000, True, False)]
in_flash = [ANALYZER.Section(".data", 0x20, 0x08000000, True, True)]
self.assertEqual(ANALYZER.flash_operation_section_errors(LAYOUT, valid), [])
+32
View File
@@ -0,0 +1,32 @@
override SHELL := /bin/sh
override MAKEFLAGS += --no-print-directory
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override CARGO := cargo
override CARGO_FLAGS := --locked --offline
override PRIMARY_GOAL := $(firstword $(MAKECMDGOALS))
ifeq ($(PRIMARY_GOAL),run)
.PHONY: run
.DEFAULT:
@:
else
.PHONY: help run
help: ## Show configurator commands
@printf '%s\n' 'Configurator' '' \
'The root Makefile passes every argument after -- to the application:' \
' make firmware/tools/configurator -- --source-system 255 --serial /dev/ttyUSB0 --baud 115200'
endif
run: ## Run the configurator with the remaining command-line arguments
@exec python3 ../../../scripts/make-argv.py exec run \
$(CARGO) run $(CARGO_FLAGS) -p kaptan-configurator --
+3 -1
View File
@@ -16,7 +16,9 @@ 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-navigation = { path = "../../libs/kaptan-navigation", features = [
"sitl",
] }
kaptan-safety = { path = "../../libs/kaptan-safety" }
kaptan-time = { path = "../../libs/kaptan-time" }
minicbor = "0.25"
+34
View File
@@ -0,0 +1,34 @@
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
+12 -12
View File
@@ -12,18 +12,18 @@ 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"
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
+42 -17
View File
@@ -50,9 +50,13 @@ 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 = 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 = 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)
@@ -78,9 +82,15 @@ def arguments() -> argparse.Namespace:
"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(
"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",
@@ -122,7 +132,9 @@ def latest_simulation_time(
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")
raise RuntimeError(
"Gazebo simulation clock reset; restart the command in the new epoch"
)
latest = simulation_time
@@ -145,7 +157,10 @@ def wait_ready(socket_: socket.socket, timeout: float) -> None:
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 tools/sitl/compose.yaml up -d --build")
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"),
@@ -174,10 +189,7 @@ def truth_sample_within_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
)
or (require_ground_plane and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES)
)
@@ -217,11 +229,16 @@ def verify_truth(
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)
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))
payload = recv(
truth_socket, min(acquisition_deadline, time.monotonic() + 0.01)
)
if payload is None or len(payload) != TRUTH_PACKET.size:
continue
(
@@ -261,8 +278,12 @@ def verify_truth(
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))
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)
@@ -449,7 +470,9 @@ def command_goal(
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")
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")
@@ -512,7 +535,9 @@ def main() -> int:
or not math.isfinite(values.truth_hold_seconds)
)
if tolerances:
raise ValueError("truth tolerances and hold duration must be finite and positive")
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":
+24 -17
View File
@@ -37,7 +37,9 @@ class TruthAcceptanceTests(unittest.TestCase):
)
def test_move_truth_contract_allows_an_armed_hover(self):
self.assertTrue(self.acceptable(motors_stopped=False, ground_plane_error=1.0, landing=False))
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))
@@ -77,23 +79,28 @@ class TruthAcceptanceTests(unittest.TestCase):
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(
(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",
))
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__":
+21 -7
View File
@@ -67,7 +67,9 @@ def check_world() -> None:
world = root.find("./world")
if world is None:
fail("missing world")
model_names = {model_element.get("name") for model_element in world.findall("model")}
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, "
@@ -76,7 +78,9 @@ def check_world() -> None:
ground = model(root, "ground")
ground_collisions = ground.findall(".//collision")
if [collision.get("name") for collision in ground_collisions] != ["ground_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")
@@ -89,11 +93,15 @@ def check_world() -> None:
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")
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}")
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):
@@ -122,7 +130,9 @@ def check_world() -> None:
"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")}
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; "
@@ -130,8 +140,12 @@ def check_world() -> None:
)
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 [(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__":
+17 -7
View File
@@ -3,7 +3,8 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
outputs =
{ nixpkgs, ... }:
let
systems = [
"x86_64-linux"
@@ -12,13 +13,16 @@
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
devShells = forAllSystems (system:
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python3.withPackages (pythonPackages: with pythonPackages; [
pymavlink
pyserial
]);
python = pkgs.python3.withPackages (
pythonPackages: with pythonPackages; [
pymavlink
pyserial
]
);
in
{
default = pkgs.mkShell {
@@ -26,9 +30,15 @@
pkgs.rustup
pkgs.dfu-util
pkgs.mdbook
pkgs.ripgrep
pkgs.ruff
pkgs.shfmt
pkgs.nixfmt
pkgs.taplo
python
];
};
});
}
);
};
}
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Preserve application argv across GNU Make's command-line parser.
GNU Make removes variable assignments, consumes its first ``--``, and flattens
quoted goal names in ``MAKECMDGOALS``. Linux exposes the original NUL-delimited
argument vector in procfs, which lets the Makefiles keep a conventional
``make component -- application arguments`` interface without rebuilding a
command string in the shell.
"""
from __future__ import annotations
import base64
import errno
import json
import os
from pathlib import Path
import re
import sys
from typing import NoReturn
PAYLOAD_ENV = "KAPTAN_MAKE_ARGV"
ARG_COUNT_ENV = "KAPTAN_MAKE_ARG_COUNT"
_MAKE_FLAG_ENV = ("MAKEFLAGS", "MFLAGS")
class ArgumentRecoveryError(RuntimeError):
"""Raised when the original Make invocation cannot be recovered."""
def encode_arguments(arguments: list[str]) -> str:
"""Encode an argument vector into an ASCII-only environment value."""
document = json.dumps(arguments, ensure_ascii=True, separators=(",", ":"))
return base64.urlsafe_b64encode(document.encode("ascii")).decode("ascii")
def decode_arguments(encoded: str) -> list[str]:
"""Decode an argument vector produced by :func:`encode_arguments`."""
try:
document = base64.b64decode(encoded, altchars=b"-_", validate=True)
arguments = json.loads(document.decode("ascii"))
except (ValueError, UnicodeError, json.JSONDecodeError) as error:
raise ArgumentRecoveryError(
f"invalid application argument payload in {PAYLOAD_ENV}"
) from error
if not isinstance(arguments, list) or not all(
isinstance(argument, str) for argument in arguments
):
raise ArgumentRecoveryError(
f"invalid application argument payload in {PAYLOAD_ENV}"
)
return arguments
def read_process_arguments(pid: int, proc_root: Path = Path("/proc")) -> list[str]:
"""Read one process's exact NUL-delimited argv from procfs."""
path = proc_root / str(pid) / "cmdline"
try:
raw = path.read_bytes()
except OSError as error:
raise ArgumentRecoveryError(
f"cannot read {path}; exact Make argument forwarding requires Linux /proc"
) from error
if not raw:
raise ArgumentRecoveryError(f"cannot recover arguments from empty {path}")
if not raw.endswith(b"\0"):
raise ArgumentRecoveryError(
f"cannot recover unterminated arguments from {path}"
)
return [os.fsdecode(argument) for argument in raw[:-1].split(b"\0")]
def read_parent_pid(pid: int, proc_root: Path = Path("/proc")) -> int:
"""Read a process's parent PID from procfs."""
path = proc_root / str(pid) / "status"
try:
status = path.read_text(encoding="utf-8")
except OSError as error:
raise ArgumentRecoveryError(
f"cannot read {path}; exact Make argument forwarding requires Linux /proc"
) from error
for line in status.splitlines():
if line.startswith("PPid:"):
return int(line.split(":", 1)[1].strip())
raise ArgumentRecoveryError(f"cannot find PPid in {path}")
def find_make_arguments(marker: str, start_pid: int | None = None) -> list[str]:
"""Find the nearest parent Make invocation containing *marker*."""
pid = os.getppid() if start_pid is None else start_pid
visited: set[int] = set()
while pid > 1 and pid not in visited:
visited.add(pid)
arguments = read_process_arguments(pid)
executable = Path(arguments[0]).name if arguments else ""
if marker in arguments and executable.endswith("make"):
return arguments
pid = read_parent_pid(pid)
raise ArgumentRecoveryError(
f"cannot find a parent Make invocation containing {marker!r}; "
"exact argument forwarding requires Linux /proc"
)
def _tail_after_marker(make_arguments: list[str], marker: str) -> list[str]:
try:
marker_index = make_arguments.index(marker)
except ValueError as error:
raise ArgumentRecoveryError(
f"cannot find {marker!r} in the parent Make invocation"
) from error
return make_arguments[marker_index + 1 :]
def arguments_after_marker(make_arguments: list[str], marker: str) -> list[str]:
"""Return arguments following *marker*, consuming one following ``--``."""
arguments = _tail_after_marker(make_arguments, marker)
if arguments[:1] == ["--"]:
return arguments[1:]
return arguments
def select_route(
make_arguments: list[str],
component: str,
default_target: str,
local_targets: set[str],
) -> tuple[str, list[str]]:
"""Select a local Make target and the exact application payload.
``default_target`` applies only when the component has no arguments. An
explicit delimiter or backward-compatible unknown words always select the
component's ``run`` target.
"""
arguments = _tail_after_marker(make_arguments, component)
if "--" in arguments:
separator = arguments.index("--")
before_separator = arguments[:separator]
payload = arguments[separator + 1 :]
if len(before_separator) == 1 and before_separator[0] in local_targets:
return before_separator[0], payload
if before_separator:
raise ArgumentRecoveryError(
f"unexpected words before -- for {component}: "
+ " ".join(repr(word) for word in before_separator)
)
return "run", payload
if not arguments:
return default_target, []
if len(arguments) == 1 and arguments[0] in local_targets:
return arguments[0], []
return "run", arguments
def assignment_names(arguments: list[str]) -> set[str]:
"""Return environment names which GNU Make may treat as assignments."""
names: set[str] = set()
for argument in arguments[1:]:
for operator in (":::=", "::=", ":=", "+=", "?=", "!=", "="):
if operator in argument:
name, _ = argument.split(operator, 1)
if name:
names.add(name)
break
return names
def scrub_make_environment(
environment: dict[str, str], make_arguments: list[str]
) -> dict[str, str]:
"""Remove recursive-Make flags and command-line variable side effects."""
clean = environment.copy()
clean.pop("MAKEOVERRIDES", None)
for name in _MAKE_FLAG_ENV:
value = clean.get(name, "")
separator = re.search(r"(?:^|\s)--(?:\s|$)", value)
flags = value[: separator.start()].strip() if separator else value.strip()
if flags:
clean[name] = flags
else:
clean.pop(name, None)
for name in assignment_names(make_arguments):
clean.pop(name, None)
return clean
def route(
component: str,
make_program: str,
default_target: str,
local_targets: set[str],
) -> NoReturn:
"""Recover argv and exec the component's selected recursive Make target."""
make_arguments = find_make_arguments(component)
target, payload = select_route(
make_arguments, component, default_target, local_targets
)
environment = scrub_make_environment(dict(os.environ), make_arguments)
environment[PAYLOAD_ENV] = encode_arguments(payload)
environment[ARG_COUNT_ENV] = str(len(payload))
os.execvpe(
make_program,
[make_program, "--no-print-directory", "-C", component, target],
environment,
)
def execute(marker: str, command: list[str]) -> NoReturn:
"""Append recovered arguments to *command* and exec it without a shell."""
if not command:
raise ArgumentRecoveryError("exec requires a command prefix")
environment = dict(os.environ)
encoded = environment.pop(PAYLOAD_ENV, None)
environment.pop(ARG_COUNT_ENV, None)
if encoded is None:
make_arguments = find_make_arguments(marker)
arguments = arguments_after_marker(make_arguments, marker)
environment = scrub_make_environment(environment, make_arguments)
else:
arguments = decode_arguments(encoded)
environment = scrub_make_environment(environment, ["make"])
os.execvpe(command[0], [*command, *arguments], environment)
def usage() -> str:
return (
"usage:\n"
" make-argv.py route COMPONENT MAKE_PROGRAM DEFAULT_TARGET [LOCAL_TARGET ...]\n"
" make-argv.py exec MARKER COMMAND [COMMAND_ARG ...]"
)
def main(arguments: list[str] | None = None) -> int:
arguments = sys.argv[1:] if arguments is None else arguments
try:
if arguments[:1] == ["route"] and len(arguments) >= 4:
route(arguments[1], arguments[2], arguments[3], set(arguments[4:]))
elif arguments[:1] == ["exec"] and len(arguments) >= 3:
execute(arguments[1], arguments[2:])
else:
raise ArgumentRecoveryError(usage())
except ArgumentRecoveryError as error:
print(f"make-argv: {error}", file=sys.stderr)
return 2
except OSError as error:
program = error.filename or "command"
detail = error.strerror or str(error)
print(f"make-argv: cannot execute {program}: {detail}", file=sys.stderr)
return 127 if error.errno == errno.ENOENT else 126
return 0
if __name__ == "__main__":
raise SystemExit(main())
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Tests for exact argument forwarding through GNU Make."""
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
SPEC = importlib.util.spec_from_file_location(
"make_argv", Path(__file__).with_name("make-argv.py")
)
assert SPEC is not None and SPEC.loader is not None
MAKE_ARGV = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MAKE_ARGV)
class ArgumentEncodingTests(unittest.TestCase):
def test_round_trip_preserves_exact_arguments(self):
arguments = [
"--key=one",
"two words",
"",
"--key=one",
"$HOME",
"--",
"--key=two",
]
self.assertEqual(
MAKE_ARGV.decode_arguments(MAKE_ARGV.encode_arguments(arguments)),
arguments,
)
def test_rejects_invalid_payload(self):
with self.assertRaisesRegex(
MAKE_ARGV.ArgumentRecoveryError, "invalid application argument payload"
):
MAKE_ARGV.decode_arguments("not-base64!")
@mock.patch.object(
MAKE_ARGV, "execute", side_effect=FileNotFoundError(2, "missing", "tool")
)
def test_missing_application_has_a_concise_error(self, _execute):
errors = io.StringIO()
with mock.patch("sys.stderr", errors):
status = MAKE_ARGV.main(["exec", "run", "tool"])
self.assertEqual(status, 127)
self.assertEqual(errors.getvalue(), "make-argv: cannot execute tool: missing\n")
class RouteSelectionTests(unittest.TestCase):
def test_delimiter_makes_test_an_application_argument(self):
target, arguments = MAKE_ARGV.select_route(
["make", "firmware", "--", "test"],
"firmware",
"run",
{"test", "clippy"},
)
self.assertEqual(target, "run")
self.assertEqual(arguments, ["test"])
def test_no_delimiter_selects_a_known_local_target(self):
target, arguments = MAKE_ARGV.select_route(
["make", "firmware", "test"],
"firmware",
"run",
{"test", "clippy"},
)
self.assertEqual(target, "test")
self.assertEqual(arguments, [])
def test_configurable_no_argument_default(self):
target, arguments = MAKE_ARGV.select_route(
["make", "docs"], "docs", "check", {"help"}
)
self.assertEqual(target, "check")
self.assertEqual(arguments, [])
def test_explicit_docs_arguments_select_run_not_no_argument_default(self):
target, arguments = MAKE_ARGV.select_route(
["make", "docs", "--", "--version"], "docs", "check", {"help"}
)
self.assertEqual(target, "run")
self.assertEqual(arguments, ["--version"])
def test_unknown_words_without_delimiter_remain_application_arguments(self):
target, arguments = MAKE_ARGV.select_route(
["make", "firmware/tools/sitl", "move", "5", "two words"],
"firmware/tools/sitl",
"run",
{"help"},
)
self.assertEqual(target, "run")
self.assertEqual(arguments, ["move", "5", "two words"])
def test_local_flash_keeps_every_argument_after_delimiter(self):
target, arguments = MAKE_ARGV.select_route(
[
"make",
"firmware/boards/example",
"flash",
"--",
"--device=0483:df11",
"",
"--",
"$HOME",
],
"firmware/boards/example",
"run",
{"flash", "help"},
)
self.assertEqual(target, "flash")
self.assertEqual(arguments, ["--device=0483:df11", "", "--", "$HOME"])
class EnvironmentTests(unittest.TestCase):
def test_scrubs_make_flags_and_command_line_assignments(self):
environment = {
"PATH": "/bin",
"MAKEFLAGS": "-- CARGO=wrong",
"MAKEOVERRIDES": "CARGO=wrong",
"MFLAGS": "--",
"CARGO": "wrong",
"OTHER": "keep",
}
clean = MAKE_ARGV.scrub_make_environment(
environment,
["make", "firmware", "--", "CARGO=wrong", "--key=value"],
)
self.assertEqual(clean, {"PATH": "/bin", "OTHER": "keep"})
def test_preserves_make_execution_flags_without_overrides(self):
clean = MAKE_ARGV.scrub_make_environment(
{
"MAKEFLAGS": "n --no-print-directory -- --key=value",
"MFLAGS": "-n -- --key=value",
"MAKEOVERRIDES": "--key=value",
},
["make", "firmware", "--", "--key=value"],
)
self.assertEqual(
clean,
{"MAKEFLAGS": "n --no-print-directory", "MFLAGS": "-n"},
)
@mock.patch.object(MAKE_ARGV.os, "execvpe")
@mock.patch.object(MAKE_ARGV, "find_make_arguments")
def test_route_execs_recursive_make_with_encoded_payload(
self, find_make_arguments, execvpe
):
payload = ["--key=one", "two words", "", "--", "--key=one"]
find_make_arguments.return_value = [
"make",
"firmware/tools/sitl",
"--",
*payload,
]
with mock.patch.dict(
MAKE_ARGV.os.environ,
{"PATH": "/bin", "MAKEFLAGS": "n -- --key=one"},
clear=True,
):
MAKE_ARGV.route("firmware/tools/sitl", "make", "run", {"help"})
program, arguments, environment = execvpe.call_args.args
self.assertEqual(program, "make")
self.assertEqual(
arguments,
[
"make",
"--no-print-directory",
"-C",
"firmware/tools/sitl",
"run",
],
)
self.assertEqual(
MAKE_ARGV.decode_arguments(environment[MAKE_ARGV.PAYLOAD_ENV]), payload
)
self.assertEqual(environment[MAKE_ARGV.ARG_COUNT_ENV], str(len(payload)))
self.assertEqual(environment["MAKEFLAGS"], "n")
@mock.patch.object(MAKE_ARGV.os, "execvpe")
def test_exec_uses_routed_payload_without_shell_joining(self, execvpe):
payload = ["--key=one", "two words", "", "$HOME", "--", "--key=one"]
environment = {
MAKE_ARGV.PAYLOAD_ENV: MAKE_ARGV.encode_arguments(payload),
MAKE_ARGV.ARG_COUNT_ENV: str(len(payload)),
"PATH": "/bin",
"MAKEFLAGS": "-- --key=one",
}
with mock.patch.dict(MAKE_ARGV.os.environ, environment, clear=True):
MAKE_ARGV.execute("run", ["example-app", "fixed argument"])
program, arguments, child_environment = execvpe.call_args.args
self.assertEqual(program, "example-app")
self.assertEqual(arguments, ["example-app", "fixed argument", *payload])
self.assertEqual(child_environment, {"PATH": "/bin"})
@mock.patch.object(MAKE_ARGV.os, "execvpe")
@mock.patch.object(MAKE_ARGV, "find_make_arguments")
def test_direct_local_make_exec_recovers_arguments(
self, find_make_arguments, execvpe
):
payload = ["--key=value", "two words", "", "--", "$HOME"]
find_make_arguments.return_value = ["make", "run", "--", *payload]
with mock.patch.dict(MAKE_ARGV.os.environ, {"PATH": "/bin"}, clear=True):
MAKE_ARGV.execute("run", ["example-app"])
self.assertEqual(execvpe.call_args.args[1], ["example-app", *payload])
class ProcfsTests(unittest.TestCase):
def test_procfs_preserves_a_final_empty_argument(self):
with tempfile.TemporaryDirectory() as directory:
process_directory = Path(directory, "123")
process_directory.mkdir()
Path(process_directory, "cmdline").write_bytes(b"make\0run\0\0")
self.assertEqual(
MAKE_ARGV.read_process_arguments(123, Path(directory)),
["make", "run", ""],
)
def test_procfs_failure_is_actionable(self):
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(
MAKE_ARGV.ArgumentRecoveryError, "requires Linux /proc"
):
MAKE_ARGV.read_process_arguments(123, Path(directory))
@unittest.skipUnless(Path("/proc/self/cmdline").is_file(), "requires Linux procfs")
class MakeIntegrationTests(unittest.TestCase):
def test_recursive_make_preserves_exact_application_argv(self):
make = shutil.which("make")
if make is None:
self.skipTest("GNU Make is unavailable")
helper = Path(__file__).with_name("make-argv.py").resolve()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
component = root / "component"
component.mkdir()
output = root / "arguments.json"
application = root / "record-arguments.py"
application.write_text(
"import json, pathlib, sys\n"
"pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))\n",
encoding="utf-8",
)
(root / "Makefile").write_text(
".PHONY: component\n"
"component:\n"
f"\t+@exec {sys.executable} {helper} route component make run help\n"
".DEFAULT:\n"
"\t@:\n",
encoding="utf-8",
)
(component / "Makefile").write_text(
".PHONY: run\n"
"run:\n"
f"\t@exec {sys.executable} {helper} exec run "
f"{sys.executable} {application} {output}\n"
".DEFAULT:\n"
"\t@:\n",
encoding="utf-8",
)
payload = [
"--key=one",
"two words",
"",
"--key=one",
"$HOME",
"--",
"--key=two",
]
subprocess.run(
[make, "component", "--", *payload],
cwd=root,
check=True,
capture_output=True,
text=True,
)
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), payload)
if __name__ == "__main__":
unittest.main()