diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a0f6f54 --- /dev/null +++ b/Makefile @@ -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 -- [arguments...]' \ + ' make 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 fmt, clippy, test, docs, pre-commit, check, or clean' \ + ' firmware -- Pass other arguments to cargo' \ + ' firmware/boards -- ' \ + ' Build and package every board; args go to cargo build' \ + ' firmware/boards/ -- ' \ + ' Build and package one board; args go to cargo build' \ + ' firmware/boards/ flash -- ' \ + ' Build, package, and flash one board over DFU' \ + ' docs Test and build the mdBook project' \ + ' docs -- Pass explicit arguments to mdbook' \ + ' firmware/tools/configurator -- ' \ + ' Pass args to kaptan-configurator' \ + ' firmware/tools/sitl -- ' \ + ' 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 diff --git a/docs/Makefile b/docs/Makefile index 0877f22..ddce0dd 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -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 -- ' '' \ + 'With no arguments, mdbook test and mdbook build are run.' \ + 'Example: make docs -- build' '' \ + 'Project target:' \ + ' check Run mdbook test and mdbook build' + +endif + +check: + $(MDBOOK) test $(MDBOOK) build -serve: - $(MDBOOK) serve --open - -test: - $(MDBOOK) test +run: + @exec python3 ../scripts/make-argv.py exec run $(MDBOOK) diff --git a/docs/src/boards/geprc-f722-v2.md b/docs/src/boards/geprc-f722-v2.md index 8d2746b..cdeb878 100644 --- a/docs/src/boards/geprc-f722-v2.md +++ b/docs/src/boards/geprc-f722-v2.md @@ -17,13 +17,10 @@ the board's immutable typestate literal. ## Build and package -From the firmware workspace: +From the repository root: ```sh -cd firmware -cargo build --release --locked -p kaptan-geprc-f722-v2 \ - --target thumbv7em-none-eabihf -./scripts/create-dfu-image.sh target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2 +make firmware/boards/geprc-f722-v2 ``` The packaging script validates the ELF against the shared F722 linker layout @@ -40,9 +37,7 @@ listed `0483:df11` target before flashing: ```sh dfu-util --list -dfu-util --device 0483:df11 --alt 0 \ - --dfuse-address 0x08000000:leave \ - --download target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2.bin +make firmware/boards/geprc-f722-v2 flash ``` ## Telemetry wiring @@ -58,8 +53,8 @@ Connect a 3.3 V USB-UART adapter with crossed data lines and common ground: Then run: ```sh -cargo run --locked -p kaptan-configurator -- \ - --source-system 255 --serial /dev/serial/by-id/ --baud 115200 +make firmware/tools/configurator -- \ + --source-system 255 --serial /dev/serial/by-id/ --baud 115200 ``` Open `http://127.0.0.1:4861`, Save the discovered fields, and manually diff --git a/docs/src/boards/kakute-f7-mini-v3.md b/docs/src/boards/kakute-f7-mini-v3.md index dbd8cc4..6e8a848 100644 --- a/docs/src/boards/kakute-f7-mini-v3.md +++ b/docs/src/boards/kakute-f7-mini-v3.md @@ -17,13 +17,10 @@ typestate literal. ## Build and package -From the firmware workspace: +From the repository root: ```sh -cd firmware -cargo build --release --locked -p kaptan-kakute-f7-mini-v3 \ - --target thumbv7em-none-eabihf -./scripts/create-dfu-image.sh target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3 +make firmware/boards/kakute-f7-mini-v3 ``` The packaging script uses the shared F722 linker layout and prints the flash @@ -45,9 +42,7 @@ application offset. ```sh dfu-util --list -dfu-util --device 0483:df11 --alt 0 \ - --dfuse-address 0x08000000:leave \ - --download target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3.bin +make firmware/boards/kakute-f7-mini-v3 flash ``` ## Telemetry wiring @@ -63,8 +58,8 @@ Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground: Launch the target-described web UI: ```sh -cargo run --locked -p kaptan-configurator -- \ - --source-system 255 --serial /dev/serial/by-id/ --baud 115200 +make firmware/tools/configurator -- \ + --source-system 255 --serial /dev/serial/by-id/ --baud 115200 ``` Open `http://127.0.0.1:4861`, edit the discovered fields, press Save, and diff --git a/docs/src/configuration.md b/docs/src/configuration.md index ab8f891..6a0fc1d 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -17,11 +17,11 @@ parameter catalog. ## Web configurator Build and flash the board image, connect its MAVLink UART directly to a 3.3 V -USB-UART adapter, then run from `firmware`: +USB-UART adapter, then run from the repository root: ```sh -cargo run --locked -p kaptan-configurator -- \ - --source-system 255 --serial /dev/serial/by-id/ --baud 115200 +make firmware/tools/configurator -- \ + --source-system 255 --serial /dev/serial/by-id/ --baud 115200 ``` `--source-system` is the configurator's exact MAVLink source identity. Every diff --git a/docs/src/development.md b/docs/src/development.md index 76b35f7..6860bc0 100644 --- a/docs/src/development.md +++ b/docs/src/development.md @@ -1,27 +1,46 @@ # Development and verification +## Command layout + +Each component owns its Makefile: the Cargo workspace, all-board workflows, +individual boards, tools, and documentation each define commands in their own +directory. The root Makefile contains repository-wide workflows and routes a +directory path to that directory's Makefile. + +The standard `--` ends Make's own options. Every following argument uses the +selected application's native command line. For example, +`make firmware -- test --workspace --locked --offline` runs `cargo test +--workspace --locked --offline` from `firmware`, while +`make firmware/tools/sitl -- ready` runs `kaptan-sim ready`. Board paths are +the complete build-and-package command, so they need no invented operation +name. + ## Fast host checks ```sh -cd firmware -cargo test --workspace --offline -cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings -cargo clippy -p platform-stm32f7 --all-targets --offline -- -D warnings -cargo fmt --all --check -git diff --check +make firmware -- fmt --all --check +make firmware -- test --workspace --locked --offline +make firmware -- clippy --workspace --all-targets \ + --exclude kaptan-geprc-f722-v2 \ + --exclude kaptan-kakute-f7-mini-v3 --locked --offline -- -D warnings ``` ## Target checks ```sh -cd firmware -cargo clippy -p kaptan-geprc-f722-v2 -p kaptan-kakute-f7-mini-v3 \ - --target thumbv7em-none-eabihf --offline -- -D warnings -cargo build --release --locked \ +make firmware/boards +make firmware -- clippy \ -p kaptan-geprc-f722-v2 -p kaptan-kakute-f7-mini-v3 \ - --target thumbv7em-none-eabihf --offline + --target thumbv7em-none-eabihf --locked --offline -- -D warnings ``` +`make firmware docs` builds Rust API documentation with warnings denied. +`make docs` checks and builds the separate mdBook project. `make pre-commit` +runs both documentation checks along with repository formatting, host and +embedded Clippy, Rust and script tests, and SITL structure tests. `make check` +adds a whitespace check. `make build` builds and packages release firmware and +builds the mdBook project. + ## Strict core coverage ```sh @@ -38,10 +57,10 @@ the gate. ## Simulation acceptance ```sh -cd firmware -python3 tools/sitl/world-structure-test.py -python3 -m unittest tools/sitl/test_kaptan_sim.py -tools/sitl/e2e-smoke.sh +cd firmware/tools/sitl +python3 world-structure-test.py +python3 -m unittest test_kaptan_sim.py +./e2e-smoke.sh ``` The Docker smoke is intentionally separate from host unit tests because it diff --git a/docs/src/errors.md b/docs/src/errors.md index 7c972c1..08f164e 100644 --- a/docs/src/errors.md +++ b/docs/src/errors.md @@ -2,7 +2,7 @@ Public reportable Kaptan error enums derive `KaptanError`: -```rust +```rust,ignore #[derive(Debug, kaptan_err_reportable::KaptanError)] enum SensorError { #[kaptan_error(id = 12345)] diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 58a6a67..68fad69 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -11,12 +11,16 @@ The repository development shell also supplies `dfu-util` and the checked nix develop ``` -For host development, run: +From the repository root, discover the developer commands with: ```sh -cd firmware -cargo test --workspace --offline -cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings +make help +``` + +Run the repository checks with: + +```sh +make pre-commit ``` Bare-metal board packages are not host-test targets. Check them with the @@ -42,7 +46,7 @@ The manual is an [mdBook](https://rust-lang.github.io/mdBook/) project. The repository's Nix development shell provides the checked tool version: ```sh -nix develop -c make -C docs build +nix develop -c make docs -- build ``` The generated static site is written to `target/docs/`. diff --git a/docs/src/logs.md b/docs/src/logs.md index 3da1fc7..c4871cd 100644 --- a/docs/src/logs.md +++ b/docs/src/logs.md @@ -2,7 +2,7 @@ Kaptan code uses the familiar logging macros on both host and flight targets: -```rust +```rust,ignore kaptan_log::debug!("estimator waiting for IMU"); kaptan_log::info!("configuration loaded"); kaptan_log::warn!("range aid stale"); diff --git a/docs/src/reference.md b/docs/src/reference.md index 7be7e7d..64fa469 100644 --- a/docs/src/reference.md +++ b/docs/src/reference.md @@ -21,24 +21,8 @@ and file contracts, [Logs](logs.md) for receiver decoding and filtering, and Before review, run: ```sh -cd firmware -cargo fmt --all --check -cargo test --workspace --locked --offline -cargo clippy --workspace --all-targets \ - --exclude kaptan-kakute-f7-mini-v3 \ - --exclude kaptan-geprc-f722-v2 --locked --offline -- -D warnings -cargo clippy -p kaptan-kakute-f7-mini-v3 -p kaptan-geprc-f722-v2 \ - --target thumbv7em-none-eabihf --locked --offline -- -D warnings -cargo build --release --locked --offline \ - -p kaptan-kakute-f7-mini-v3 -p kaptan-geprc-f722-v2 \ - --target thumbv7em-none-eabihf -python3 -m unittest scripts/test-analyze-fw.py -scripts/analyze-fw.py \ - target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3 \ - platforms/stm32f7/link/f722/memory.x -scripts/analyze-fw.py \ - target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2 \ - platforms/stm32f7/link/f722/memory.x +make pre-commit +make build ``` The target-specific lint verifies the local configuration receiver and journal diff --git a/docs/src/safety-boundary.md b/docs/src/safety-boundary.md index efd99e6..0c9a592 100644 --- a/docs/src/safety-boundary.md +++ b/docs/src/safety-boundary.md @@ -26,8 +26,8 @@ authority. Run the structural guard after any platform or board change: ```sh -cd firmware -cargo test -p platform-stm32f7 --test platform_safety_boundary --offline +make firmware -- test -p platform-stm32f7 \ + --test platform_safety_boundary --locked --offline ``` This boundary is deliberately stronger than an application-level disarm flag: diff --git a/docs/src/sitl.md b/docs/src/sitl.md index e9ed2dd..4d4ed6f 100644 --- a/docs/src/sitl.md +++ b/docs/src/sitl.md @@ -11,19 +11,17 @@ It is a pre-bench software gate, not a physical-flight validation. ## Run the closed loop ```sh -cd firmware -docker compose -f tools/sitl/compose.yaml up -d --build -tools/sitl/kaptan-sim ready -tools/sitl/kaptan-sim move 5 3 1 --wait -tools/sitl/kaptan-sim land 0 0 --wait -docker compose -f tools/sitl/compose.yaml down +docker compose -f firmware/tools/sitl/compose.yaml up -d --build +make firmware/tools/sitl -- ready +make firmware/tools/sitl -- move 5 3 1 --wait +make firmware/tools/sitl -- land 0 0 --wait +docker compose -f firmware/tools/sitl/compose.yaml down ``` For the reproducible command-path gate, use: ```sh -cd firmware -tools/sitl/e2e-smoke.sh +firmware/tools/sitl/e2e-smoke.sh ``` The smoke scenario verifies ascent, translation, descent, landing, flat-plane diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 3211d39..2592dc8 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -1,33 +1,33 @@ [workspace] members = [ - "boards/geprc-f722-v2", - "boards/kakute-f7-mini-v3", - "libs/kaptan-actuator", - "libs/kaptan-control", - "libs/kaptan-config", - "libs/kaptan-config-derive", - "libs/kaptan-file-mavlink", - "libs/kaptan-data-channel", - "libs/kaptan-dma", - "libs/kaptan-drivers", - "libs/kaptan-err-reportable", - "libs/kaptan-err-reportable-macros", - "libs/kaptan-flight-config", - "libs/kaptan-flight-runtime", - "libs/kaptan-i2c", - "libs/kaptan-math", - "libs/kaptan-mavlink", - "libs/kaptan-log", - "libs/kaptan-diagnostics-mavlink", - "libs/kaptan-navigation", - "libs/kaptan-safety", - "libs/kaptan-sensor", - "libs/kaptan-spi", - "libs/kaptan-time", - "libs/kaptan-tx", - "platforms/stm32f7", - "tools/sitl", - "tools/configurator", + "boards/geprc-f722-v2", + "boards/kakute-f7-mini-v3", + "libs/kaptan-actuator", + "libs/kaptan-control", + "libs/kaptan-config", + "libs/kaptan-config-derive", + "libs/kaptan-file-mavlink", + "libs/kaptan-data-channel", + "libs/kaptan-dma", + "libs/kaptan-drivers", + "libs/kaptan-err-reportable", + "libs/kaptan-err-reportable-macros", + "libs/kaptan-flight-config", + "libs/kaptan-flight-runtime", + "libs/kaptan-i2c", + "libs/kaptan-math", + "libs/kaptan-mavlink", + "libs/kaptan-log", + "libs/kaptan-diagnostics-mavlink", + "libs/kaptan-navigation", + "libs/kaptan-safety", + "libs/kaptan-sensor", + "libs/kaptan-spi", + "libs/kaptan-time", + "libs/kaptan-tx", + "platforms/stm32f7", + "tools/sitl", + "tools/configurator", ] resolver = "2" diff --git a/firmware/Makefile b/firmware/Makefile new file mode 100644 index 0000000..286d14a --- /dev/null +++ b/firmware/Makefile @@ -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 -- ' '' \ + '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 diff --git a/firmware/README.md b/firmware/README.md index 9fe7776..1c8df4e 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -14,9 +14,15 @@ firmware/ └── tests/ firmware integration-test fixtures ``` -Run Cargo commands from this directory: +This workspace owns its Makefile. From this directory, list its commands with: ```sh -cd firmware -cargo test --workspace --locked --offline +make help ``` + +From the repository root, the firmware path is a direct Cargo proxy. For +example, `make firmware -- test --workspace --locked --offline` runs the host +test suite. Build and package every supported board with +`make firmware/boards`, or select one board by its directory path. +Run `make firmware clippy` for Rust lints and `make firmware docs` for API +documentation with warnings denied. diff --git a/firmware/boards/Makefile b/firmware/boards/Makefile new file mode 100644 index 0000000..394d1fb --- /dev/null +++ b/firmware/boards/Makefile @@ -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 -- ' '' \ + 'With no arguments, every board is built and packaged.' + +endif diff --git a/firmware/boards/board.mk b/firmware/boards/board.mk new file mode 100644 index 0000000..3625f54 --- /dev/null +++ b/firmware/boards/board.mk @@ -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) -- ' '' \ + ' make firmware/boards/$(BOARD) flash -- ' '' \ + 'With no arguments, this board is built and packaged.' \ + 'The flash target builds, packages, and writes this board over DFU.' + +endif + +endif diff --git a/firmware/boards/geprc-f722-v2/Cargo.toml b/firmware/boards/geprc-f722-v2/Cargo.toml index b8112fe..e367945 100644 --- a/firmware/boards/geprc-f722-v2/Cargo.toml +++ b/firmware/boards/geprc-f722-v2/Cargo.toml @@ -14,7 +14,9 @@ kaptan-config = { path = "../../libs/kaptan-config" } platform-stm32f7 = { path = "../../platforms/stm32f7" } # RTIC's attribute expansion emits absolute `rtic` support paths in the final # board crate; the task graph and all RTIC source remain platform-owned. -rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] } +rtic = { version = "2.3", default-features = false, features = [ + "thumbv7-backend", +] } [[bin]] name = "kaptan-geprc-f722-v2" diff --git a/firmware/boards/geprc-f722-v2/Makefile b/firmware/boards/geprc-f722-v2/Makefile new file mode 100644 index 0000000..777a623 --- /dev/null +++ b/firmware/boards/geprc-f722-v2/Makefile @@ -0,0 +1,3 @@ +override BOARD := geprc-f722-v2 + +include ../board.mk diff --git a/firmware/boards/kakute-f7-mini-v3/Cargo.toml b/firmware/boards/kakute-f7-mini-v3/Cargo.toml index 4810773..70e5131 100644 --- a/firmware/boards/kakute-f7-mini-v3/Cargo.toml +++ b/firmware/boards/kakute-f7-mini-v3/Cargo.toml @@ -13,7 +13,9 @@ kaptan-config = { path = "../../libs/kaptan-config" } platform-stm32f7 = { path = "../../platforms/stm32f7" } # RTIC's attribute expansion emits absolute `rtic` support paths in the final # board crate; the task graph and all RTIC source remain platform-owned. -rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] } +rtic = { version = "2.3", default-features = false, features = [ + "thumbv7-backend", +] } [[bin]] name = "kaptan-kakute-f7-mini-v3" diff --git a/firmware/boards/kakute-f7-mini-v3/Makefile b/firmware/boards/kakute-f7-mini-v3/Makefile new file mode 100644 index 0000000..77f22ab --- /dev/null +++ b/firmware/boards/kakute-f7-mini-v3/Makefile @@ -0,0 +1,3 @@ +override BOARD := kakute-f7-mini-v3 + +include ../board.mk diff --git a/firmware/libs/kaptan-data-channel/Cargo.toml b/firmware/libs/kaptan-data-channel/Cargo.toml index 57849d0..0492ea0 100644 --- a/firmware/libs/kaptan-data-channel/Cargo.toml +++ b/firmware/libs/kaptan-data-channel/Cargo.toml @@ -11,7 +11,9 @@ workspace = true [dependencies] kaptan-err-reportable = { path = "../kaptan-err-reportable" } kaptan-dma = { path = "../kaptan-dma" } -kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["sensor-telemetry"] } +kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = [ + "sensor-telemetry", +] } kaptan-sensor = { path = "../kaptan-sensor" } kaptan-time = { path = "../kaptan-time" } mavlink-core = { version = "0.18", default-features = false } diff --git a/firmware/libs/kaptan-mavlink/Cargo.toml b/firmware/libs/kaptan-mavlink/Cargo.toml index aa4809c..18f39db 100644 --- a/firmware/libs/kaptan-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-mavlink/Cargo.toml @@ -17,7 +17,9 @@ kaptan-err-reportable = { path = "../kaptan-err-reportable" } kaptan-dma = { path = "../kaptan-dma" } kaptan-sensor = { path = "../kaptan-sensor", optional = true } kaptan-tx = { path = "../kaptan-tx" } -mavlink-core = { version = "0.18", default-features = false, features = ["embedded"] } +mavlink-core = { version = "0.18", default-features = false, features = [ + "embedded", +] } bitflags = "2" num-derive = "0.4" num-traits = { version = "0.2", default-features = false } diff --git a/firmware/platforms/stm32f7/Cargo.toml b/firmware/platforms/stm32f7/Cargo.toml index 674423e..d917036 100644 --- a/firmware/platforms/stm32f7/Cargo.toml +++ b/firmware/platforms/stm32f7/Cargo.toml @@ -30,6 +30,11 @@ kaptan-spi = { path = "../../libs/kaptan-spi" } kaptan-time = { path = "../../libs/kaptan-time" } cortex-m = "0.7" cortex-m-rt = "0.7" -rtic = { version = "2.3", default-features = false, features = ["thumbv7-backend"] } -stm32f7 = { version = "0.16", default-features = false, features = ["rt", "stm32f722"] } +rtic = { version = "2.3", default-features = false, features = [ + "thumbv7-backend", +] } +stm32f7 = { version = "0.16", default-features = false, features = [ + "rt", + "stm32f722", +] } panic-halt = "1" diff --git a/firmware/rust-toolchain.toml b/firmware/rust-toolchain.toml index 41a2d69..ba07cbf 100644 --- a/firmware/rust-toolchain.toml +++ b/firmware/rust-toolchain.toml @@ -1,8 +1,14 @@ [toolchain] channel = "1.90.0" -components = ["cargo", "clippy", "rustfmt", "rust-analyzer", "llvm-tools-preview"] +components = [ + "cargo", + "clippy", + "rustfmt", + "rust-analyzer", + "llvm-tools-preview", +] targets = [ - "thumbv7em-none-eabihf", - "thumbv7em-none-eabi", - "x86_64-unknown-linux-gnu", + "thumbv7em-none-eabihf", + "thumbv7em-none-eabi", + "x86_64-unknown-linux-gnu", ] diff --git a/firmware/scripts/analyze-fw.py b/firmware/scripts/analyze-fw.py index 11e9be9..d78994c 100755 --- a/firmware/scripts/analyze-fw.py +++ b/firmware/scripts/analyze-fw.py @@ -166,18 +166,22 @@ def validation_errors(layout, section_list, load_segments): ) for index, segment in enumerate(load_segments): - if segment.memory_size and region_for_span( - segment.virtual_address, segment.memory_size, layout - ) is None: + if ( + segment.memory_size + and region_for_span(segment.virtual_address, segment.memory_size, layout) + is None + ): errors.append( f"PT_LOAD[{index}] virtual span " f"0x{segment.virtual_address:08X}.." f"0x{segment.virtual_address + segment.memory_size:08X} " "is outside declared memory" ) - if segment.file_size and region_for_span( - segment.physical_address, segment.file_size, layout - ) is None: + if ( + segment.file_size + and region_for_span(segment.physical_address, segment.file_size, layout) + is None + ): errors.append( f"PT_LOAD[{index}] physical span " f"0x{segment.physical_address:08X}.." @@ -200,9 +204,7 @@ def flash_operation_section_errors(layout, section_list): return [] -INSTRUCTION = re.compile( - r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$" -) +INSTRUCTION = re.compile(r"^\s*([0-9A-Fa-f]+):\s+([A-Za-z][A-Za-z0-9.]*)\s*(.*?)\s*$") DIRECT_BRANCH = re.compile( r"^(?:b(?:eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)?" r"(?:\.[nw])?|blx?|bx|cbz|cbnz)$" @@ -321,7 +323,9 @@ def main(): errors = flash_operation_section_errors(layout, section_list) if errors: - raise ValueError("Flash-operation placement validation failed:\n " + "\n ".join(errors)) + raise ValueError( + "Flash-operation placement validation failed:\n " + "\n ".join(errors) + ) data = next(section for section in section_list if section.name == ".data") objdump = llvm_tool("llvm-objdump") errors = sram_control_flow_errors( @@ -337,7 +341,9 @@ def main(): ), ) if errors: - raise ValueError("Flash-operation control-flow validation failed:\n " + "\n ".join(errors)) + raise ValueError( + "Flash-operation control-flow validation failed:\n " + "\n ".join(errors) + ) if __name__ == "__main__": diff --git a/firmware/scripts/create-dfu-image.sh b/firmware/scripts/create-dfu-image.sh index 9d4a3c8..e117ff3 100755 --- a/firmware/scripts/create-dfu-image.sh +++ b/firmware/scripts/create-dfu-image.sh @@ -2,8 +2,8 @@ set -euo pipefail if [[ $# -lt 1 || $# -gt 2 ]]; then - printf 'Usage: %s [memory.x]\n' "$0" >&2 - exit 2 + printf 'Usage: %s [memory.x]\n' "$0" >&2 + exit 2 fi readonly input_elf="$1" @@ -15,18 +15,18 @@ readonly host="$(rustc -vV | sed -n 's/^host: //p')" readonly llvm_objcopy="$(rustc --print sysroot)/lib/rustlib/$host/bin/llvm-objcopy" if [[ ! -f "$input_elf" ]]; then - printf 'firmware ELF does not exist: %s\n' "$input_elf" >&2 - exit 1 + printf 'firmware ELF does not exist: %s\n' "$input_elf" >&2 + exit 1 fi if [[ ! -f "$memory_x" ]]; then - printf 'cannot find linker layout: %s\n' "$memory_x" >&2 - exit 1 + printf 'cannot find linker layout: %s\n' "$memory_x" >&2 + exit 1 fi if [[ ! -x "$llvm_objcopy" ]]; then - printf 'llvm-objcopy is unavailable; install it with: rustup component add llvm-tools-preview\n' >&2 - exit 1 + printf 'llvm-objcopy is unavailable; install it with: rustup component add llvm-tools-preview\n' >&2 + exit 1 fi "$script_directory/analyze-fw.py" "$input_elf" "$memory_x" diff --git a/firmware/scripts/report-test-coverage.sh b/firmware/scripts/report-test-coverage.sh index f848a82..62524f0 100755 --- a/firmware/scripts/report-test-coverage.sh +++ b/firmware/scripts/report-test-coverage.sh @@ -14,18 +14,18 @@ set -eu if ! cargo llvm-cov --version >/dev/null 2>&1; then - echo "cargo-llvm-cov is required; install it with: cargo install cargo-llvm-cov --locked" >&2 - exit 127 + echo "cargo-llvm-cov is required; install it with: cargo install cargo-llvm-cov --locked" >&2 + exit 127 fi if ! rustup toolchain list | grep -q '^nightly'; then - echo "nightly with llvm-tools-preview is required for branch coverage" >&2 - exit 127 + echo "nightly with llvm-tools-preview is required for branch coverage" >&2 + exit 127 fi if ! python3 --version >/dev/null 2>&1; then - echo "python3 is required for strict source-level coverage accounting" >&2 - exit 127 + echo "python3 is required for strict source-level coverage accounting" >&2 + exit 127 fi python3 scripts/test-summarize-test-coverage.py @@ -46,50 +46,50 @@ coverage_root=$CARGO_TARGET_DIR core_incomplete=0 report_package() { - package=$1 - source_path=$2 - shift 2 - case "$package" in - kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety) - package_target_dir="$coverage_root/$package" - lcov_report="$coverage_root/$package.lcov" - json_report="$coverage_root/$package.json" - report_format=strict-core - ;; - *) - package_target_dir=$coverage_root - lcov_report="$coverage_root/$package.lcov" - report_format=lcov - ;; - esac + package=$1 + source_path=$2 + shift 2 + case "$package" in + kaptan-math | kaptan-navigation | kaptan-control | kaptan-safety) + package_target_dir="$coverage_root/$package" + lcov_report="$coverage_root/$package.lcov" + json_report="$coverage_root/$package.json" + report_format=strict-core + ;; + *) + package_target_dir=$coverage_root + lcov_report="$coverage_root/$package.lcov" + report_format=lcov + ;; + esac - case "$report_format" in - strict-core) - # Keep the strict core isolated, then export both representations - # from the same test run. LCOV `DA` defines source lines; JSON - # coordinates define branch identities across generic instances. - RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \ - --branch \ - --no-report \ - -p "$package" \ - "$@" - RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \ - --lcov \ - --output-path "$lcov_report" - RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \ - --json \ - --output-path "$json_report" - totals=$(python3 scripts/summarize-test-coverage.py \ - "$lcov_report" "$json_report" "$source_path") - ;; - lcov) - RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \ - --branch \ - --lcov \ - --output-path "$lcov_report" \ - -p "$package" \ - "$@" - totals=$(awk -v source_path="$source_path" ' + case "$report_format" in + strict-core) + # Keep the strict core isolated, then export both representations + # from the same test run. LCOV `DA` defines source lines; JSON + # coordinates define branch identities across generic instances. + RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \ + --branch \ + --no-report \ + -p "$package" \ + "$@" + RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \ + --lcov \ + --output-path "$lcov_report" + RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov report \ + --json \ + --output-path "$json_report" + totals=$(python3 scripts/summarize-test-coverage.py \ + "$lcov_report" "$json_report" "$source_path") + ;; + lcov) + RUSTC_BOOTSTRAP=1 CARGO_TARGET_DIR="$package_target_dir" cargo llvm-cov \ + --branch \ + --lcov \ + --output-path "$lcov_report" \ + -p "$package" \ + "$@" + totals=$(awk -v source_path="$source_path" ' /^SF:/ { selected = index($0, source_path) != 0 } selected && /^LF:/ { lines_found += substr($0, 4) } selected && /^LH:/ { lines_hit += substr($0, 4) } @@ -99,18 +99,18 @@ report_package() { printf "%d %d %d %d", lines_hit, lines_found, branches_hit, branches_found } ' "$lcov_report") - ;; - esac - set -- $totals - printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4" + ;; + esac + set -- $totals + printf '%-26s lines %s/%s, branches %s/%s\n' "$package" "$1" "$2" "$3" "$4" - case "$package" in - kaptan-math|kaptan-navigation|kaptan-control|kaptan-safety) - if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then - core_incomplete=1 - fi - ;; - esac + case "$package" in + kaptan-math | kaptan-navigation | kaptan-control | kaptan-safety) + if [ "$1" -ne "$2" ] || [ "$3" -ne "$4" ]; then + core_incomplete=1 + fi + ;; + esac } echo "Host test coverage (reports: $CARGO_TARGET_DIR; strict core unions LCOV DA lines and JSON branches)" @@ -135,6 +135,6 @@ echo "kaptan-geprc-f722-v2 target-only/N/A (run its thumbv7em-none-eabihf c echo "kaptan-kakute-f7-mini-v3 target-only/N/A (run its thumbv7em-none-eabihf check)" if [ "$core_incomplete" -ne 0 ]; then - echo "Strict core coverage is incomplete; reports above identify each crate." >&2 - exit 1 + echo "Strict core coverage is incomplete; reports above identify each crate." >&2 + exit 1 fi diff --git a/firmware/scripts/summarize-test-coverage.py b/firmware/scripts/summarize-test-coverage.py index 929edb8..3d8a722 100644 --- a/firmware/scripts/summarize-test-coverage.py +++ b/firmware/scripts/summarize-test-coverage.py @@ -34,7 +34,9 @@ class CoverageFormatError(ValueError): """Coverage records cannot be safely summarized by source location.""" -def selected_files(report: dict[object, object], source_path: str) -> Iterable[dict[object, object]]: +def selected_files( + report: dict[object, object], source_path: str +) -> Iterable[dict[object, object]]: """Yield selected file records after rejecting ambiguous macro expansion.""" exports = report.get("data") if not isinstance(exports, list): @@ -78,7 +80,11 @@ def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]] if record == "end_of_record": filename = None continue - if filename is None or source_path not in filename or not record.startswith("DA:"): + if ( + filename is None + or source_path not in filename + or not record.startswith("DA:") + ): continue fields = record[3:].split(",", 2) if len(fields) < 2: @@ -87,7 +93,9 @@ def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]] line = int(fields[0]) count = int(fields[1]) except ValueError as error: - raise CoverageFormatError(f"{filename}: non-integer LCOV DA record") from error + raise CoverageFormatError( + f"{filename}: non-integer LCOV DA record" + ) from error if line <= 0 or count < 0: raise CoverageFormatError(f"{filename}: invalid LCOV DA location or count") key = (filename, line) @@ -100,7 +108,9 @@ def lcov_line_totals(report: str, source_path: str) -> tuple[int, int, set[str]] return sum(lines.values()), len(lines), files -def branch_totals(report: dict[object, object], source_path: str) -> tuple[int, int, set[str]]: +def branch_totals( + report: dict[object, object], source_path: str +) -> tuple[int, int, set[str]]: """Return the union of selected JSON branch outcomes and source files.""" branches: dict[tuple[str, int, int, int, int], list[bool]] = {} files: set[str] = set() @@ -142,7 +152,9 @@ def branch_totals(report: dict[object, object], source_path: str) -> tuple[int, or file_id < 0 or expanded_file_id < 0 ): - raise CoverageFormatError(f"{filename}: invalid branch location or count") + raise CoverageFormatError( + f"{filename}: invalid branch location or count" + ) if file_id != expanded_file_id: raise CoverageFormatError( f"{filename}:{start_line}:{start_column}: macro-expanded branch is present; " @@ -175,9 +187,15 @@ def coverage_totals( def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("lcov_report", type=Path, help="LCOV report from cargo llvm-cov") - parser.add_argument("json_report", type=Path, help="LLVM JSON report from cargo llvm-cov") - parser.add_argument("source_path", help="workspace-relative path fragment to include") + parser.add_argument( + "lcov_report", type=Path, help="LCOV report from cargo llvm-cov" + ) + parser.add_argument( + "json_report", type=Path, help="LLVM JSON report from cargo llvm-cov" + ) + parser.add_argument( + "source_path", help="workspace-relative path fragment to include" + ) arguments = parser.parse_args() try: diff --git a/firmware/scripts/test-analyze-fw.py b/firmware/scripts/test-analyze-fw.py index ee18d6c..ce92bcd 100644 --- a/firmware/scripts/test-analyze-fw.py +++ b/firmware/scripts/test-analyze-fw.py @@ -122,9 +122,7 @@ class FirmwareMemoryValidationTests(unittest.TestCase): self.assertEqual( ANALYZER.region_usage("FLASH", LAYOUT, sections, segments), 0x50 ) - self.assertEqual( - ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10 - ) + self.assertEqual(ANALYZER.region_usage("RAM", LAYOUT, sections, segments), 0x10) def test_rejects_loadable_virtual_and_physical_spans_outside_memory(self): segments = [ @@ -138,9 +136,7 @@ class FirmwareMemoryValidationTests(unittest.TestCase): def test_requires_one_executable_data_section_in_ram(self): valid = [ANALYZER.Section(".data", 0x20, 0x20000000, True, True)] - non_executable = [ - ANALYZER.Section(".data", 0x20, 0x20000000, True, False) - ] + non_executable = [ANALYZER.Section(".data", 0x20, 0x20000000, True, False)] in_flash = [ANALYZER.Section(".data", 0x20, 0x08000000, True, True)] self.assertEqual(ANALYZER.flash_operation_section_errors(LAYOUT, valid), []) diff --git a/firmware/tools/configurator/Makefile b/firmware/tools/configurator/Makefile new file mode 100644 index 0000000..75a7aa3 --- /dev/null +++ b/firmware/tools/configurator/Makefile @@ -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 -- diff --git a/firmware/tools/sitl/Cargo.toml b/firmware/tools/sitl/Cargo.toml index fa0847b..99f57f7 100644 --- a/firmware/tools/sitl/Cargo.toml +++ b/firmware/tools/sitl/Cargo.toml @@ -16,7 +16,9 @@ kaptan-flight-config = { path = "../../libs/kaptan-flight-config" } kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" } kaptan-log = { path = "../../libs/kaptan-log" } kaptan-math = { path = "../../libs/kaptan-math" } -kaptan-navigation = { path = "../../libs/kaptan-navigation", features = ["sitl"] } +kaptan-navigation = { path = "../../libs/kaptan-navigation", features = [ + "sitl", +] } kaptan-safety = { path = "../../libs/kaptan-safety" } kaptan-time = { path = "../../libs/kaptan-time" } minicbor = "0.25" diff --git a/firmware/tools/sitl/Makefile b/firmware/tools/sitl/Makefile new file mode 100644 index 0000000..c65fd47 --- /dev/null +++ b/firmware/tools/sitl/Makefile @@ -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 diff --git a/firmware/tools/sitl/e2e-smoke.sh b/firmware/tools/sitl/e2e-smoke.sh index 0718e64..75fa9a9 100755 --- a/firmware/tools/sitl/e2e-smoke.sh +++ b/firmware/tools/sitl/e2e-smoke.sh @@ -12,18 +12,18 @@ compose_file="$project_root/tools/sitl/compose.yaml" python3 "$project_root/tools/sitl/world-structure-test.py" cleanup() { - status=$? - trap - EXIT - # Preserve the first failing command's result, but retain enough evidence - # to distinguish a client/lease failure from a Gazebo or container start - # failure in CI. `down` otherwise removes the only useful diagnostics. - if [ "$status" -ne 0 ]; then - echo "SITL smoke failed (exit $status); compose state and recent logs follow:" >&2 - docker compose -f "$compose_file" ps >&2 || true - docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true - fi - docker compose -f "$compose_file" down --remove-orphans || true - exit "$status" + status=$? + trap - EXIT + # Preserve the first failing command's result, but retain enough evidence + # to distinguish a client/lease failure from a Gazebo or container start + # failure in CI. `down` otherwise removes the only useful diagnostics. + if [ "$status" -ne 0 ]; then + echo "SITL smoke failed (exit $status); compose state and recent logs follow:" >&2 + docker compose -f "$compose_file" ps >&2 || true + docker compose -f "$compose_file" logs --no-color --tail 250 >&2 || true + fi + docker compose -f "$compose_file" down --remove-orphans || true + exit "$status" } trap cleanup EXIT trap 'exit 130' INT diff --git a/firmware/tools/sitl/kaptan-sim b/firmware/tools/sitl/kaptan-sim index 5b67de5..28e1121 100755 --- a/firmware/tools/sitl/kaptan-sim +++ b/firmware/tools/sitl/kaptan-sim @@ -50,9 +50,13 @@ GROUND_PLANE_TOLERANCE_METRES = 0.10 def arguments() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) subcommands = parser.add_subparsers(dest="command", required=True) - ready = subcommands.add_parser("ready", help="wait for estimator and simulated sensors") + ready = subcommands.add_parser( + "ready", help="wait for estimator and simulated sensors" + ) ready.add_argument("--timeout", type=float, default=20.0) - move = subcommands.add_parser("move", help="command absolute local north/east/up metres") + move = subcommands.add_parser( + "move", help="command absolute local north/east/up metres" + ) move.add_argument("north", type=float) move.add_argument("east", type=float) move.add_argument("up", type=float) @@ -78,9 +82,15 @@ def arguments() -> argparse.Namespace: "land", help="descend to an explicit local landing point and revoke virtual motor authority", ) - land.add_argument("north", type=float, help="absolute local north landing coordinate in metres") - land.add_argument("east", type=float, help="absolute local east landing coordinate in metres") - land.add_argument("--wait", action="store_true", help="wait for the terminal landed result") + land.add_argument( + "north", type=float, help="absolute local north landing coordinate in metres" + ) + land.add_argument( + "east", type=float, help="absolute local east landing coordinate in metres" + ) + land.add_argument( + "--wait", action="store_true", help="wait for the terminal landed result" + ) land.add_argument( "--verify-truth", action="store_true", @@ -122,7 +132,9 @@ def latest_simulation_time( if magic != b"KSC1" or version != VERSION: continue if latest is not None and simulation_time < latest: - raise RuntimeError("Gazebo simulation clock reset; restart the command in the new epoch") + raise RuntimeError( + "Gazebo simulation clock reset; restart the command in the new epoch" + ) latest = simulation_time @@ -145,7 +157,10 @@ def wait_ready(socket_: socket.socket, timeout: float) -> None: if latest is not None and latest & READY == READY: return if latest is None: - raise RuntimeError("host SITL is not running; run docker compose -f tools/sitl/compose.yaml up -d --build") + raise RuntimeError( + "host SITL is not running; run docker compose -f " + "firmware/tools/sitl/compose.yaml up -d --build from the repository root" + ) names = ( (ESTIMATOR_READY, "estimator initialization"), (IMU_FRESH, "fresh simulator IMU"), @@ -174,10 +189,7 @@ def truth_sample_within_tolerance( or velocity > velocity_tolerance or attitude_error > attitude_tolerance or (require_stopped_motors and not motors_stopped) - or ( - require_ground_plane - and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES - ) + or (require_ground_plane and ground_plane_error > GROUND_PLANE_TOLERANCE_METRES) ) @@ -217,11 +229,16 @@ def verify_truth( keepalive is not None and control_socket is not None and simulation_time is not None - and (next_keepalive_time is None or simulation_time >= next_keepalive_time) + and ( + next_keepalive_time is None + or simulation_time >= next_keepalive_time + ) ): control_socket.sendto(keepalive, CONTROL) next_keepalive_time = simulation_time + GOAL_KEEPALIVE_MICROSECONDS - payload = recv(truth_socket, min(acquisition_deadline, time.monotonic() + 0.01)) + payload = recv( + truth_socket, min(acquisition_deadline, time.monotonic() + 0.01) + ) if payload is None or len(payload) != TRUTH_PACKET.size: continue ( @@ -261,8 +278,12 @@ def verify_truth( attitude_w**2 + attitude_x**2 + attitude_y**2 + attitude_z**2 ) if not math.isfinite(quaternion_norm) or abs(quaternion_norm - 1.0) > 1e-3: - raise RuntimeError("Gazebo truth contained a non-unit attitude quaternion") - attitude_error = 2.0 * math.acos(min(1.0, abs(attitude_w) / quaternion_norm)) + raise RuntimeError( + "Gazebo truth contained a non-unit attitude quaternion" + ) + attitude_error = 2.0 * math.acos( + min(1.0, abs(attitude_w) / quaternion_norm) + ) motors = (motor_1, motor_2, motor_3, motor_4) maximum_motor_fraction = max(abs(motor) for motor in motors) motors_stopped = all(abs(motor) <= 0.01 for motor in motors) @@ -449,7 +470,9 @@ def command_goal( if magic != b"KSL1" or version != VERSION or received_id != goal_id: continue if lifecycle == GOAL_REJECTED: - raise RuntimeError(f"{action} was rejected because SITL was not estimator-ready") + raise RuntimeError( + f"{action} was rejected because SITL was not estimator-ready" + ) if lifecycle == GOAL_ACTIVE: active = True print(f"{action} active: north={north:g} m, east={east:g} m, up={up:g} m") @@ -512,7 +535,9 @@ def main() -> int: or not math.isfinite(values.truth_hold_seconds) ) if tolerances: - raise ValueError("truth tolerances and hold duration must be finite and positive") + raise ValueError( + "truth tolerances and hold duration must be finite and positive" + ) with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: client.bind(USER_CONTROL) if values.command == "ready": diff --git a/firmware/tools/sitl/test_kaptan_sim.py b/firmware/tools/sitl/test_kaptan_sim.py index 99af61a..c7d29f1 100644 --- a/firmware/tools/sitl/test_kaptan_sim.py +++ b/firmware/tools/sitl/test_kaptan_sim.py @@ -37,7 +37,9 @@ class TruthAcceptanceTests(unittest.TestCase): ) def test_move_truth_contract_allows_an_armed_hover(self): - self.assertTrue(self.acceptable(motors_stopped=False, ground_plane_error=1.0, landing=False)) + self.assertTrue( + self.acceptable(motors_stopped=False, ground_plane_error=1.0, landing=False) + ) def test_landing_truth_contract_requires_zero_motors_and_ground_plane(self): self.assertFalse(self.acceptable(motors_stopped=False)) @@ -77,23 +79,28 @@ class TruthAcceptanceTests(unittest.TestCase): arguments = command_goal.call_args.args self.assertEqual(arguments[0:2], (control_socket, clock_socket)) magic, version, goal_id, north, east, up = self.client.GOAL.unpack(arguments[2]) - self.assertEqual((magic, version, north, east, up), (b"KSD1", 1, 1.0, -2.0, 0.0)) + self.assertEqual( + (magic, version, north, east, up), (b"KSD1", 1, 1.0, -2.0, 0.0) + ) self.assertEqual(arguments[3], goal_id) - self.assertEqual(arguments[4:], ( - 1.0, - -2.0, - 0.0, - 60.0, - True, - True, - 0.75, - 0.25, - 0.35, - 0.75, - self.client.GOAL_LANDED, - True, - "land", - )) + self.assertEqual( + arguments[4:], + ( + 1.0, + -2.0, + 0.0, + 60.0, + True, + True, + 0.75, + 0.25, + 0.35, + 0.75, + self.client.GOAL_LANDED, + True, + "land", + ), + ) if __name__ == "__main__": diff --git a/firmware/tools/sitl/world-structure-test.py b/firmware/tools/sitl/world-structure-test.py index 8b0429f..feef870 100644 --- a/firmware/tools/sitl/world-structure-test.py +++ b/firmware/tools/sitl/world-structure-test.py @@ -67,7 +67,9 @@ def check_world() -> None: world = root.find("./world") if world is None: fail("missing world") - model_names = {model_element.get("name") for model_element in world.findall("model")} + model_names = { + model_element.get("name") for model_element in world.findall("model") + } if model_names != {"ground", "sitl_reference_map", "kaptan_sitl_quad"}: fail( "the declared flat-world model may contain only ground, the visual reference map, " @@ -76,7 +78,9 @@ def check_world() -> None: ground = model(root, "ground") ground_collisions = ground.findall(".//collision") - if [collision.get("name") for collision in ground_collisions] != ["ground_collision"]: + if [collision.get("name") for collision in ground_collisions] != [ + "ground_collision" + ]: fail("ground must retain exactly one flat collision plane") if ground_collisions[0].find("geometry/plane") is None: fail("ground collision must remain a flat plane") @@ -89,11 +93,15 @@ def check_world() -> None: if reference_map.findall(".//collision"): fail("reference map must remain visual-only and cannot add obstacle physics") if reference_map.findall(".//transparency"): - fail("reference map must not use unsupported SDF transparency material children") + fail( + "reference map must not use unsupported SDF transparency material children" + ) reference_link = named(reference_map, "link", "reference_link") actual_marks = {visual.get("name") for visual in reference_link.findall("visual")} if actual_marks != {"landing_pad"}: - fail(f"reference map may contain only the landing pad; found {sorted(actual_marks)!r}") + fail( + f"reference map may contain only the landing pad; found {sorted(actual_marks)!r}" + ) quad = model(root, "kaptan_sitl_quad") if pose(quad)[:3] != (0.0, 0.0, 0.14): @@ -122,7 +130,9 @@ def check_world() -> None: "body_collision", *(f"landing_foot_{foot}_collision" for foot in foot_positions), } - actual_quad_collisions = {collision.get("name") for collision in base_link.findall("collision")} + actual_quad_collisions = { + collision.get("name") for collision in base_link.findall("collision") + } if actual_quad_collisions != expected_quad_collisions: fail( "quad collision geometry must remain its body and four declared landing feet; " @@ -130,8 +140,12 @@ def check_world() -> None: ) sensors = quad.findall(".//sensor") - if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [("imu", "imu")]: - fail("this local-state SITL world must not imply unmodeled visual/range/flow sensors") + if [(sensor.get("name"), sensor.get("type")) for sensor in sensors] != [ + ("imu", "imu") + ]: + fail( + "this local-state SITL world must not imply unmodeled visual/range/flow sensors" + ) if __name__ == "__main__": diff --git a/flake.nix b/flake.nix index 315bac2..35772f9 100644 --- a/flake.nix +++ b/flake.nix @@ -3,7 +3,8 @@ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - outputs = { nixpkgs, ... }: + outputs = + { nixpkgs, ... }: let systems = [ "x86_64-linux" @@ -12,13 +13,16 @@ forAllSystems = nixpkgs.lib.genAttrs systems; in { - devShells = forAllSystems (system: + devShells = forAllSystems ( + system: let pkgs = nixpkgs.legacyPackages.${system}; - python = pkgs.python3.withPackages (pythonPackages: with pythonPackages; [ - pymavlink - pyserial - ]); + python = pkgs.python3.withPackages ( + pythonPackages: with pythonPackages; [ + pymavlink + pyserial + ] + ); in { default = pkgs.mkShell { @@ -26,9 +30,15 @@ pkgs.rustup pkgs.dfu-util pkgs.mdbook + pkgs.ripgrep + pkgs.ruff + pkgs.shfmt + pkgs.nixfmt + pkgs.taplo python ]; }; - }); + } + ); }; } diff --git a/scripts/make-argv.py b/scripts/make-argv.py new file mode 100644 index 0000000..44e5b7c --- /dev/null +++ b/scripts/make-argv.py @@ -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()) diff --git a/scripts/test-make-argv.py b/scripts/test-make-argv.py new file mode 100644 index 0000000..c8e6f05 --- /dev/null +++ b/scripts/test-make-argv.py @@ -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()