build: add directory-oriented make workflows
Host SITL smoke / Deterministic Gazebo command path (push) Has been cancelled
Host SITL smoke / Deterministic Gazebo command path (push) Has been cancelled
This commit is contained in:
1 parent
4bc98adbc6
commit
7dc3ccb619
40 files changed
+1367
-278
No files matched your search
@@ -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
@@ -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
|
$(MDBOOK) build
|
||||||
|
|
||||||
serve:
|
run:
|
||||||
$(MDBOOK) serve --open
|
@exec python3 ../scripts/make-argv.py exec run $(MDBOOK)
|
||||||
|
|
||||||
test:
|
|
||||||
$(MDBOOK) test
|
|
||||||
@@ -17,13 +17,10 @@ the board's immutable typestate literal.
|
|||||||
|
|
||||||
## Build and package
|
## Build and package
|
||||||
|
|
||||||
From the firmware workspace:
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make firmware/boards/geprc-f722-v2
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The packaging script validates the ELF against the shared F722 linker layout
|
The packaging script validates the ELF against the shared F722 linker layout
|
||||||
@@ -40,9 +37,7 @@ listed `0483:df11` target before flashing:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
dfu-util --list
|
dfu-util --list
|
||||||
dfu-util --device 0483:df11 --alt 0 \
|
make firmware/boards/geprc-f722-v2 flash
|
||||||
--dfuse-address 0x08000000:leave \
|
|
||||||
--download target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2.bin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Telemetry wiring
|
## Telemetry wiring
|
||||||
@@ -58,8 +53,8 @@ Connect a 3.3 V USB-UART adapter with crossed data lines and common ground:
|
|||||||
Then run:
|
Then run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run --locked -p kaptan-configurator -- \
|
make firmware/tools/configurator -- \
|
||||||
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
|
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://127.0.0.1:4861`, Save the discovered fields, and manually
|
Open `http://127.0.0.1:4861`, Save the discovered fields, and manually
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ typestate literal.
|
|||||||
|
|
||||||
## Build and package
|
## Build and package
|
||||||
|
|
||||||
From the firmware workspace:
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make firmware/boards/kakute-f7-mini-v3
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The packaging script uses the shared F722 linker layout and prints the flash
|
The packaging script uses the shared F722 linker layout and prints the flash
|
||||||
@@ -45,9 +42,7 @@ application offset.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
dfu-util --list
|
dfu-util --list
|
||||||
dfu-util --device 0483:df11 --alt 0 \
|
make firmware/boards/kakute-f7-mini-v3 flash
|
||||||
--dfuse-address 0x08000000:leave \
|
|
||||||
--download target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3.bin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Telemetry wiring
|
## 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:
|
Launch the target-described web UI:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run --locked -p kaptan-configurator -- \
|
make firmware/tools/configurator -- \
|
||||||
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
|
--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
|
Open `http://127.0.0.1:4861`, edit the discovered fields, press Save, and
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ parameter catalog.
|
|||||||
## Web configurator
|
## Web configurator
|
||||||
|
|
||||||
Build and flash the board image, connect its MAVLink UART directly to a 3.3 V
|
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
|
```sh
|
||||||
cargo run --locked -p kaptan-configurator -- \
|
make firmware/tools/configurator -- \
|
||||||
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
|
--source-system 255 --serial /dev/serial/by-id/<adapter> --baud 115200
|
||||||
```
|
```
|
||||||
|
|
||||||
`--source-system` is the configurator's exact MAVLink source identity. Every
|
`--source-system` is the configurator's exact MAVLink source identity. Every
|
||||||
|
|||||||
+34
-15
@@ -1,27 +1,46 @@
|
|||||||
# Development and verification
|
# 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
|
## Fast host checks
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make firmware -- fmt --all --check
|
||||||
cargo test --workspace --offline
|
make firmware -- test --workspace --locked --offline
|
||||||
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
|
make firmware -- clippy --workspace --all-targets \
|
||||||
cargo clippy -p platform-stm32f7 --all-targets --offline -- -D warnings
|
--exclude kaptan-geprc-f722-v2 \
|
||||||
cargo fmt --all --check
|
--exclude kaptan-kakute-f7-mini-v3 --locked --offline -- -D warnings
|
||||||
git diff --check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Target checks
|
## Target checks
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make firmware/boards
|
||||||
cargo clippy -p kaptan-geprc-f722-v2 -p kaptan-kakute-f7-mini-v3 \
|
make firmware -- clippy \
|
||||||
--target thumbv7em-none-eabihf --offline -- -D warnings
|
|
||||||
cargo build --release --locked \
|
|
||||||
-p kaptan-geprc-f722-v2 -p kaptan-kakute-f7-mini-v3 \
|
-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
|
## Strict core coverage
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -38,10 +57,10 @@ the gate.
|
|||||||
## Simulation acceptance
|
## Simulation acceptance
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
cd firmware/tools/sitl
|
||||||
python3 tools/sitl/world-structure-test.py
|
python3 world-structure-test.py
|
||||||
python3 -m unittest tools/sitl/test_kaptan_sim.py
|
python3 -m unittest test_kaptan_sim.py
|
||||||
tools/sitl/e2e-smoke.sh
|
./e2e-smoke.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The Docker smoke is intentionally separate from host unit tests because it
|
The Docker smoke is intentionally separate from host unit tests because it
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Public reportable Kaptan error enums derive `KaptanError`:
|
Public reportable Kaptan error enums derive `KaptanError`:
|
||||||
|
|
||||||
```rust
|
```rust,ignore
|
||||||
#[derive(Debug, kaptan_err_reportable::KaptanError)]
|
#[derive(Debug, kaptan_err_reportable::KaptanError)]
|
||||||
enum SensorError {
|
enum SensorError {
|
||||||
#[kaptan_error(id = 12345)]
|
#[kaptan_error(id = 12345)]
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ The repository development shell also supplies `dfu-util` and the checked
|
|||||||
nix develop
|
nix develop
|
||||||
```
|
```
|
||||||
|
|
||||||
For host development, run:
|
From the repository root, discover the developer commands with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make help
|
||||||
cargo test --workspace --offline
|
```
|
||||||
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
|
|
||||||
|
Run the repository checks with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make pre-commit
|
||||||
```
|
```
|
||||||
|
|
||||||
Bare-metal board packages are not host-test targets. Check them with the
|
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:
|
repository's Nix development shell provides the checked tool version:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix develop -c make -C docs build
|
nix develop -c make docs -- build
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated static site is written to `target/docs/`.
|
The generated static site is written to `target/docs/`.
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Kaptan code uses the familiar logging macros on both host and flight targets:
|
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::debug!("estimator waiting for IMU");
|
||||||
kaptan_log::info!("configuration loaded");
|
kaptan_log::info!("configuration loaded");
|
||||||
kaptan_log::warn!("range aid stale");
|
kaptan_log::warn!("range aid stale");
|
||||||
|
|||||||
+2
-18
@@ -21,24 +21,8 @@ and file contracts, [Logs](logs.md) for receiver decoding and filtering, and
|
|||||||
Before review, run:
|
Before review, run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make pre-commit
|
||||||
cargo fmt --all --check
|
make build
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The target-specific lint verifies the local configuration receiver and journal
|
The target-specific lint verifies the local configuration receiver and journal
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ authority.
|
|||||||
Run the structural guard after any platform or board change:
|
Run the structural guard after any platform or board change:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make firmware -- test -p platform-stm32f7 \
|
||||||
cargo test -p platform-stm32f7 --test platform_safety_boundary --offline
|
--test platform_safety_boundary --locked --offline
|
||||||
```
|
```
|
||||||
|
|
||||||
This boundary is deliberately stronger than an application-level disarm flag:
|
This boundary is deliberately stronger than an application-level disarm flag:
|
||||||
|
|||||||
+6
-8
@@ -11,19 +11,17 @@ It is a pre-bench software gate, not a physical-flight validation.
|
|||||||
## Run the closed loop
|
## Run the closed loop
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
docker compose -f firmware/tools/sitl/compose.yaml up -d --build
|
||||||
docker compose -f tools/sitl/compose.yaml up -d --build
|
make firmware/tools/sitl -- ready
|
||||||
tools/sitl/kaptan-sim ready
|
make firmware/tools/sitl -- move 5 3 1 --wait
|
||||||
tools/sitl/kaptan-sim move 5 3 1 --wait
|
make firmware/tools/sitl -- land 0 0 --wait
|
||||||
tools/sitl/kaptan-sim land 0 0 --wait
|
docker compose -f firmware/tools/sitl/compose.yaml down
|
||||||
docker compose -f tools/sitl/compose.yaml down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For the reproducible command-path gate, use:
|
For the reproducible command-path gate, use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
firmware/tools/sitl/e2e-smoke.sh
|
||||||
tools/sitl/e2e-smoke.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The smoke scenario verifies ascent, translation, descent, landing, flat-plane
|
The smoke scenario verifies ascent, translation, descent, landing, flat-plane
|
||||||
|
|||||||
+28
-28
@@ -1,33 +1,33 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"boards/geprc-f722-v2",
|
"boards/geprc-f722-v2",
|
||||||
"boards/kakute-f7-mini-v3",
|
"boards/kakute-f7-mini-v3",
|
||||||
"libs/kaptan-actuator",
|
"libs/kaptan-actuator",
|
||||||
"libs/kaptan-control",
|
"libs/kaptan-control",
|
||||||
"libs/kaptan-config",
|
"libs/kaptan-config",
|
||||||
"libs/kaptan-config-derive",
|
"libs/kaptan-config-derive",
|
||||||
"libs/kaptan-file-mavlink",
|
"libs/kaptan-file-mavlink",
|
||||||
"libs/kaptan-data-channel",
|
"libs/kaptan-data-channel",
|
||||||
"libs/kaptan-dma",
|
"libs/kaptan-dma",
|
||||||
"libs/kaptan-drivers",
|
"libs/kaptan-drivers",
|
||||||
"libs/kaptan-err-reportable",
|
"libs/kaptan-err-reportable",
|
||||||
"libs/kaptan-err-reportable-macros",
|
"libs/kaptan-err-reportable-macros",
|
||||||
"libs/kaptan-flight-config",
|
"libs/kaptan-flight-config",
|
||||||
"libs/kaptan-flight-runtime",
|
"libs/kaptan-flight-runtime",
|
||||||
"libs/kaptan-i2c",
|
"libs/kaptan-i2c",
|
||||||
"libs/kaptan-math",
|
"libs/kaptan-math",
|
||||||
"libs/kaptan-mavlink",
|
"libs/kaptan-mavlink",
|
||||||
"libs/kaptan-log",
|
"libs/kaptan-log",
|
||||||
"libs/kaptan-diagnostics-mavlink",
|
"libs/kaptan-diagnostics-mavlink",
|
||||||
"libs/kaptan-navigation",
|
"libs/kaptan-navigation",
|
||||||
"libs/kaptan-safety",
|
"libs/kaptan-safety",
|
||||||
"libs/kaptan-sensor",
|
"libs/kaptan-sensor",
|
||||||
"libs/kaptan-spi",
|
"libs/kaptan-spi",
|
||||||
"libs/kaptan-time",
|
"libs/kaptan-time",
|
||||||
"libs/kaptan-tx",
|
"libs/kaptan-tx",
|
||||||
"platforms/stm32f7",
|
"platforms/stm32f7",
|
||||||
"tools/sitl",
|
"tools/sitl",
|
||||||
"tools/configurator",
|
"tools/configurator",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -14,9 +14,15 @@ firmware/
|
|||||||
└── tests/ firmware integration-test fixtures
|
└── tests/ firmware integration-test fixtures
|
||||||
```
|
```
|
||||||
|
|
||||||
Run Cargo commands from this directory:
|
This workspace owns its Makefile. From this directory, list its commands with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make help
|
||||||
cargo test --workspace --locked --offline
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -14,7 +14,9 @@ kaptan-config = { path = "../../libs/kaptan-config" }
|
|||||||
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
||||||
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
|
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
|
||||||
# board crate; the task graph and all RTIC source remain platform-owned.
|
# 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]]
|
[[bin]]
|
||||||
name = "kaptan-geprc-f722-v2"
|
name = "kaptan-geprc-f722-v2"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
override BOARD := geprc-f722-v2
|
||||||
|
|
||||||
|
include ../board.mk
|
||||||
@@ -13,7 +13,9 @@ kaptan-config = { path = "../../libs/kaptan-config" }
|
|||||||
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
||||||
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
|
# RTIC's attribute expansion emits absolute `rtic` support paths in the final
|
||||||
# board crate; the task graph and all RTIC source remain platform-owned.
|
# 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]]
|
[[bin]]
|
||||||
name = "kaptan-kakute-f7-mini-v3"
|
name = "kaptan-kakute-f7-mini-v3"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
override BOARD := kakute-f7-mini-v3
|
||||||
|
|
||||||
|
include ../board.mk
|
||||||
@@ -11,7 +11,9 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
|
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
|
||||||
kaptan-dma = { path = "../kaptan-dma" }
|
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-sensor = { path = "../kaptan-sensor" }
|
||||||
kaptan-time = { path = "../kaptan-time" }
|
kaptan-time = { path = "../kaptan-time" }
|
||||||
mavlink-core = { version = "0.18", default-features = false }
|
mavlink-core = { version = "0.18", default-features = false }
|
||||||
@@ -17,7 +17,9 @@ kaptan-err-reportable = { path = "../kaptan-err-reportable" }
|
|||||||
kaptan-dma = { path = "../kaptan-dma" }
|
kaptan-dma = { path = "../kaptan-dma" }
|
||||||
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
|
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
|
||||||
kaptan-tx = { path = "../kaptan-tx" }
|
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"
|
bitflags = "2"
|
||||||
num-derive = "0.4"
|
num-derive = "0.4"
|
||||||
num-traits = { version = "0.2", default-features = false }
|
num-traits = { version = "0.2", default-features = false }
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ kaptan-spi = { path = "../../libs/kaptan-spi" }
|
|||||||
kaptan-time = { path = "../../libs/kaptan-time" }
|
kaptan-time = { path = "../../libs/kaptan-time" }
|
||||||
cortex-m = "0.7"
|
cortex-m = "0.7"
|
||||||
cortex-m-rt = "0.7"
|
cortex-m-rt = "0.7"
|
||||||
rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] }
|
rtic = { version = "2.3", default-features = false, features = [
|
||||||
stm32f7 = { version = "0.16", default-features = false, features = ["rt", "stm32f722"] }
|
"thumbv7-backend",
|
||||||
|
] }
|
||||||
|
stm32f7 = { version = "0.16", default-features = false, features = [
|
||||||
|
"rt",
|
||||||
|
"stm32f722",
|
||||||
|
] }
|
||||||
panic-halt = "1"
|
panic-halt = "1"
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.90.0"
|
channel = "1.90.0"
|
||||||
components = ["cargo", "clippy", "rustfmt", "rust-analyzer", "llvm-tools-preview"]
|
components = [
|
||||||
|
"cargo",
|
||||||
|
"clippy",
|
||||||
|
"rustfmt",
|
||||||
|
"rust-analyzer",
|
||||||
|
"llvm-tools-preview",
|
||||||
|
]
|
||||||
targets = [
|
targets = [
|
||||||
"thumbv7em-none-eabihf",
|
"thumbv7em-none-eabihf",
|
||||||
"thumbv7em-none-eabi",
|
"thumbv7em-none-eabi",
|
||||||
"x86_64-unknown-linux-gnu",
|
"x86_64-unknown-linux-gnu",
|
||||||
]
|
]
|
||||||
@@ -166,18 +166,22 @@ def validation_errors(layout, section_list, load_segments):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for index, segment in enumerate(load_segments):
|
for index, segment in enumerate(load_segments):
|
||||||
if segment.memory_size and region_for_span(
|
if (
|
||||||
segment.virtual_address, segment.memory_size, layout
|
segment.memory_size
|
||||||
) is None:
|
and region_for_span(segment.virtual_address, segment.memory_size, layout)
|
||||||
|
is None
|
||||||
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"PT_LOAD[{index}] virtual span "
|
f"PT_LOAD[{index}] virtual span "
|
||||||
f"0x{segment.virtual_address:08X}.."
|
f"0x{segment.virtual_address:08X}.."
|
||||||
f"0x{segment.virtual_address + segment.memory_size:08X} "
|
f"0x{segment.virtual_address + segment.memory_size:08X} "
|
||||||
"is outside declared memory"
|
"is outside declared memory"
|
||||||
)
|
)
|
||||||
if segment.file_size and region_for_span(
|
if (
|
||||||
segment.physical_address, segment.file_size, layout
|
segment.file_size
|
||||||
) is None:
|
and region_for_span(segment.physical_address, segment.file_size, layout)
|
||||||
|
is None
|
||||||
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
f"PT_LOAD[{index}] physical span "
|
f"PT_LOAD[{index}] physical span "
|
||||||
f"0x{segment.physical_address:08X}.."
|
f"0x{segment.physical_address:08X}.."
|
||||||
@@ -200,9 +204,7 @@ def flash_operation_section_errors(layout, section_list):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
INSTRUCTION = re.compile(
|
INSTRUCTION = re.compile(r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$")
|
||||||
r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$"
|
|
||||||
)
|
|
||||||
DIRECT_BRANCH = re.compile(
|
DIRECT_BRANCH = re.compile(
|
||||||
r"^(?:b(?:eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)?"
|
r"^(?:b(?:eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)?"
|
||||||
r"(?:\.[nw])?|blx?|bx|cbz|cbnz)$"
|
r"(?:\.[nw])?|blx?|bx|cbz|cbnz)$"
|
||||||
@@ -321,7 +323,9 @@ def main():
|
|||||||
|
|
||||||
errors = flash_operation_section_errors(layout, section_list)
|
errors = flash_operation_section_errors(layout, section_list)
|
||||||
if errors:
|
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")
|
data = next(section for section in section_list if section.name == ".data")
|
||||||
objdump = llvm_tool("llvm-objdump")
|
objdump = llvm_tool("llvm-objdump")
|
||||||
errors = sram_control_flow_errors(
|
errors = sram_control_flow_errors(
|
||||||
@@ -337,7 +341,9 @@ def main():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if errors:
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||||
printf 'Usage: %s <firmware.elf> [memory.x]\n' "$0" >&2
|
printf 'Usage: %s <firmware.elf> [memory.x]\n' "$0" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
readonly input_elf="$1"
|
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"
|
readonly llvm_objcopy="$(rustc --print sysroot)/lib/rustlib/$host/bin/llvm-objcopy"
|
||||||
|
|
||||||
if [[ ! -f "$input_elf" ]]; then
|
if [[ ! -f "$input_elf" ]]; then
|
||||||
printf 'firmware ELF does not exist: %s\n' "$input_elf" >&2
|
printf 'firmware ELF does not exist: %s\n' "$input_elf" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$memory_x" ]]; then
|
if [[ ! -f "$memory_x" ]]; then
|
||||||
printf 'cannot find linker layout: %s\n' "$memory_x" >&2
|
printf 'cannot find linker layout: %s\n' "$memory_x" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ! -x "$llvm_objcopy" ]]; then
|
if [[ ! -x "$llvm_objcopy" ]]; then
|
||||||
printf 'llvm-objcopy is unavailable; install it with: rustup component add llvm-tools-preview\n' >&2
|
printf 'llvm-objcopy is unavailable; install it with: rustup component add llvm-tools-preview\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"$script_directory/analyze-fw.py" "$input_elf" "$memory_x"
|
"$script_directory/analyze-fw.py" "$input_elf" "$memory_x"
|
||||||
|
|||||||
@@ -14,18 +14,18 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
if ! cargo llvm-cov --version >/dev/null 2>&1; then
|
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
|
echo "cargo-llvm-cov is required; install it with: cargo install cargo-llvm-cov --locked" >&2
|
||||||
exit 127
|
exit 127
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! rustup toolchain list | grep -q '^nightly'; then
|
if ! rustup toolchain list | grep -q '^nightly'; then
|
||||||
echo "nightly with llvm-tools-preview is required for branch coverage" >&2
|
echo "nightly with llvm-tools-preview is required for branch coverage" >&2
|
||||||
exit 127
|
exit 127
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! python3 --version >/dev/null 2>&1; then
|
if ! python3 --version >/dev/null 2>&1; then
|
||||||
echo "python3 is required for strict source-level coverage accounting" >&2
|
echo "python3 is required for strict source-level coverage accounting" >&2
|
||||||
exit 127
|
exit 127
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 scripts/test-summarize-test-coverage.py
|
python3 scripts/test-summarize-test-coverage.py
|
||||||
@@ -46,50 +46,50 @@ coverage_root=$CARGO_TARGET_DIR
|
|||||||
core_incomplete=0
|
core_incomplete=0
|
||||||
|
|
||||||
report_package() {
|
report_package() {
|
||||||
package=$1
|
package=$1
|
||||||
source_path=$2
|
source_path=$2
|
||||||
shift 2
|
shift 2
|
||||||
case "$package" in
|
case "$package" in
|
||||||
kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety)
|
kaptan-math | kaptan-navigation | kaptan-control | kaptan-safety)
|
||||||
package_target_dir="$coverage_root/$package"
|
package_target_dir="$coverage_root/$package"
|
||||||
lcov_report="$coverage_root/$package.lcov"
|
lcov_report="$coverage_root/$package.lcov"
|
||||||
json_report="$coverage_root/$package.json"
|
json_report="$coverage_root/$package.json"
|
||||||
report_format=strict-core
|
report_format=strict-core
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
package_target_dir=$coverage_root
|
package_target_dir=$coverage_root
|
||||||
lcov_report="$coverage_root/$package.lcov"
|
lcov_report="$coverage_root/$package.lcov"
|
||||||
report_format=lcov
|
report_format=lcov
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
case "$report_format" in
|
case "$report_format" in
|
||||||
strict-core)
|
strict-core)
|
||||||
# Keep the strict core isolated, then export both representations
|
# Keep the strict core isolated, then export both representations
|
||||||
# from the same test run. LCOV `DA` defines source lines; JSON
|
# from the same test run. LCOV `DA` defines source lines; JSON
|
||||||
# coordinates define branch identities across generic instances.
|
# coordinates define branch identities across generic instances.
|
||||||
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
|
||||||
--branch \
|
--branch \
|
||||||
--no-report \
|
--no-report \
|
||||||
-p "$package" \
|
-p "$package" \
|
||||||
"$@"
|
"$@"
|
||||||
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
|
||||||
--lcov \
|
--lcov \
|
||||||
--output-path "$lcov_report"
|
--output-path "$lcov_report"
|
||||||
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \
|
||||||
--json \
|
--json \
|
||||||
--output-path "$json_report"
|
--output-path "$json_report"
|
||||||
totals=$(python3 scripts/summarize-test-coverage.py \
|
totals=$(python3 scripts/summarize-test-coverage.py \
|
||||||
"$lcov_report" "$json_report" "$source_path")
|
"$lcov_report" "$json_report" "$source_path")
|
||||||
;;
|
;;
|
||||||
lcov)
|
lcov)
|
||||||
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
|
RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \
|
||||||
--branch \
|
--branch \
|
||||||
--lcov \
|
--lcov \
|
||||||
--output-path "$lcov_report" \
|
--output-path "$lcov_report" \
|
||||||
-p "$package" \
|
-p "$package" \
|
||||||
"$@"
|
"$@"
|
||||||
totals=$(awk -v source_path="$source_path" '
|
totals=$(awk -v source_path="$source_path" '
|
||||||
/^SF:/ { selected = index($0, source_path) != 0 }
|
/^SF:/ { selected = index($0, source_path) != 0 }
|
||||||
selected && /^LF:/ { lines_found += substr($0, 4) }
|
selected && /^LF:/ { lines_found += substr($0, 4) }
|
||||||
selected && /^LH:/ { lines_hit += 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
|
printf "%d %d %d %d", lines_hit, lines_found, branches_hit, branches_found
|
||||||
}
|
}
|
||||||
' "$lcov_report")
|
' "$lcov_report")
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
set -- $totals
|
set -- $totals
|
||||||
printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4"
|
printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4"
|
||||||
|
|
||||||
case "$package" in
|
case "$package" in
|
||||||
kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety)
|
kaptan-math | kaptan-navigation | kaptan-control | kaptan-safety)
|
||||||
if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then
|
if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then
|
||||||
core_incomplete=1
|
core_incomplete=1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Host test coverage (reports: $CARGO_TARGET_DIR; strict core unions LCOV DA lines and JSON branches)"
|
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)"
|
echo "kaptan-kakute-f7-mini-v3 target-only/N/A (run its thumbv7em-none-eabihf check)"
|
||||||
|
|
||||||
if [ "$core_incomplete" -ne 0 ]; then
|
if [ "$core_incomplete" -ne 0 ]; then
|
||||||
echo "Strict core coverage is incomplete; reports above identify each crate." >&2
|
echo "Strict core coverage is incomplete; reports above identify each crate." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -34,7 +34,9 @@ class CoverageFormatError(ValueError):
|
|||||||
"""Coverage records cannot be safely summarized by source location."""
|
"""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."""
|
"""Yield selected file records after rejecting ambiguous macro expansion."""
|
||||||
exports = report.get("data")
|
exports = report.get("data")
|
||||||
if not isinstance(exports, list):
|
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":
|
if record == "end_of_record":
|
||||||
filename = None
|
filename = None
|
||||||
continue
|
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
|
continue
|
||||||
fields = record[3:].split(",", 2)
|
fields = record[3:].split(",", 2)
|
||||||
if len(fields) < 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])
|
line = int(fields[0])
|
||||||
count = int(fields[1])
|
count = int(fields[1])
|
||||||
except ValueError as error:
|
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:
|
if line <= 0 or count < 0:
|
||||||
raise CoverageFormatError(f"{filename}: invalid LCOV DA location or count")
|
raise CoverageFormatError(f"{filename}: invalid LCOV DA location or count")
|
||||||
key = (filename, line)
|
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
|
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."""
|
"""Return the union of selected JSON branch outcomes and source files."""
|
||||||
branches: dict[tuple[str, int, int, int, int], list[bool]] = {}
|
branches: dict[tuple[str, int, int, int, int], list[bool]] = {}
|
||||||
files: set[str] = set()
|
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 file_id < 0
|
||||||
or expanded_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:
|
if file_id != expanded_file_id:
|
||||||
raise CoverageFormatError(
|
raise CoverageFormatError(
|
||||||
f"{filename}:{start_line}:{start_column}: macro-expanded branch is present; "
|
f"{filename}:{start_line}:{start_column}: macro-expanded branch is present; "
|
||||||
@@ -175,9 +187,15 @@ def coverage_totals(
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("lcov_report", type=Path, help="LCOV report from cargo llvm-cov")
|
parser.add_argument(
|
||||||
parser.add_argument("json_report", type=Path, help="LLVM JSON report from cargo llvm-cov")
|
"lcov_report", type=Path, help="LCOV report from cargo llvm-cov"
|
||||||
parser.add_argument("source_path", help="workspace-relative path fragment to include")
|
)
|
||||||
|
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()
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -122,9 +122,7 @@ class FirmwareMemoryValidationTests(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
ANALYZER.region_usage("FLASH", LAYOUT, sections, segments), 0x50
|
ANALYZER.region_usage("FLASH", LAYOUT, sections, segments), 0x50
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10)
|
||||||
ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_rejects_loadable_virtual_and_physical_spans_outside_memory(self):
|
def test_rejects_loadable_virtual_and_physical_spans_outside_memory(self):
|
||||||
segments = [
|
segments = [
|
||||||
@@ -138,9 +136,7 @@ class FirmwareMemoryValidationTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_requires_one_executable_data_section_in_ram(self):
|
def test_requires_one_executable_data_section_in_ram(self):
|
||||||
valid = [ANALYZER.Section(".data", 0x20, 0x20000000, True, True)]
|
valid = [ANALYZER.Section(".data", 0x20, 0x20000000, True, True)]
|
||||||
non_executable = [
|
non_executable = [ANALYZER.Section(".data", 0x20, 0x20000000, True, False)]
|
||||||
ANALYZER.Section(".data", 0x20, 0x20000000, True, False)
|
|
||||||
]
|
|
||||||
in_flash = [ANALYZER.Section(".data", 0x20, 0x08000000, True, True)]
|
in_flash = [ANALYZER.Section(".data", 0x20, 0x08000000, True, True)]
|
||||||
|
|
||||||
self.assertEqual(ANALYZER.flash_operation_section_errors(LAYOUT, valid), [])
|
self.assertEqual(ANALYZER.flash_operation_section_errors(LAYOUT, valid), [])
|
||||||
|
|||||||
@@ -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 --
|
||||||
@@ -16,7 +16,9 @@ kaptan-flight-config = { path = "../../libs/kaptan-flight-config" }
|
|||||||
kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" }
|
kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" }
|
||||||
kaptan-log = { path = "../../libs/kaptan-log" }
|
kaptan-log = { path = "../../libs/kaptan-log" }
|
||||||
kaptan-math = { path = "../../libs/kaptan-math" }
|
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-safety = { path = "../../libs/kaptan-safety" }
|
||||||
kaptan-time = { path = "../../libs/kaptan-time" }
|
kaptan-time = { path = "../../libs/kaptan-time" }
|
||||||
minicbor = "0.25"
|
minicbor = "0.25"
|
||||||
|
|||||||
@@ -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,18 +12,18 @@ compose_file="$project_root/tools/sitl/compose.yaml"
|
|||||||
python3 "$project_root/tools/sitl/world-structure-test.py"
|
python3 "$project_root/tools/sitl/world-structure-test.py"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
status=$?
|
status=$?
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
# Preserve the first failing command's result, but retain enough evidence
|
# Preserve the first failing command's result, but retain enough evidence
|
||||||
# to distinguish a client/lease failure from a Gazebo or container start
|
# to distinguish a client/lease failure from a Gazebo or container start
|
||||||
# failure in CI. `down` otherwise removes the only useful diagnostics.
|
# failure in CI. `down` otherwise removes the only useful diagnostics.
|
||||||
if [ "$status" -ne 0 ]; then
|
if [ "$status" -ne 0 ]; then
|
||||||
echo "SITL smoke failed (exit $status); compose state and recent logs follow:" >&2
|
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" ps >&2 || true
|
||||||
docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true
|
docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true
|
||||||
fi
|
fi
|
||||||
docker compose -f "$compose_file" down --remove-orphans || true
|
docker compose -f "$compose_file" down --remove-orphans || true
|
||||||
exit "$status"
|
exit "$status"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ GROUND_PLANE_TOLERANCE_METRES = 0.10
|
|||||||
def arguments() -> argparse.Namespace:
|
def arguments() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
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)
|
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("north", type=float)
|
||||||
move.add_argument("east", type=float)
|
move.add_argument("east", type=float)
|
||||||
move.add_argument("up", type=float)
|
move.add_argument("up", type=float)
|
||||||
@@ -78,9 +82,15 @@ def arguments() -> argparse.Namespace:
|
|||||||
"land",
|
"land",
|
||||||
help="descend to an explicit local landing point and revoke virtual motor authority",
|
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(
|
||||||
land.add_argument("east", type=float, help="absolute local east landing coordinate in metres")
|
"north", type=float, help="absolute local north landing coordinate in metres"
|
||||||
land.add_argument("--wait", action="store_true", help="wait for the terminal landed result")
|
)
|
||||||
|
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(
|
land.add_argument(
|
||||||
"--verify-truth",
|
"--verify-truth",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -122,7 +132,9 @@ def latest_simulation_time(
|
|||||||
if magic != b"KSC1" or version != VERSION:
|
if magic != b"KSC1" or version != VERSION:
|
||||||
continue
|
continue
|
||||||
if latest is not None and simulation_time < latest:
|
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
|
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:
|
if latest is not None and latest & READY == READY:
|
||||||
return
|
return
|
||||||
if latest is None:
|
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 = (
|
names = (
|
||||||
(ESTIMATOR_READY, "estimator initialization"),
|
(ESTIMATOR_READY, "estimator initialization"),
|
||||||
(IMU_FRESH, "fresh simulator IMU"),
|
(IMU_FRESH, "fresh simulator IMU"),
|
||||||
@@ -174,10 +189,7 @@ def truth_sample_within_tolerance(
|
|||||||
or velocity > velocity_tolerance
|
or velocity > velocity_tolerance
|
||||||
or attitude_error > attitude_tolerance
|
or attitude_error > attitude_tolerance
|
||||||
or (require_stopped_motors and not motors_stopped)
|
or (require_stopped_motors and not motors_stopped)
|
||||||
or (
|
or (require_ground_plane and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES)
|
||||||
require_ground_plane
|
|
||||||
and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -217,11 +229,16 @@ def verify_truth(
|
|||||||
keepalive is not None
|
keepalive is not None
|
||||||
and control_socket is not None
|
and control_socket is not None
|
||||||
and simulation_time 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)
|
control_socket.sendto(keepalive, CONTROL)
|
||||||
next_keepalive_time = simulation_time + GOAL_KEEPALIVE_MICROSECONDS
|
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:
|
if payload is None or len(payload) != TRUTH_PACKET.size:
|
||||||
continue
|
continue
|
||||||
(
|
(
|
||||||
@@ -261,8 +278,12 @@ def verify_truth(
|
|||||||
attitude_w**2 + attitude_x**2 + attitude_y**2 + attitude_z**2
|
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:
|
if not math.isfinite(quaternion_norm) or abs(quaternion_norm - 1.0) > 1e-3:
|
||||||
raise RuntimeError("Gazebo truth contained a non-unit attitude quaternion")
|
raise RuntimeError(
|
||||||
attitude_error = 2.0 * math.acos(min(1.0, abs(attitude_w) / quaternion_norm))
|
"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)
|
motors = (motor_1, motor_2, motor_3, motor_4)
|
||||||
maximum_motor_fraction = max(abs(motor) for motor in motors)
|
maximum_motor_fraction = max(abs(motor) for motor in motors)
|
||||||
motors_stopped = all(abs(motor) <= 0.01 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:
|
if magic != b"KSL1" or version != VERSION or received_id != goal_id:
|
||||||
continue
|
continue
|
||||||
if lifecycle == GOAL_REJECTED:
|
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:
|
if lifecycle == GOAL_ACTIVE:
|
||||||
active = True
|
active = True
|
||||||
print(f"{action} active: north={north:g} m, east={east:g} m, up={up:g} m")
|
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)
|
or not math.isfinite(values.truth_hold_seconds)
|
||||||
)
|
)
|
||||||
if tolerances:
|
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:
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
|
||||||
client.bind(USER_CONTROL)
|
client.bind(USER_CONTROL)
|
||||||
if values.command == "ready":
|
if values.command == "ready":
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ class TruthAcceptanceTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_move_truth_contract_allows_an_armed_hover(self):
|
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):
|
def test_landing_truth_contract_requires_zero_motors_and_ground_plane(self):
|
||||||
self.assertFalse(self.acceptable(motors_stopped=False))
|
self.assertFalse(self.acceptable(motors_stopped=False))
|
||||||
@@ -77,23 +79,28 @@ class TruthAcceptanceTests(unittest.TestCase):
|
|||||||
arguments = command_goal.call_args.args
|
arguments = command_goal.call_args.args
|
||||||
self.assertEqual(arguments[0:2], (control_socket, clock_socket))
|
self.assertEqual(arguments[0:2], (control_socket, clock_socket))
|
||||||
magic, version, goal_id, north, east, up = self.client.GOAL.unpack(arguments[2])
|
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[3], goal_id)
|
||||||
self.assertEqual(arguments[4:], (
|
self.assertEqual(
|
||||||
1.0,
|
arguments[4:],
|
||||||
-2.0,
|
(
|
||||||
0.0,
|
1.0,
|
||||||
60.0,
|
-2.0,
|
||||||
True,
|
0.0,
|
||||||
True,
|
60.0,
|
||||||
0.75,
|
True,
|
||||||
0.25,
|
True,
|
||||||
0.35,
|
0.75,
|
||||||
0.75,
|
0.25,
|
||||||
self.client.GOAL_LANDED,
|
0.35,
|
||||||
True,
|
0.75,
|
||||||
"land",
|
self.client.GOAL_LANDED,
|
||||||
))
|
True,
|
||||||
|
"land",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ def check_world() -> None:
|
|||||||
world = root.find("./world")
|
world = root.find("./world")
|
||||||
if world is None:
|
if world is None:
|
||||||
fail("missing world")
|
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"}:
|
if model_names != {"ground", "sitl_reference_map", "kaptan_sitl_quad"}:
|
||||||
fail(
|
fail(
|
||||||
"the declared flat-world model may contain only ground, the visual reference map, "
|
"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 = model(root, "ground")
|
||||||
ground_collisions = ground.findall(".//collision")
|
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")
|
fail("ground must retain exactly one flat collision plane")
|
||||||
if ground_collisions[0].find("geometry/plane") is None:
|
if ground_collisions[0].find("geometry/plane") is None:
|
||||||
fail("ground collision must remain a flat plane")
|
fail("ground collision must remain a flat plane")
|
||||||
@@ -89,11 +93,15 @@ def check_world() -> None:
|
|||||||
if reference_map.findall(".//collision"):
|
if reference_map.findall(".//collision"):
|
||||||
fail("reference map must remain visual-only and cannot add obstacle physics")
|
fail("reference map must remain visual-only and cannot add obstacle physics")
|
||||||
if reference_map.findall(".//transparency"):
|
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")
|
reference_link = named(reference_map, "link", "reference_link")
|
||||||
actual_marks = {visual.get("name") for visual in reference_link.findall("visual")}
|
actual_marks = {visual.get("name") for visual in reference_link.findall("visual")}
|
||||||
if actual_marks != {"landing_pad"}:
|
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")
|
quad = model(root, "kaptan_sitl_quad")
|
||||||
if pose(quad)[:3] != (0.0, 0.0, 0.14):
|
if pose(quad)[:3] != (0.0, 0.0, 0.14):
|
||||||
@@ -122,7 +130,9 @@ def check_world() -> None:
|
|||||||
"body_collision",
|
"body_collision",
|
||||||
*(f"landing_foot_{foot}_collision" for foot in foot_positions),
|
*(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:
|
if actual_quad_collisions != expected_quad_collisions:
|
||||||
fail(
|
fail(
|
||||||
"quad collision geometry must remain its body and four declared landing feet; "
|
"quad collision geometry must remain its body and four declared landing feet; "
|
||||||
@@ -130,8 +140,12 @@ def check_world() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
sensors = quad.findall(".//sensor")
|
sensors = quad.findall(".//sensor")
|
||||||
if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [("imu", "imu")]:
|
if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [
|
||||||
fail("this local-state SITL world must not imply unmodeled visual/range/flow sensors")
|
("imu", "imu")
|
||||||
|
]:
|
||||||
|
fail(
|
||||||
|
"this local-state SITL world must not imply unmodeled visual/range/flow sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
outputs = { nixpkgs, ... }:
|
outputs =
|
||||||
|
{ nixpkgs, ... }:
|
||||||
let
|
let
|
||||||
systems = [
|
systems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
@@ -12,13 +13,16 @@
|
|||||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells = forAllSystems (system:
|
devShells = forAllSystems (
|
||||||
|
system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
python = pkgs.python3.withPackages (pythonPackages: with pythonPackages; [
|
python = pkgs.python3.withPackages (
|
||||||
pymavlink
|
pythonPackages: with pythonPackages; [
|
||||||
pyserial
|
pymavlink
|
||||||
]);
|
pyserial
|
||||||
|
]
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
@@ -26,9 +30,15 @@
|
|||||||
pkgs.rustup
|
pkgs.rustup
|
||||||
pkgs.dfu-util
|
pkgs.dfu-util
|
||||||
pkgs.mdbook
|
pkgs.mdbook
|
||||||
|
pkgs.ripgrep
|
||||||
|
pkgs.ruff
|
||||||
|
pkgs.shfmt
|
||||||
|
pkgs.nixfmt
|
||||||
|
pkgs.taplo
|
||||||
python
|
python
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user