Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6c03cb6b | ||
|
|
62b18d5b2e | ||
|
|
4953f0a974 | ||
|
|
a78754d909 | ||
|
|
d32b03c87f | ||
|
|
f9a61a086f | ||
|
|
fb1b8af2d1 | ||
|
|
84807dde4e | ||
|
|
3226ef97fe | ||
|
|
53e57c5d73 | ||
|
|
3ae1a8efb1 | ||
|
|
1f74983d61 | ||
|
|
604a7aba86 | ||
|
|
7dc3ccb619 | ||
|
|
4bc98adbc6 | ||
|
|
5f91465e66 | ||
|
|
1c90870634 | ||
|
|
9fec2fa095 | ||
|
|
21e9497915 | ||
|
|
fdf82a5114 | ||
|
|
ba9c97ed3b | ||
|
|
97acb2a2d7 | ||
|
|
b7631c0bc7 | ||
|
|
ceb2134392 | ||
|
|
387fdaad23 | ||
|
|
7048ed89d0 |
No files matched your search
@@ -0,0 +1,41 @@
|
|||||||
|
name: Firmware gates
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: 2
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verification:
|
||||||
|
name: Source-bound firmware verification
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 90
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install Nix
|
||||||
|
uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b # v31
|
||||||
|
- name: Fetch pinned dependencies
|
||||||
|
working-directory: firmware
|
||||||
|
run: nix develop .. -c cargo fetch --locked
|
||||||
|
- name: Install the coverage compiler and matching LLVM tools
|
||||||
|
run: nix develop -c rustup toolchain install nightly-2025-08-01 --profile minimal --component llvm-tools-preview
|
||||||
|
- name: Install manifest-pinned Miri and Kani tools
|
||||||
|
run: nix develop -c make verification-tools
|
||||||
|
- name: Host, target, board, coverage, Miri and Kani gates
|
||||||
|
run: nix develop -c make verification
|
||||||
|
- name: Retain verification evidence, including failed attempts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: firmware-verification
|
||||||
|
path: |
|
||||||
|
firmware/target/verification/**
|
||||||
|
firmware/target/verification-tools/setup-*/*.log
|
||||||
|
if-no-files-found: warn
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
name: Host SITL smoke
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
smoke:
|
|
||||||
name: Deterministic Gazebo command path
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 25
|
|
||||||
steps:
|
|
||||||
- name: Check out source
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Check host-SITL contracts
|
|
||||||
working-directory: firmware
|
|
||||||
run: |
|
|
||||||
python3 -m unittest tools/sitl/test_kaptan_sim.py
|
|
||||||
python3 tools/sitl/world-structure-test.py
|
|
||||||
cargo check --locked -p kaptan-mavlink --no-default-features
|
|
||||||
cargo check --locked -p kaptan-log --no-default-features
|
|
||||||
cargo check --locked -p kaptan-log-mavlink --no-default-features
|
|
||||||
cargo test --locked -p kaptan-log --features facade
|
|
||||||
cargo test --locked -p kaptan-log-derive -p kaptan-log-mavlink
|
|
||||||
if rg -n '(^use log::|\blog::)' platforms/stm32f7/src/f722; then
|
|
||||||
echo 'the standard log facade is forbidden in F722 IRQ/panic paths'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if rg -n 'max_level_(off|error|warn|info|debug|trace)' --glob 'Cargo.toml' --glob '*.rs' .; then
|
|
||||||
echo 'log/max_level_* removes records before Kaptan can observe them'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cargo test --locked -p kaptan-sitl
|
|
||||||
|
|
||||||
- name: Run Docker closed-loop smoke
|
|
||||||
run: firmware/tools/sitl/e2e-smoke.sh
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
- Run the flight runtime from RTIC with physical sensor input.
|
||||||
|
- Persist vehicle settings and sensor calibration in board configuration.
|
||||||
|
- Carry IMU acquisition times, uncertainty and complete sample batches into navigation.
|
||||||
|
- Add range and optical-flow fusion for local navigation.
|
||||||
|
- Add hardware motor-output drivers using the existing DShot and PWM code.
|
||||||
|
- Connect motor outputs to arming, failsafe and command-expiry handling.
|
||||||
|
- Add private MAVLink commands for arming, disarming and onboard missions.
|
||||||
|
- Inhibit motors during configuration flash writes.
|
||||||
|
- Add onboard barometer drivers and shared I2C bus access.
|
||||||
|
- Add battery-voltage, current and analog RSSI acquisition.
|
||||||
|
- Add onboard flash storage and blackbox recording.
|
||||||
|
- Add analog OSD support.
|
||||||
|
- Add buzzer, LED-strip, camera-control and auxiliary-output drivers.
|
||||||
|
- Add receiver, GPS, VTX and ESC telemetry support to configurable ports.
|
||||||
|
- Complete board component identification and peripheral pin maps.
|
||||||
|
- Add hardware tests for sensor decoding, calibration, timing and recovery.
|
||||||
|
- Add hardware tests for motor shutdown on reset, missed deadlines and faults.
|
||||||
|
- Measure startup time, control-loop latency and peak stack under I/O load.
|
||||||
|
- Verify onboard takeoff, hover, landing and motor shutdown on hardware.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
SHELL := /bin/sh
|
||||||
|
MAKEFLAGS += --no-print-directory
|
||||||
|
export CARGO_BUILD_JOBS ?= 2
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
|
||||||
|
.NOTPARALLEL:
|
||||||
|
.PHONY: help nix-shell build boards docs configurator fmt test app-native-test coverage verification verification-tools miri kani verification-inventory pre-commit check clean
|
||||||
|
|
||||||
|
help: ## Show the command interface (the default)
|
||||||
|
@printf '%s\n' 'Kaptan developer commands' '' \
|
||||||
|
'Usage: make <target>' '' \
|
||||||
|
' help Show this help' \
|
||||||
|
' nix-shell Enter the Nix development shell' \
|
||||||
|
' build Build all firmware and documentation' \
|
||||||
|
' boards Build and package every board' \
|
||||||
|
' docs Test and build the mdBook project' \
|
||||||
|
' configurator Start the local configurator web application' \
|
||||||
|
' fmt Check Rust, shell, Python, Nix, and TOML formatting' \
|
||||||
|
' test Run shared-library, host algorithm, and script tests' \
|
||||||
|
' app-native-test Check the app UTF conversion function with a host API stub' \
|
||||||
|
' coverage Required final strict-core line/branch coverage gate' \
|
||||||
|
' verification Run all gates and retain source-bound evidence' \
|
||||||
|
' verification-tools Install the pinned Miri and Kani tools' \
|
||||||
|
' miri Run the manifest-selected Miri checks' \
|
||||||
|
' kani Run the manifest-selected Kani proofs' \
|
||||||
|
' verification-inventory Show verification owners, claims and gaps' \
|
||||||
|
' pre-commit Run fmt, firmware clippy/test/docs, and mdBook checks' \
|
||||||
|
' check Run pre-commit plus whitespace checks' \
|
||||||
|
' clean Remove generated build artifacts' \
|
||||||
|
'' 'Component workflows' \
|
||||||
|
' make -C firmware help' \
|
||||||
|
' make -C firmware/boards/<board> build|flash' \
|
||||||
|
' make -C docs build|test|check|clean' \
|
||||||
|
'' 'Native tools (run from firmware/)' \
|
||||||
|
' make test' \
|
||||||
|
' cargo run --locked --offline -p kaptan-configurator -- --baud 115200'
|
||||||
|
|
||||||
|
nix-shell: ## Enter the reproducible Nix development shell
|
||||||
|
nix develop
|
||||||
|
|
||||||
|
build: boards ## Build all firmware boards and documentation
|
||||||
|
+@$(MAKE) -C docs build
|
||||||
|
|
||||||
|
boards:
|
||||||
|
+@$(MAKE) -C firmware/boards build
|
||||||
|
|
||||||
|
docs:
|
||||||
|
+@$(MAKE) -C docs check
|
||||||
|
|
||||||
|
configurator:
|
||||||
|
+@$(MAKE) -C firmware/tools/configurator run
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
+@$(MAKE) -C firmware fmt
|
||||||
|
shfmt -d $$(rg --files -g '*.sh')
|
||||||
|
ruff format --check $$(rg --files -g '*.py')
|
||||||
|
ruff check firmware/scripts
|
||||||
|
nixfmt --check $$(rg --files -g '*.nix')
|
||||||
|
taplo format --check $$(rg --files -g '*.toml')
|
||||||
|
|
||||||
|
test:
|
||||||
|
+@$(MAKE) -C firmware test
|
||||||
|
+@$(MAKE) app-native-test
|
||||||
|
|
||||||
|
app-native-test:
|
||||||
|
python3 ground_suite/windows/runner/test_utf8_from_utf16.py \
|
||||||
|
--work-dir "$(or $(CARGO_TARGET_DIR),target)/native-tests/utf8"
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
+@$(MAKE) -C firmware coverage
|
||||||
|
|
||||||
|
verification verification-tools miri kani verification-inventory:
|
||||||
|
+@$(MAKE) -C firmware $@
|
||||||
|
|
||||||
|
pre-commit: fmt test
|
||||||
|
+@$(MAKE) -C firmware clippy
|
||||||
|
+@$(MAKE) -C firmware docs
|
||||||
|
+@$(MAKE) -C docs check
|
||||||
|
@printf '%s\n' 'Pre-commit passed. Coverage was not run; make coverage is a required final gate.'
|
||||||
|
|
||||||
|
check: pre-commit ## Run pre-commit plus whitespace checks
|
||||||
|
git diff HEAD --check
|
||||||
|
|
||||||
|
clean: ## Remove firmware and documentation build artifacts
|
||||||
|
+@$(MAKE) -C firmware clean
|
||||||
|
+@$(MAKE) -C docs clean
|
||||||
+21
-7
@@ -1,12 +1,26 @@
|
|||||||
MDBOOK ?= mdbook
|
SHELL := /bin/sh
|
||||||
|
MAKEFLAGS += --no-print-directory
|
||||||
|
|
||||||
.PHONY: build serve test
|
.DEFAULT_GOAL := help
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
|
||||||
|
.PHONY: help build test check clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' 'Usage: make -C docs build|test|check|clean' '' \
|
||||||
|
' build Build the book' \
|
||||||
|
' test Test book examples' \
|
||||||
|
' check Test and build the book' \
|
||||||
|
' clean Remove generated documentation' '' \
|
||||||
|
'For other options, run mdbook directly from docs/.'
|
||||||
|
|
||||||
build:
|
build:
|
||||||
$(MDBOOK) build
|
mdbook build
|
||||||
|
|
||||||
serve:
|
|
||||||
$(MDBOOK) serve --open
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
$(MDBOOK) test
|
mdbook test
|
||||||
|
|
||||||
|
check: test build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
mdbook clean
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[book]
|
[book]
|
||||||
title = "Kaptan documentation"
|
title = "Kaptan documentation"
|
||||||
description = "Architecture, board bring-up, simulation, and contributor guidance for Kaptan."
|
description = "Architecture, board bring-up, and contributor guidance for Kaptan."
|
||||||
authors = ["Kaptan contributors"]
|
authors = ["Kaptan contributors"]
|
||||||
language = "en"
|
language = "en"
|
||||||
src = "src"
|
src = "src"
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
- [Configuration](configuration.md)
|
- [Configuration](configuration.md)
|
||||||
- [Logs](logs.md)
|
- [Logs](logs.md)
|
||||||
- [Errors](errors.md)
|
- [Errors](errors.md)
|
||||||
- [Ground Suite](ground-suite.md)
|
|
||||||
- [Boards](boards/index.md)
|
- [Boards](boards/index.md)
|
||||||
- [Kakute F7 Mini V3](boards/kakute-f7-mini-v3.md)
|
- [Kakute F7 Mini V3](boards/kakute-f7-mini-v3.md)
|
||||||
- [GEPRC GEP-F722-HD V2](boards/geprc-f722-v2.md)
|
- [GEPRC GEP-F722-HD V2](boards/geprc-f722-v2.md)
|
||||||
- [Host SITL](sitl.md)
|
|
||||||
- [Reference](reference.md)
|
- [Reference](reference.md)
|
||||||
+166
-56
@@ -10,73 +10,182 @@ board declaration
|
|||||||
F722 platform / RTIC application ──► data channel ──► MAVLink telemetry
|
F722 platform / RTIC application ──► data channel ──► MAVLink telemetry
|
||||||
│
|
│
|
||||||
├── physical sensor drivers
|
├── physical sensor drivers
|
||||||
└── motor-pin inhibit interlock
|
└── startup motor-pin inhibition
|
||||||
|
|
||||||
portable libraries
|
Portable flight libraries (not connected to physical actuators)
|
||||||
math ─► navigation ─► control ─► safety
|
FlightEndpoint ──► Navigation / control / phases / safety
|
||||||
|
│
|
||||||
|
└── caller-owned synchronous output sink
|
||||||
```
|
```
|
||||||
|
|
||||||
The board contract selects one immutable `BoardProfile`. The checked-in
|
Each F722 board combines `F722UserConfig` with an immutable hardware manifest.
|
||||||
profile is `TelemetryOnly`; `ExternalControlOnly` is represented explicitly
|
The manifest selects the IMU route, driver settings, mount, serial inventory,
|
||||||
but rejected at validation time because it has no reviewed runtime.
|
status LED, optional I²C sensor, motor pins and service frequency. User settings
|
||||||
|
select UART roles, baud and MAVLink settings. Motor pins are inhibited at startup.
|
||||||
|
|
||||||
The data channel owns rate selection, mailbox consumption, message encoding,
|
`F722ImmutableConfig<R>` retains the IMU route type through initialization and
|
||||||
and bounded TX classification. Board code supplies physical routes and
|
interrupt acknowledgement. The sealed `Mpu6000Spi1` and `Icm42688Spi3` routes
|
||||||
telemetry configuration; it does not receive generic output authority.
|
select IMU wiring and backend independently of serial inventory and LED wiring.
|
||||||
|
|
||||||
Configuration and runtime observability are separate, one-way planes:
|
Each board declares one `PortCatalog<N>::DESCRIPTORS` array. Both
|
||||||
|
`Ports<Catalog, N>` and `SerialInventory::new(&Catalog::DESCRIPTORS)` use it for
|
||||||
|
stable UART/field identities, supported roles, baud allowlists and resource
|
||||||
|
claims. Validation produces a boot-only `PortPlan<N>`. The same const `N` sizes
|
||||||
|
I/O and protocol storage: five UARTs on each current board. Absent UARTs
|
||||||
|
have no slots. A declared UART set to Disabled retains its slot but initializes
|
||||||
|
no peripheral or pins. USB is fixed normal MAVLink outside that inventory,
|
||||||
|
separate from ROM DFU.
|
||||||
|
|
||||||
|
Each MAVLink `Link` has its own scheduler, encoder and receive framing, with a
|
||||||
|
separate transport queue. A shared collector snapshots sensor mailboxes for all
|
||||||
|
links. See [MAVLink](mavlink.md) for scheduling and transport behavior.
|
||||||
|
|
||||||
|
`kaptan-flight-runtime::FlightEndpoint` runs Quad-X control and
|
||||||
|
`kaptan-navigation::Navigation` without allocation. Callers supply
|
||||||
|
observations, independently captured receipt/processing time, clock ticks and a
|
||||||
|
synchronous output sink. Transport and wire encoding stay outside the core.
|
||||||
|
The board images do not yet construct a flight endpoint.
|
||||||
|
See [Safety boundary](safety-boundary.md#shared-runtime-commit-contract) for
|
||||||
|
epochs, request sequencing and the output commit contract.
|
||||||
|
|
||||||
|
`FlightEndpoint::supervise` checks actual time, cached health and phase/stream
|
||||||
|
deadlines without advancing navigation, integrating control or consuming a
|
||||||
|
control release. It can inhibit and publish outcomes, but cannot renew enabled
|
||||||
|
output. Callers must service the endpoint with actual time even when input
|
||||||
|
stops. The core owns control release and deadline decisions.
|
||||||
|
|
||||||
|
Within `FlightEndpoint`, fusion, current prediction and initialization completion
|
||||||
|
run only when the 100-Hz control service is due. Early `tick` calls supervise
|
||||||
|
cached evidence without advancing navigation. Ingress admission and its
|
||||||
|
diagnostics remain immediate, but receiving or queuing fresh observations alone
|
||||||
|
does not renew capabilities. The actual control gap is checked before a due
|
||||||
|
navigation update can refresh evidence.
|
||||||
|
|
||||||
|
`Navigation` initializes from a stationary window of IMU, fixed-reference local
|
||||||
|
position/velocity, magnetic, pressure and range observations. It derives attitude
|
||||||
|
and a fixed launch origin from sensors, with explicit calibration/reference
|
||||||
|
identities. Its shared 16-state error covariance covers position, velocity,
|
||||||
|
attitude error, gyro bias, accelerometer bias and barometric datum. Bounded
|
||||||
|
delayed fusion and current prediction preserve observation timing and capability
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
The navigation `sensor-snapshots` feature enables driver snapshot conversion.
|
||||||
|
The same flight estimator is available on host and embedded targets.
|
||||||
|
|
||||||
|
Navigation owns the 25-m/s² free-flight acceleration bound checked by
|
||||||
|
complete-profile validation. See
|
||||||
|
[Configuration](configuration.md#flight-configuration) for required installation
|
||||||
|
data and the supported vehicle model.
|
||||||
|
|
||||||
|
Configuration and diagnostics use separate paths:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Kaptan GCS custom MAVLink V2 messages
|
FILE_CONTROL / FILE_DATA
|
||||||
│ (future: verified signing, source binding, replay state)
|
│
|
||||||
▼
|
▼
|
||||||
kaptan-config-mavlink ──► state-only ConfigProtocol
|
kaptan-file-mavlink ──► virtual FileProvider
|
||||||
│ borrows
|
|
||||||
kaptan-flight-runtime
|
|
||||||
├── one ConfigManager ──► kaptan-config ──► flight schema/CBOR
|
|
||||||
└── one kaptan-log hub
|
|
||||||
│
|
│
|
||||||
kaptan-log-mavlink payload adapter
|
KCFG importer/exporter
|
||||||
│
|
│
|
||||||
platform-owned sole encoder / DMA TX link
|
ConfigManager ──► private store
|
||||||
|
|
||||||
|
kaptan-log / kaptan-err-reportable
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
kaptan-diagnostics-mavlink ──► per-link encoder / bounded TX
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated dialect is intentionally custom rather than a `PARAM_*` or QGC
|
The Kaptan-native file dialect owns bounded transfers and retries across links.
|
||||||
adapter. The core uses numeric field IDs and typed scalar values; descriptor
|
Its provider maps saved, active and factory-reset endpoints to streaming KCFG v3
|
||||||
text is metadata, not a wire identity. Discovery/value reads, bounded
|
and the generic configuration lifecycle.
|
||||||
descriptor pages, and structured results are custom-dialect records. Runtime
|
|
||||||
logs use one bounded text message with MAVLink severity and flags. CBOR import uses a fixed
|
|
||||||
4 KiB transaction buffer and
|
|
||||||
fixed 192-byte chunks; its decoded candidate still goes through the same whole
|
|
||||||
profile validation path. The core owns one RAM transaction and the distinction
|
|
||||||
between boot-active, committed, and editable candidate snapshots. In the
|
|
||||||
current P1 policy a commit is restart-only. It cannot mutate controller or
|
|
||||||
estimator state in flight.
|
|
||||||
|
|
||||||
One current CBOR profile format is a bounded complete snapshot. There is no
|
`ConfigManager` owns boot-active values, known committed values or unknown durable
|
||||||
migration reader: any other format is rejected. SITL supplies the host file
|
state, and the factory candidate. F722 supplies its UART-disabled setup fallback
|
||||||
adapter and persists a nonzero generation alongside its raw profile payload.
|
through `recover_with`. Readable invalid settings remain inspectable for USB
|
||||||
Motor allocation/mixer topology is immutable compiled
|
repair. Saving or factory reset never changes active consumers until reboot.
|
||||||
vehicle capability data, never a scalar field or CBOR candidate member. The
|
|
||||||
bare-metal receiver continues to reject inbound control authority. Target
|
|
||||||
persistence, authenticated ingress, durable audit logging, disarmed
|
|
||||||
activation, and atomic consumer reconstruction remain separate reviewed work
|
|
||||||
and cannot be implied by a valid CBOR file or CRC.
|
|
||||||
|
|
||||||
The MAVLink dialect root includes `kaptan/config.xml`, `kaptan/log.xml`,
|
Target-owned stores use private persistence formats. F722 alternates compact
|
||||||
`kaptan/error.xml`, and `kaptan/shutdown.xml`.
|
`KCSP` snapshots between two erase sectors, retaining only the old and replacement
|
||||||
The build watches every fragment and generates one `message::kaptan` module;
|
records across the commit boundary. That format is not exposed as KCFG. Motor
|
||||||
fragments are never separately generated Rust modules. `kaptan-log` itself has
|
allocation/mixer topology remains compiled vehicle capability data.
|
||||||
no MAVLink, platform, time, allocation, filesystem, or configuration
|
|
||||||
dependency. It assigns bounded Critical and BestEffort queues for text logs.
|
|
||||||
The platform drains a record only after TX admission. `KAPTAN_LOG` carries
|
|
||||||
severity plus one bounded text field; `KAPTAN_ERROR` carries flags plus bounded
|
|
||||||
path and additional-information fields. Local saturation accounting remains
|
|
||||||
inside the hub, facade, and producer mailboxes; heartbeat and safety telemetry
|
|
||||||
remain separate from diagnostics.
|
|
||||||
|
|
||||||
See [Configuration](configuration.md) for the deployment matrix and GCS upload
|
## I/O ownership
|
||||||
contract, [Logs](logs.md) for the receiver contract, and [Errors](errors.md)
|
|
||||||
|
Sensor handoff uses owned `LatestMailbox<T>` storage split into exactly one
|
||||||
|
non-cloneable producer and one non-cloneable consumer. Publishing replaces an
|
||||||
|
unread value, and taking removes it at most once. Each operation copies or takes
|
||||||
|
an `Option<T>` inside the platform critical section. No callback, allocation,
|
||||||
|
retry or wait occurs inside that section. Storage stays borrowed while endpoints
|
||||||
|
are in use, and moving, dropping or forgetting an endpoint holds no lock. The
|
||||||
|
backend must exclude every participating context, using Cortex-M single-core exclusion on
|
||||||
|
F722 and the `critical-section/std` backend for host threads.
|
||||||
|
|
||||||
|
UART TX and RX are interrupt-driven, without UART DMA. Each I/O owner retains
|
||||||
|
the peripheral, TX queue and a 512-byte RX buffer with a typed `Timestamp` per
|
||||||
|
byte captured at IRQ receipt. The worker takes at most 16 bytes per UART per
|
||||||
|
turn under a short lock. Protocol parsing, scheduling and persistence run
|
||||||
|
outside UART locks. Overflow or a hardware RX fault discards the damaged backlog
|
||||||
|
and resets the decoder before later bytes. These bounds do not establish a
|
||||||
|
measured latency guarantee.
|
||||||
|
|
||||||
|
`kaptan-tx::TransmitQueue` owns bounded admission and one logically active frame.
|
||||||
|
Pending admission cannot replace that active frame. The queue itself is movable
|
||||||
|
and does not promise DMA address stability. UART byte writes and USB endpoint
|
||||||
|
writes borrow or copy its bytes synchronously. MAVLink owns encoding, while
|
||||||
|
platform I/O owners handle transmission.
|
||||||
|
|
||||||
|
USB uses the repository-patched Synopsys driver. Runtime register waits allow
|
||||||
|
higher-priority interrupts and stop after 5 ms or 1,080,000 polls per wait. These
|
||||||
|
are fault limits, not measured execution times. A fault holds USB in peripheral
|
||||||
|
reset until restart and discards its queued data. Brief critical sections protect
|
||||||
|
USB ownership and endpoint buffers. Boot initialization still runs under RTIC's
|
||||||
|
startup interrupt mask.
|
||||||
|
|
||||||
|
SPI1 and SPI3 DMA each use a once-claimed static pair of 32-byte TX/RX buffers.
|
||||||
|
`start_transfer` copies in, so hardware retains no caller slice. Completion
|
||||||
|
copies out only after checking stream disable, SPI quiescence, transfer counts
|
||||||
|
and faults. Failure to establish shutdown or drain RX quarantines the storage
|
||||||
|
and prevents reuse. Moving, dropping or forgetting the owner never releases
|
||||||
|
that claim. The F722 implementation relies on DMA-accessible SRAM, disabled
|
||||||
|
D-cache and device barriers. Physical SPI shutdown, timing and fault behavior
|
||||||
|
still require hardware validation.
|
||||||
|
|
||||||
|
## Monotonic time
|
||||||
|
|
||||||
|
TIM2 is the canonical F722 clock. The configured 108-MHz timer input and PSC=107
|
||||||
|
produce a free-running 32-bit counter at 1 MHz with ARR=`u32::MAX`. All readers
|
||||||
|
and the CC1 service IRQ extend one shared `u64` microsecond epoch inside the same
|
||||||
|
critical section. A consumer may remain silent across wraps if other readers
|
||||||
|
or IRQ service keep extending that epoch. Combined observations must be strictly
|
||||||
|
less than 2^32 microseconds apart, about 71.58 minutes, including interrupt
|
||||||
|
blackouts and debugger halts. NMI and fault handlers must not access this clock.
|
||||||
|
|
||||||
|
CC1 schedules the current 100-Hz service at epoch-aligned 10-ms deadlines.
|
||||||
|
Delayed service skips missed periods in constant work and emits at most one
|
||||||
|
notification per IRQ. Rearming uses two bounded attempts before pending a
|
||||||
|
recovery invocation. Callbacks and notifications run after clock exclusion ends.
|
||||||
|
TIM2 is not reset for each service tick, and no update IRQ or timer DMA is used.
|
||||||
|
Its IRQ is enabled only at the successful end of initialization.
|
||||||
|
|
||||||
|
Ordinary WFI retains the timer clock. Deep sleep, STOP/Standby, clock-tree changes,
|
||||||
|
counter reset/regression and debugger halts spanning a full wrap are unsupported
|
||||||
|
and require platform reset. Uptime must leave one service period before `u64`
|
||||||
|
microsecond overflow. DWT is used only for the CPU-running SRAM flash-poll timeout
|
||||||
|
at 216 MHz, not sensor timestamps or service deadlines. These contracts do not
|
||||||
|
establish measured oscillator accuracy, IRQ latency, jitter or worst-case timing.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
`kaptan-log` has no MAVLink, platform, time, allocation or filesystem dependency.
|
||||||
|
Its configuration coupling is compile-time enum metadata for `LevelFilter`.
|
||||||
|
`kaptan-err-reportable` independently owns explicit error identities and a bounded
|
||||||
|
nonblocking ingress, without logging, transport or clock dependencies.
|
||||||
|
Event creation is independent from sink filtering. The worker drains reportable
|
||||||
|
errors before logs and offers each diagnostic to all MAVLink links. Heartbeat
|
||||||
|
and sensor publications use separate scheduling.
|
||||||
|
|
||||||
|
See [Configuration](configuration.md) for KCFG and the file-transfer contract,
|
||||||
|
[Logs](logs.md) for the receiver contract, and [Errors](errors.md)
|
||||||
for derived-error and failure-reporting scope.
|
for derived-error and failure-reporting scope.
|
||||||
|
|
||||||
## Ownership rules
|
## Ownership rules
|
||||||
@@ -84,7 +193,8 @@ for derived-error and failure-reporting scope.
|
|||||||
- A peripheral owner is local to the platform runtime.
|
- A peripheral owner is local to the platform runtime.
|
||||||
- Portable crates use typed units and validated values rather than raw wire or
|
- Portable crates use typed units and validated values rather than raw wire or
|
||||||
timer representations.
|
timer representations.
|
||||||
- A safety permit is epoch-bound and must be revalidated at the only nonzero
|
- `SafetySupervisor::evaluate_and_commit` checks current evidence at the
|
||||||
output boundary.
|
synchronous enabled-output boundary. No transferable permit escapes it.
|
||||||
- A failed observation or numerical check does not partially commit estimator,
|
- Rejected numerical updates do not partially commit computational candidates.
|
||||||
controller, allocator, or safety state.
|
Fault latches, inhibition and ingress replay watermarks follow their explicit
|
||||||
|
failure contracts rather than rolling back authority or replay history.
|
||||||
@@ -1,21 +1,66 @@
|
|||||||
# GEPRC GEP-F722-HD V2
|
# GEPRC GEP-F722-HD V2
|
||||||
|
|
||||||
The GEPRC GEP-F722-HD V2 board declaration uses the ICM-42688-P on SPI3 and
|
The GEPRC GEP-F722-HD V2 uses the ICM-42688-P on SPI3.
|
||||||
MAVLink telemetry on USART2. It declares no vertical sensor stream.
|
The manufacturer's [GEP-F722-HD V2 manual](https://geprc.com/wp-content/uploads/2025/05/GEP-F722-HD-v2%E9%A3%9E%E6%8E%A7_%E4%BA%A7%E5%93%81%E8%AF%B4%E6%98%8E%E4%B9%A6-.pdf)
|
||||||
|
labels UART1 as `T1`/`R1` and UART2 as `T2`/`R2`.
|
||||||
|
|
||||||
Its immutable board policy selects the ICM-42688-P yaw-180° mount, logical
|
The board declares its yaw-180° IMU mount, UART catalog, active-high PA13 LED
|
||||||
MAVLink `Serial2`, endpoint `1/1`, 115200 baud, the documented telemetry
|
and no I²C range sensor.
|
||||||
schedule, and an `INFO` optional-log threshold. The platform verifies these
|
Before fallible initialization, the platform holds the four documented motor
|
||||||
values against the checked SPI3/USART2 typestate route before it enables
|
pins PB0/PB1/PB4/PB5 low.
|
||||||
peripherals. They are compiled image policy, not ground-station parameters.
|
|
||||||
|
|
||||||
Build and package from the firmware workspace:
|
## Build and package
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make -C firmware/boards/geprc-f722-v2 build
|
||||||
cargo build --release -p kaptan-geprc-f722-v2 --target thumbv7em-none-eabihf
|
|
||||||
./scripts/create-dfu-image.sh target/thumbv7em-none-eabihf/release/kaptan-geprc-f722-v2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The resulting image is telemetry-only and has the shared physical motor-pin
|
The packaging script validates the ELF against the shared F722 linker layout
|
||||||
inhibit boundary.
|
and prints the flash and RAM report before producing the raw binary.
|
||||||
|
|
||||||
|
## Flash over USB DFU
|
||||||
|
|
||||||
|
Remove the propellers and disconnect the LiPo before connecting USB. This image
|
||||||
|
is linked at the beginning of internal flash and the command below assumes the
|
||||||
|
STM32 system-memory ROM DFU loader, not a flash-resident bootloader with an
|
||||||
|
application offset. Enter the board's documented ROM DFU mode, disconnect any
|
||||||
|
other STM32 boards in DFU mode, and verify that the intended device is the
|
||||||
|
listed `0483:df11` target before flashing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
dfu-util --list
|
||||||
|
make -C firmware/boards/geprc-f722-v2 flash
|
||||||
|
```
|
||||||
|
|
||||||
|
## MAVLink over USB
|
||||||
|
|
||||||
|
After flashing, leave ROM DFU mode and reconnect USB. The running application
|
||||||
|
enumerates as `Kaptan / GEPRC TAKER F722 V2 (ICM-42688-P)`, normally
|
||||||
|
`/dev/ttyACM*` on Linux. Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make configurator
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:4861` and connect `/dev/serial/by-id/<kaptan-device>` or
|
||||||
|
the discovered `/dev/ttyACM*` path from the Connection section.
|
||||||
|
|
||||||
|
## UART connection
|
||||||
|
|
||||||
|
First assign UART2 to `KaptanMavlink` over USB, save and restart. Then connect
|
||||||
|
a 3.3 V USB-UART adapter with crossed data lines and common ground:
|
||||||
|
|
||||||
|
| Adapter | Flight controller |
|
||||||
|
| --- | --- |
|
||||||
|
| `RX` | `T2` |
|
||||||
|
| `TX` | `R2` |
|
||||||
|
| `GND` | `GND` |
|
||||||
|
|
||||||
|
Enter `/dev/serial/by-id/<adapter>` in the same configurator's Connection section.
|
||||||
|
|
||||||
|
Use UART2's boot-active baud. If its role was changed or recovery disabled it,
|
||||||
|
connect over normal USB. Follow [save/reset/reload](../configuration.md#web-configurator)
|
||||||
|
before expecting new settings to apply. Disconnect other transmitters from `R2`
|
||||||
|
while configuring. Physical motor control is not yet implemented.
|
||||||
@@ -3,10 +3,20 @@
|
|||||||
Board pages describe supported hardware topology, build products, and safe
|
Board pages describe supported hardware topology, build products, and safe
|
||||||
verification. They do not declare a board flight-ready.
|
verification. They do not declare a board flight-ready.
|
||||||
|
|
||||||
| Board | MCU family | Primary IMU | MAVLink UART |
|
| Board | IMU route | Serial inventory | Factory UART roles |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| [Kakute F7 Mini V3](kakute-f7-mini-v3.md) | STM32F722 | MPU-6000 | USART1 |
|
| [Kakute F7 Mini V3](kakute-f7-mini-v3.md) | `Mpu6000Spi1` | UART1, UART2, UART3, UART4, UART6 | All disabled |
|
||||||
| [GEPRC GEP-F722-HD V2](geprc-f722-v2.md) | STM32F722 | ICM-42688-P | USART2 |
|
| [GEPRC GEP-F722-HD V2](geprc-f722-v2.md) | `Icm42688Spi3` | UART1, UART2, UART3, UART4, UART6 | All disabled |
|
||||||
|
|
||||||
|
USB CDC-ACM always uses Kaptan MAVLink and has no configurable role. ROM DFU is a separate flashing mode. UART roles and baud are
|
||||||
|
restart-only user settings. Both factories use 115200 baud, endpoint `1/1`,
|
||||||
|
20-Hz IMU, 2-Hz heartbeat, 20-Hz range/flow settings and the `Info` log filter.
|
||||||
|
Range/flow publications require an enabled sensor source.
|
||||||
|
|
||||||
|
See [Configuration](../configuration.md) for role choices, supported baud values,
|
||||||
|
save/reset/reload and invalid-storage USB repair. The range-limited DFU workflows
|
||||||
|
on these pages preserve the journal outside the application image. Mass erase
|
||||||
|
removes saved settings and selects factory values at the next boot.
|
||||||
|
|
||||||
All board images share the motor-pin inhibit boundary described in
|
All board images share the motor-pin inhibit boundary described in
|
||||||
[Safety boundary](../safety-boundary.md).
|
[Safety boundary](../safety-boundary.md).
|
||||||
@@ -1,43 +1,62 @@
|
|||||||
# Kakute F7 Mini V3
|
# Kakute F7 Mini V3
|
||||||
|
|
||||||
The Holybro Kakute F7 Mini V3 board declaration uses the MPU-6000 on SPI1,
|
The Holybro Kakute F7 Mini V3 uses the MPU-6000 on SPI1.
|
||||||
MAVLink telemetry on USART1, and the MTF01 range/optical-flow route on UART4.
|
|
||||||
The checked-in manufacturer datasheet is
|
The checked-in manufacturer datasheet is
|
||||||
`firmware/boards/kakute-f7-mini-v3/datasheet.pdf`.
|
`firmware/boards/kakute-f7-mini-v3/datasheet.pdf`.
|
||||||
|
|
||||||
Its immutable board policy selects the MPU-6000 body-aligned mount, logical
|
The board maps MPU axes `(X, Y, Z)` to `(-Y, X, Z)`, matching the manufacturer's
|
||||||
MAVLink `Serial1`, endpoint `1/1`, 115200 baud, the documented telemetry
|
`KAKUTEF7MINIV3` target, and supplies the driver settings. It declares the UART
|
||||||
schedule, and an `INFO` optional-log threshold. The platform verifies these
|
catalog, active-low PD2 LED and no I²C range sensor.
|
||||||
values against the checked SPI1/USART1/MTF01 typestate route before it enables
|
Before fallible initialization, the platform holds all six documented
|
||||||
peripherals. They are compiled image policy, not ground-station parameters.
|
motor pins PB0/PB1/PB4/PB5/PB6/PB7 low.
|
||||||
|
|
||||||
## Build and package
|
## Build and package
|
||||||
|
|
||||||
From the firmware workspace:
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make -C firmware/boards/kakute-f7-mini-v3 build
|
||||||
cargo build --release -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 derives the matching binary and linker layout, then
|
The packaging script uses the shared F722 linker layout and prints the flash
|
||||||
prints the flash and RAM report.
|
and RAM report before producing the raw binary.
|
||||||
|
|
||||||
## Flash over USB DFU
|
## Flash over USB DFU
|
||||||
|
|
||||||
|
Remove the propellers and disconnect the LiPo before connecting USB. This image
|
||||||
|
is linked at the beginning of internal flash and the command below assumes the
|
||||||
|
STM32 system-memory ROM DFU loader, not a flash-resident bootloader with an
|
||||||
|
application offset.
|
||||||
|
|
||||||
1. Disconnect USB.
|
1. Disconnect USB.
|
||||||
2. Hold the board's `Boot` button.
|
2. Hold the board's `Boot` button.
|
||||||
3. Connect USB, then release `Boot` once the STM32 DFU device enumerates.
|
3. Connect USB, then release `Boot` once the STM32 DFU device enumerates.
|
||||||
4. Flash the generated image:
|
4. Disconnect any other STM32 boards in DFU mode and verify that the intended
|
||||||
|
device is the listed `0483:df11` target.
|
||||||
|
5. Flash the generated image:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dfu-util --alt 0 --dfuse-address 0x08000000:leave --download target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3.bin
|
dfu-util --list
|
||||||
|
make -C firmware/boards/kakute-f7-mini-v3 flash
|
||||||
```
|
```
|
||||||
|
|
||||||
## Telemetry wiring
|
## MAVLink over USB
|
||||||
|
|
||||||
Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground:
|
After flashing, release `Boot` and reconnect USB so the application starts and
|
||||||
|
enumerates as `Kaptan / Kakute F7 Mini V3`, normally `/dev/ttyACM*` on Linux.
|
||||||
|
Then launch the target-described web UI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make configurator
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:4861` and connect `/dev/serial/by-id/<kaptan-device>` or
|
||||||
|
the discovered `/dev/ttyACM*` path from the Connection section.
|
||||||
|
|
||||||
|
## UART connection
|
||||||
|
|
||||||
|
First assign UART1 to `KaptanMavlink` over USB, save and restart. Then connect
|
||||||
|
a 3.3 V USB-UART adapter with crossed data lines and common ground:
|
||||||
|
|
||||||
| Adapter | Flight controller |
|
| Adapter | Flight controller |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -45,4 +64,9 @@ Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground:
|
|||||||
| `TX` | `R1` |
|
| `TX` | `R1` |
|
||||||
| `GND` | `G` |
|
| `GND` | `G` |
|
||||||
|
|
||||||
This wiring is telemetry only. It does not enable motor control.
|
Enter `/dev/serial/by-id/<adapter>` in the same configurator's Connection section.
|
||||||
|
|
||||||
|
Use the UART's boot-active baud. If its role was changed or recovery disabled
|
||||||
|
UARTs, connect over normal USB. Follow [save/reset/reload](../configuration.md#web-configurator)
|
||||||
|
before expecting new settings to apply. Disconnect other transmitters from the
|
||||||
|
selected RX pad while configuring. Physical motor control is not yet implemented.
|
||||||
+280
-78
@@ -1,102 +1,304 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Kaptan has one generic configuration lifecycle crate, `kaptan-config`. It
|
`kaptan-config` owns typed fields, validation and the committed-versus-active
|
||||||
owns descriptors, typed writes, transactions, validation, generations, and
|
lifecycle. The F722 platform supplies the shared user schema and factory values.
|
||||||
stores. It does not grow a crate per subsystem. Flight-specific values live in
|
Boards declare their UART catalog and immutable hardware context. The
|
||||||
the single `kaptan-flight-config` candidate; board electrical topology remains
|
configurator renders target metadata without a board or parameter catalog.
|
||||||
in the immutable board declaration.
|
|
||||||
|
|
||||||
## Current deployment matrix
|
## Flight configuration
|
||||||
|
|
||||||
| Deployment | Configuration consumed today | Remote writes |
|
`kaptan-flight-config::CandidateProfile` owns the flight policy, gains, thrust
|
||||||
|
model, navigation noise densities and timing constraints. It is consumed by the
|
||||||
|
portable runtime and its tests. The board firmware uses its own board schema
|
||||||
|
and **KCFG v3** service.
|
||||||
|
Field IDs **6, 7 and 25** remain reserved.
|
||||||
|
The other field IDs retain their meanings and numbering.
|
||||||
|
|
||||||
|
Construct the candidate's raw sections and supply a `NavigationInstallation`
|
||||||
|
through `VehicleCapabilities::quad_x`. It declares sources, calibration and
|
||||||
|
reference identities, range mounting and quantization, contact geometry,
|
||||||
|
magnetic field and local pressure/temperature. Complete validation produces
|
||||||
|
the immutable `VehicleProfile`. The current runtime supports Quad-X allocation
|
||||||
|
and four equal contact spheres on a body-frame rectangle.
|
||||||
|
|
||||||
|
The current controller descriptors include fields 41..44:
|
||||||
|
|
||||||
|
| ID | Key | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `kaptan-sitl serve --config FILE.cbor` | One validated Quad-X profile at process construction. | No live MAVLink endpoint is attached yet. |
|
| 41 | `control.attitude_roll_pitch_gain_per_s` | Roll/pitch attitude gain in 1/s. |
|
||||||
| `kaptan-sitl serve` | The validated factory Quad-X profile. | No persistent store; commits are rejected. |
|
| 42 | `control.attitude_yaw_gain_per_s` | Yaw attitude gain in 1/s. |
|
||||||
| STM32F722 telemetry image | Immutable compiled `BoardConfig` plus checked typestate routes. | Disabled: all inbound MAVLink remains discarded. |
|
| 43 | `control.yaw_rate_pid.proportional` | Yaw rate proportional gain. |
|
||||||
|
| 44 | `control.yaw_rate_pid.integral` | Yaw rate integral gain. |
|
||||||
|
|
||||||
The current SITL profile has only settings consumed by its estimator,
|
Roll/pitch rate gains use the rate-PID fields. Derivative and contribution limits
|
||||||
controller, safety supervisor, allocator envelope, or log hub:
|
are shared.
|
||||||
|
|
||||||
- prearm dwell;
|
Use the generated `kaptan-flight-config::SCHEMA_ID` from the source/build being
|
||||||
- IMU, local-aiding, and command freshness/timing;
|
used. Schema identity describes the field contract; it does not qualify a
|
||||||
- position/velocity aiding variance;
|
physical vehicle or identify calibrated values.
|
||||||
- outer-loop gains and acceleration limits;
|
|
||||||
- mass, collective-thrust, and tilt limits;
|
|
||||||
- rate PID gains and limits;
|
|
||||||
- actuator minimum, maximum, and slew limits; and
|
|
||||||
- the restart-only maximum optional log severity.
|
|
||||||
|
|
||||||
The fixed Quad-X mixer is intentionally not configurable. It is reviewed
|
The test fixture has a 10-ms control period, 20-ms control deadline, 250-ms
|
||||||
capability data, not an operator-editable array.
|
maximum command-admission TTL, 300-ms offboard stream timeout and one-second
|
||||||
|
fallback hold. Its configured output lifetime ceiling is 50 ms, further limited
|
||||||
|
by the 20-ms control deadline and live evidence. The navigation time-error
|
||||||
|
ceiling is 2000 us. These are library contracts, not measured F722 timing
|
||||||
|
guarantees or a qualified aircraft profile.
|
||||||
|
|
||||||
Each editable field declares its ID, display key, unit, default, scalar type,
|
Navigation owns `MAXIMUM_FREE_ACCELERATION_MPS2 = 25`. Complete-profile
|
||||||
and local validation in one `#[config(...)]` attribute. The derive generates
|
validation checks maximum collective thrust `T_max <= m * (25 - g)` using `f64`
|
||||||
the descriptor registry and local validation from that declaration; the only
|
arithmetic, and requires thrust above weight. The bound applies to free flight,
|
||||||
handwritten checks are whole-profile physical relationships such as thrust
|
not contact impulses. Mixer and installation remain compiled capabilities.
|
||||||
margin, timing ordering, and actuator limits.
|
|
||||||
|
|
||||||
## Immutable F722 board policy
|
The 1.2-kg/18-N fixture and its installation values exist only in test support.
|
||||||
|
They do not calibrate physical sensors or identify a propulsion system.
|
||||||
|
|
||||||
The checked-in F722 boards consume these compiled `BoardConfig` settings before
|
## Web configurator
|
||||||
peripheral initialization:
|
|
||||||
|
|
||||||
- IMU model and its fixed body mount (`BodyAligned` or `Yaw180`);
|
Build and flash the board image, boot normally, connect the board's USB port,
|
||||||
- logical MAVLink port, endpoint IDs, and baud rate;
|
and run from the repository root:
|
||||||
- vertical-sensor capability and telemetry schedule; and
|
|
||||||
- maximum optional MAVLink log severity.
|
|
||||||
|
|
||||||
The platform compares that metadata with the selected `F722BoardRoutes` before
|
```sh
|
||||||
claiming pins, UARTs, DMA, or IRQs. A board declaration therefore cannot claim
|
make configurator
|
||||||
an MPU-6000 while selecting the ICM route, select the wrong serial port or
|
```
|
||||||
mount, or request a baud rate that cannot carry the fixed link budget. The
|
|
||||||
physical port, sensor driver, pins, DMA, timers, and calibration are not file
|
|
||||||
configuration and are not GCS-editable.
|
|
||||||
|
|
||||||
F722’s log threshold is immutable board policy. It is deliberately separate
|
Open `http://127.0.0.1:4861`, enter the device path in the Connection section,
|
||||||
from the SITL profile’s `observability.maximum_log_level`: the F722 image does
|
and select **Connect serial**. On Linux the application device normally appears
|
||||||
not construct a runtime `ConfigManager` or profile store.
|
as `/dev/serial/by-id/<kaptan-device>` or `/dev/ttyACM0`. For UART, connect a
|
||||||
|
3.3 V USB-UART adapter to the board-specific RX/TX pads and enter its device
|
||||||
|
path instead.
|
||||||
|
|
||||||
## One CBOR profile format
|
The tool defaults to MAVLink source system `255` and baud `115200`.
|
||||||
|
`--source-system` and `--baud` are command-line overrides. Match the adapter baud
|
||||||
|
to the UART's boot-active value. For example, from `firmware/`, launch
|
||||||
|
`cargo run -p kaptan-configurator --locked --offline -- --baud 230400`.
|
||||||
|
Peers sharing a link need distinct nonzero source identities, but those
|
||||||
|
identities do not authenticate them.
|
||||||
|
|
||||||
There is one unreleased raw profile blob format:
|
1. Connect and wait for discovery to read both saved and boot-active artifacts.
|
||||||
|
Each field shows **Boot-active**, **Saved** and **Draft** values.
|
||||||
|
2. Edit the draft and press **Save**. The target checks the downloaded baseline,
|
||||||
|
validates the complete replacement and persists it. After success, the tool
|
||||||
|
reloads both artifacts to establish the next save's baseline.
|
||||||
|
3. To replace saved settings with the target's factory values, press
|
||||||
|
**Reset saved configuration**. This is a persistence operation, not a reboot.
|
||||||
|
It also works when saved state is unknown, provided active metadata was read.
|
||||||
|
4. Save and factory reset leave active roles, baud, identity, rates and logging
|
||||||
|
unchanged. When restart is required, manually reset or power-cycle the board,
|
||||||
|
then reconnect and check the reloaded values. A UART may now use another baud
|
||||||
|
or no longer carry MAVLink. Normal USB remains the configuration route.
|
||||||
|
|
||||||
- it is a CBOR map keyed by the discovered numeric `FieldId`s;
|
A stale baseline returns `REQUEST_CONFLICT` without writing flash. The tool
|
||||||
- key `0` is `profile_format = 1` and reserved key `65535` is the 16-byte
|
discards that draft. Disconnect and reconnect to reload before editing again.
|
||||||
`schema_id = "KAPTANPROFILE-01"`;
|
If saved state is unknown, ordinary saves are blocked. Reconnect to inspect it,
|
||||||
- the complete blob is at most 4 KiB; and
|
then use factory reset or reboot recovery as described below.
|
||||||
- malformed, trailing, wrong-format, non-finite, out-of-range, cross-field,
|
|
||||||
and capability-invalid values are rejected.
|
|
||||||
|
|
||||||
The map is exact: it has those 26 keys once each. Unknown, duplicate, missing,
|
A changed save or explicit factory reset synchronously erases a 128-KiB journal
|
||||||
or indefinite-map keys are rejected. That keeps the unreleased file contract
|
sector. Flash activity can interrupt telemetry and sensor servicing. The host
|
||||||
unambiguous instead of silently accepting undeclared extensions.
|
allows five seconds for the final close, which is not a measured erase-time
|
||||||
|
guarantee. Keep setup power connected until the operation completes. An unchanged
|
||||||
|
ordinary replacement skips flash writes.
|
||||||
|
|
||||||
There is no migration path because no version has shipped. The raw blob is the
|
See [MAVLink](mavlink.md) and [Logs](logs.md) for the packet and runtime-log views.
|
||||||
future GCS import/export representation. The SITL file is a
|
|
||||||
private host envelope `[generation, raw-profile-blob]`; it is not an upload
|
|
||||||
blob and must not be sent to `IMPORT_*` directly.
|
|
||||||
|
|
||||||
## Future GCS upload contract
|
## Board ownership
|
||||||
|
|
||||||
The custom dialect is prepared for a first-party GCS without borrowing
|
Each F722 board selects `F722UserConfig<Catalog, N>`. The shared factory values
|
||||||
`PARAM_*` semantics. A client will:
|
leave every configurable UART disabled. USB always carries Kaptan MAVLink and
|
||||||
|
has no configurable role. Board catalogs determine which UART fields exist.
|
||||||
|
|
||||||
1. send `KAPTAN_CONFIG_INFO_REQUEST` and require the exact protocol, profile
|
Both current boards expose MAVLink identity, publication rates, log filtering,
|
||||||
format, and schema ID in the correlated `KAPTAN_CONFIG_INFO` reply;
|
and `ports` fields from `kaptan-ports`. Range and optical-flow rates are present
|
||||||
2. request descriptors and values as needed;
|
on both boards because either can assign an inventoried UART to MTF01. Their
|
||||||
3. open a transaction with `KAPTAN_CONFIG_BEGIN`;
|
streams are enabled only when the selected sensor service supplies them.
|
||||||
4. either issue typed setters or begin a raw-CBOR upload with the advertised
|
|
||||||
schema ID, then send aligned idempotent chunks in any order;
|
|
||||||
5. validate and commit; and
|
|
||||||
6. treat `COMMITTED_RESTART_REQUIRED` as persistence only, never live
|
|
||||||
activation.
|
|
||||||
|
|
||||||
An accepted import begin returns a server-issued transfer ID. Every chunk and
|
The adjacent `IMMUTABLE_CONFIG` manifest holds:
|
||||||
finish request echoes it, preventing delayed data from an earlier transfer
|
|
||||||
from entering the current one. The service validates CRC-32C and the entire
|
|
||||||
candidate before it replaces the RAM transaction.
|
|
||||||
|
|
||||||
This is protocol groundwork, not a claim of active aircraft configuration:
|
- the IMU route, buses and interrupt wiring
|
||||||
F722 needs authenticated ingress, source binding and replay protection,
|
- sensor mount and driver setup
|
||||||
journalled target storage, a disarmed/inhibited activation permit, and atomic
|
- the available serial inventory, status LED and optional I²C range route
|
||||||
consumer rebuild/reset before it may accept any configuration command.
|
- service-timer frequency.
|
||||||
|
|
||||||
|
Motor pins are inhibited at startup. The board images have no physical actuator
|
||||||
|
driver.
|
||||||
|
|
||||||
|
UART roles and baud are writable settings applied only at boot. Each inventoried
|
||||||
|
UART supports `Disabled = 0`, `KaptanMavlink = 1` (`Role::Mavlink`), and
|
||||||
|
`Mtf01 = 2`. Disabled ports do not initialize their peripheral or pins.
|
||||||
|
One board-owned `PortCatalog<N>::DESCRIPTORS` array supplies both
|
||||||
|
`Ports<Catalog, N>` metadata and `SerialInventory`. The same const `N` sizes the
|
||||||
|
boot plan, I/O slots and protocol slots. An absent UART has no slot or field.
|
||||||
|
A declared but disabled UART retains its bounded slot storage.
|
||||||
|
|
||||||
|
| Keys | Role / baud field IDs | MCU TX / RX | Board inventory |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `ports.uart1.role`, `ports.uart1.baud` | 201 / 202 | PA9 / PA10 | Both |
|
||||||
|
| `ports.uart2.role`, `ports.uart2.baud` | 203 / 204 | PA2 / PA3 | Both |
|
||||||
|
| `ports.uart3.role`, `ports.uart3.baud` | 205 / 206 | PB10 / PB11 | Both |
|
||||||
|
| `ports.uart4.role`, `ports.uart4.baud` | 207 / 208 | PA0 / PA1 | Both |
|
||||||
|
| `ports.uart6.role`, `ports.uart6.baud` | 211 / 212 | PC6 / PC7 | Both |
|
||||||
|
|
||||||
|
Roles are native `u8` values and baud is `u32`. The F722 allowlist is exactly
|
||||||
|
**115200, 230400, 460800**. Intermediate values are rejected even though the
|
||||||
|
displayed bounds span that range. The current MTF01 driver requires **115200**.
|
||||||
|
At most one UART may use MTF01, and it cannot coexist with the immutable I²C
|
||||||
|
range source. These restrictions come from current platform capabilities.
|
||||||
|
|
||||||
|
Whole-candidate validation checks resource conflicts and the enabled publication
|
||||||
|
schedule against the 100-Hz service budget. Each MAVLink UART must also carry
|
||||||
|
the scheduled framing-inclusive bytes at 8N1. This is an admission check, not a
|
||||||
|
delivery guarantee for unscheduled file or diagnostic traffic. USB has no role
|
||||||
|
or baud field. See [Boards](boards/index.md) for factory assignments.
|
||||||
|
|
||||||
|
## Recovery and activation
|
||||||
|
|
||||||
|
`ConfigManager::recover_with` owns recovery state. F722 supplies an explicit
|
||||||
|
setup policy that validates factory settings with all UART roles disabled when
|
||||||
|
stored settings cannot be activated. Normal USB MAVLink is initialized
|
||||||
|
independently of the UART plan. It is not ROM DFU, and its availability still
|
||||||
|
depends on successful clocks, USB hardware and application initialization.
|
||||||
|
|
||||||
|
- Empty storage selects the board factory configuration.
|
||||||
|
- Readable but invalid committed settings remain available for inspection and
|
||||||
|
repair through USB. The boot-active export shows the UART-disabled fallback.
|
||||||
|
- Unreadable storage or a storage error leaves committed state **unknown**.
|
||||||
|
Active values and factory metadata remain readable. Ordinary writes are
|
||||||
|
blocked until reboot recovery establishes a baseline or factory reset succeeds.
|
||||||
|
|
||||||
|
Fallback selection never rewrites storage. A persistence error also makes the
|
||||||
|
committed view unknown because the attempted write may already be durable.
|
||||||
|
Reboot may therefore activate that attempted value. Explicit factory reset
|
||||||
|
always writes the validated factory, even if values appear unchanged. A failed
|
||||||
|
reset leaves durability unknown. Neither operation changes the immutable
|
||||||
|
boot-active view or its boot diagnostic.
|
||||||
|
|
||||||
|
## Configuration endpoints
|
||||||
|
|
||||||
|
| Path | Access | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/config/user.kcfg` | Read/write | Read the known committed values. Write a complete replacement guarded by its expected base. |
|
||||||
|
| `/config/active.kcfg` | Read-only | Inspect the actual boot selection, including recovery fallback. |
|
||||||
|
| `/config/reset.kcfg` | Write-only | Accept only exact factory requested values. Ignore the expected base and force persistence. |
|
||||||
|
|
||||||
|
Reset still requires a complete schema-matching KCFG artifact. The configurator
|
||||||
|
builds it from active metadata. There is no file command to reboot or activate
|
||||||
|
settings live.
|
||||||
|
|
||||||
|
## KCFG artifact
|
||||||
|
|
||||||
|
KCFG is the public, canonical import/export representation. It is independent
|
||||||
|
of MAVLink and of the private storage format. A file contains:
|
||||||
|
|
||||||
|
- `KCFG` magic, format version **3** and a restart flag
|
||||||
|
- generated 16-byte schema identity and exact field count
|
||||||
|
- one record per field with ID, native kind, requested/current value, factory
|
||||||
|
value and expected-base value at native width, plus field flags, bounds,
|
||||||
|
display key, unit and enum choices
|
||||||
|
- a big-endian CRC-32C trailer covering the preceding artifact bytes.
|
||||||
|
|
||||||
|
Exports put the selected view's value in both current and expected-base slots.
|
||||||
|
Edits change only requested values. On ordinary save, the manager compares every
|
||||||
|
expected-base scalar's native kind and bits with current committed values before
|
||||||
|
whole-candidate validation, no-op detection or persistence. This is a full-value
|
||||||
|
compare-and-swap (CAS). Native-kind-and-bit equality also governs no-op and
|
||||||
|
restart comparisons. Positive and negative floating-point zero differ, while
|
||||||
|
identical NaN payload bits compare equal when the field's rules allow NaN.
|
||||||
|
It is not a CRC, generation counter or history check. A change away from and
|
||||||
|
back to the same values still matches.
|
||||||
|
|
||||||
|
Field flag bits 0 and 1 indicate minimum and maximum bounds. Bit 2 is `finite`
|
||||||
|
and bit 3 is `nonzero`. Other bits are reserved zero. These constraints are
|
||||||
|
independent: `finite` rejects NaN and infinities, while `nonzero` rejects numeric
|
||||||
|
zero, including both floating-point signed zeros. `nonzero` alone does not imply
|
||||||
|
finite values. Any declared floating-point range rejects NaN as unordered even
|
||||||
|
without `finite`. Infinities are subject to the declared bounds and `finite`
|
||||||
|
rule. These flags are descriptor metadata and contribute to schema identity.
|
||||||
|
|
||||||
|
The configurator exchanges floating-point values with the browser as strings.
|
||||||
|
Finite decimal strings preserve signed zero. Nonfinite values use exact IEEE
|
||||||
|
bit strings, `bits:0x` followed by 8 hexadecimal digits for `f32` or 16 for `f64`,
|
||||||
|
preserving NaN sign and payload. This representation does not bypass validation.
|
||||||
|
|
||||||
|
CRC-32C detects accidental corruption. The big-endian trailer avoids the fixed
|
||||||
|
residue produced by a little-endian reflected CRC under the outer transfer CRC,
|
||||||
|
but neither checksum proves unique content or authenticates a peer.
|
||||||
|
|
||||||
|
An imported file must match the target-generated schema, descriptor metadata,
|
||||||
|
factory values, field order, native scalar kinds, and exact length. Unknown,
|
||||||
|
duplicate, missing, malformed, noncanonical, out-of-range, and relationally
|
||||||
|
invalid requested values are rejected. The target stages typed requested and
|
||||||
|
expected-base candidates with a small parser state, not a whole-file buffer.
|
||||||
|
Exports are generated by range from metadata and the selected view, with one
|
||||||
|
length/checksum plan prepared at open.
|
||||||
|
|
||||||
|
The board-agnostic `decode_kcfg` API instead borrows a complete input artifact
|
||||||
|
and requires caller-owned 8192-byte field-ID scratch. Hosted callers can allocate
|
||||||
|
that bitmap directly on the heap. Decoding visits fields once, with work
|
||||||
|
O(input bytes + 8192), and the returned view borrows only the input, so scratch
|
||||||
|
is immediately reusable. This dynamic decoder is distinct from the bounded
|
||||||
|
typed streaming importer used by firmware.
|
||||||
|
|
||||||
|
Schema identity detects schema mismatch. It is not a device identity,
|
||||||
|
authentication token or proof of all hardware compatibility. Complete-candidate
|
||||||
|
validation against the immutable context remains authoritative.
|
||||||
|
|
||||||
|
The restart bit reports manager state, including on the active export. It is not
|
||||||
|
an upload instruction. The target derives it from committed versus boot-active
|
||||||
|
values, or sets it when durable state is unknown.
|
||||||
|
|
||||||
|
## Generic file dialect
|
||||||
|
|
||||||
|
Kaptan uses custom `FILE_CONTROL` and `FILE_DATA` messages rather than standard
|
||||||
|
MAVLink FTP or configuration-specific messages.
|
||||||
|
|
||||||
|
`FILE_CONTROL` opens, closes, aborts, and acknowledges a transfer. It
|
||||||
|
carries a client-selected nonzero `u16` transfer ID, exact target, position,
|
||||||
|
complete file size, CRC-32C, operation/status, and at most 64 bytes of path or
|
||||||
|
result detail. A peer does not reuse an ID during the ten-second replay lease.
|
||||||
|
A new ID from the same source and ingress may supersede an open only before the
|
||||||
|
first byte advances. A progressed abandoned transfer stays pinned until expiry.
|
||||||
|
`FILE_DATA` uses the full MAVLink payload budget for a
|
||||||
|
246-byte chunk. The logical final-chunk length follows from the opened file
|
||||||
|
size, so no redundant length field is sent with every data packet. An upload
|
||||||
|
request carries status `UNSPECIFIED`; a read-data response carries status `OK`.
|
||||||
|
All failures remain typed `FILE_CONTROL` responses.
|
||||||
|
|
||||||
|
One stop-and-wait transfer is shared across all links. Ownership includes the
|
||||||
|
claimed source system/component and the platform-supplied ingress identity.
|
||||||
|
Chunks are sequential. Immediate retries compare exact retained bytes, not just
|
||||||
|
checksums. Conflicting retries are rejected. Accepted owner activity renews the
|
||||||
|
ten-second monotonic lease, while rejected traffic does not. Expiry aborts staged
|
||||||
|
provider state. A receive discontinuity resets framing without itself releasing
|
||||||
|
the file lease. Responses use the requesting link.
|
||||||
|
|
||||||
|
For either writable KCFG endpoint, a successful close has generic status `OK`
|
||||||
|
and an authoritative seven-byte KCFG result prefix:
|
||||||
|
`flags:u8 | primary_field:u16 LE |
|
||||||
|
related_field:u16 LE | validation_rule:u16 LE`. Flag bit 0, when set, means a
|
||||||
|
restart is required, and every other flag bit is reserved zero. The field
|
||||||
|
sentinel is `0xffff`. Rule zero means no rule.
|
||||||
|
KCFG `CONTENT_REJECTED` responses use the same prefix and may append bounded
|
||||||
|
UTF-8 detail. The configurator rejects a successful close with a missing,
|
||||||
|
malformed, or failure-bearing prefix. The file layer passes these bytes through
|
||||||
|
without interpreting them.
|
||||||
|
|
||||||
|
See [MAVLink](mavlink.md) for the local-link trust boundary.
|
||||||
|
|
||||||
|
## Private persistence
|
||||||
|
|
||||||
|
The F722 private storage format is independent of the public KCFG artifact.
|
||||||
|
|
||||||
|
### F722 flash journal
|
||||||
|
|
||||||
|
F722 stores a `KCSP` scalar snapshot inside a two-slot journal.
|
||||||
|
Each entry stores its stable field ID, scalar kind and value at native width. Each
|
||||||
|
journal record includes payload length, CRC-32C, commit marker, and a private
|
||||||
|
monotonic sequence. Saving erases the inactive slot, programs and verifies the
|
||||||
|
complete replacement, and writes the commit marker last. The previous valid
|
||||||
|
slot remains recoverable across power loss until that point.
|
||||||
|
|
||||||
|
Only two complete records can exist: the old committed value and its
|
||||||
|
replacement. Saves do not append history. The sequence is storage metadata used
|
||||||
|
only to select the newer valid slot if both slots survive; it is never exported
|
||||||
|
through KCFG or shown in the UI.
|
||||||
|
|
||||||
|
There is no migration path because no configuration version has shipped.
|
||||||
+119
-32
@@ -1,46 +1,133 @@
|
|||||||
# Development and verification
|
# Development and verification
|
||||||
|
|
||||||
## Fast host checks
|
Use the locked Nix shell. Run Cargo from `firmware/`, which pins Rust 1.90.0.
|
||||||
|
Keep total Cargo concurrency at two jobs. `make help` lists repository commands.
|
||||||
|
|
||||||
|
## Setup and required gates
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
nix develop -c sh -c 'cd firmware && cargo fetch --locked'
|
||||||
cargo test --workspace --offline
|
nix develop -c rustup toolchain install nightly-2025-08-01 \
|
||||||
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
|
--profile minimal --component llvm-tools-preview
|
||||||
cargo clippy -p platform-stm32f7 --all-targets --offline -- -D warnings
|
nix develop -c make verification-tools
|
||||||
cargo fmt --check
|
nix develop -c make verification
|
||||||
git diff --check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Target checks
|
Setup needs network access. Verification uses offline dependencies and runs
|
||||||
|
host, target, board, coverage, Miri and Kani gates sequentially. Kani runs one
|
||||||
|
harness at a time. Firmware CI runs
|
||||||
|
the same command and retains evidence on failure. Keep source fixed during a run.
|
||||||
|
|
||||||
|
| Command, inside `nix develop` | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| `make pre-commit` | Formatting, host tests, host/embedded Clippy, API docs and mdBook. Does not run coverage. |
|
||||||
|
| `make -C firmware target-checks` | Sensor-free consumers, the portable runtime on ARM, and linked RTIC cross-route fixtures. |
|
||||||
|
| `make boards` | Both release board images and final-image placement checks. |
|
||||||
|
| `make coverage` | Strict core line/branch gate and informational totals for other host crates. |
|
||||||
|
| `make miri` / `make kani` | Manifest-selected checks with their own evidence receipts. |
|
||||||
|
| `make verification-inventory` | Package ownership, property claims, assumptions and gaps. |
|
||||||
|
| `env CARGO_BUILD_JOBS=1 make -j1 -C docs check` | Test examples and render mdBook. Review changed links and anchors separately. |
|
||||||
|
|
||||||
|
Host gates exclude the two bare-metal boards and include platform contracts.
|
||||||
|
They enable `kaptan-navigation/sensor-snapshots`. Navigation itself is unconditional.
|
||||||
|
Host platform tests must not invoke MMIO. Rust 1.90.0 runs all doctests and
|
||||||
|
compile-pass/fail fixtures. The test gate also runs the configurator's Node tests,
|
||||||
|
Python script tests, release-LTO final-link cases and the UTF conversion helper.
|
||||||
|
|
||||||
|
Run the native helpers separately with `make -C firmware native-test` and
|
||||||
|
`make app-native-test`. The default artifact directories are
|
||||||
|
`firmware/target/native-tests/final-link/` and `target/native-tests/utf8/`.
|
||||||
|
`CARGO_TARGET_DIR` relocates them. The UTF helper uses a host
|
||||||
|
API stub and 16-bit `wchar_t`; it does not build the Windows runner. See the
|
||||||
|
[final-link fixture](../../firmware/libs/kaptan-err-reportable-macros/tests/final_link.md)
|
||||||
|
for its archive-extraction limit.
|
||||||
|
|
||||||
|
## Evidence and proof scope
|
||||||
|
|
||||||
|
[verification.toml](../../firmware/verification.toml) owns exact tool versions,
|
||||||
|
package/harness selection, required covers, claims, assumptions and gaps. Every
|
||||||
|
workspace package must have one verification area.
|
||||||
|
|
||||||
|
By default, each run creates a new directory under `firmware/target/verification/`. It retains
|
||||||
|
tracked and nonignored untracked source, hashes, Git state, commands, tool
|
||||||
|
identities, logs, Kani exports, successful coverage reports, board ELF/binary
|
||||||
|
artifacts and `receipt.json`. Source drift fails the receipt. To choose a fresh
|
||||||
|
output directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
nix develop -c python3 firmware/scripts/verify.py all --output /tmp/kaptan-review
|
||||||
cargo check -p kaptan-geprc-f722-v2 --release --target thumbv7em-none-eabihf --offline
|
|
||||||
cargo check -p kaptan-kakute-f7-mini-v3 --release --target thumbv7em-none-eabihf --offline
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Strict core coverage
|
Miri checks executed paths and schedules, not all interleavings or physical DMA.
|
||||||
|
Kani checks the manifest's component contracts with default checks and unwind
|
||||||
|
assertions enabled. Required covers must be satisfied. Missing results, timeouts,
|
||||||
|
unsupported operations and unexpected failures fail the gate.
|
||||||
|
|
||||||
|
The PID result has one qualification: finite operands can overflow to opposite
|
||||||
|
infinities whose sum is NaN. The implementation rejects that intermediate value
|
||||||
|
before changing state. Kani's raw verdict remains `Failure`. The runner accepts
|
||||||
|
only the exact declared diagnostic, function and location, with the contract
|
||||||
|
assertions and covers satisfied. Reviewed unreachable automatic checks are
|
||||||
|
recorded separately. No check is disabled or input domain narrowed. A passing
|
||||||
|
repository receipt therefore does not mean every raw Kani verdict passed.
|
||||||
|
|
||||||
|
These checks do not establish numerical accuracy, vehicle stability, arbitrary
|
||||||
|
safety histories, MMIO behavior or MCU timing. Kani's
|
||||||
|
[UB coverage](https://model-checking.github.io/kani/undefined-behaviour.html) and
|
||||||
|
[floating-point models](https://model-checking.github.io/kani/rust-feature-support.html#floating-point-operations)
|
||||||
|
have further limits. Source review and independent numerical evidence remain
|
||||||
|
required. No certification or tool qualification is claimed.
|
||||||
|
|
||||||
|
## Compiler contracts
|
||||||
|
|
||||||
|
| Contract | Enforcement and limit |
|
||||||
|
| --- | --- |
|
||||||
|
| Frames, rotation direction and position versus velocity | Distinct types and compile-fail examples. Raw sensor labeling still needs validation. |
|
||||||
|
| Allocator motor count | Const-generic dimensions. These do not establish physical mixer geometry. |
|
||||||
|
| Validated configuration and output authority | Private fields and distinct types, with compile-fail examples. Constructors and current admission policy remain responsible for validation. |
|
||||||
|
| Numerical-core unsafe operations | `forbid(unsafe_code)` in math, navigation, control, safety and runtime. Dependencies and generated export attributes remain separate boundaries. |
|
||||||
|
| Representation limits | Const assertions for navigation masks, field counts, runtime size and board storage. Object size does not measure peak stack. |
|
||||||
|
| Schemas and UART catalogs | Derive diagnostics and const validation. Received settings still need runtime validation. |
|
||||||
|
| Shared resources | Rust ownership and RTIC's resource graph. Device completion, barriers and MMIO validity remain hardware obligations. |
|
||||||
|
| Error IDs and flash-busy placement | Release final-link fixtures and packaged ELF checks, within their documented linker scope. |
|
||||||
|
|
||||||
|
Strict Clippy retains unsafe-operation and undocumented-unsafe-block lints.
|
||||||
|
A target or feature excluded from a build is not checked by that build.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
`cargo-llvm-cov 0.8.7` comes from the locked Nix shell. The script selects
|
||||||
|
`nightly-2025-08-01`, its matching LLVM tools and the `dev` profile. Normal gates
|
||||||
|
keep Rust 1.90.0. Reports default to `firmware/target/test-coverage/`; an absolute
|
||||||
|
`CARGO_TARGET_DIR` relocates them. The verification runner uses a separate build directory.
|
||||||
|
|
||||||
|
Math, navigation, control and safety require 100% lines and branch outcomes.
|
||||||
|
Empty denominators fail. The summarizer unions LCOV source lines and LLVM branch
|
||||||
|
spans across generic instantiations. Other totals are informational, but any
|
||||||
|
test/report failure fails the gate. An absent instrumented host body is unavailable.
|
||||||
|
|
||||||
|
All host-testable members are included. Navigation enables `sensor-snapshots`
|
||||||
|
and MAVLink enables `sensor-telemetry`. Compiler-diagnostic fixtures remain on
|
||||||
|
Rust 1.90.0: coverage selects `--lib --test section` for `kaptan-config-derive`
|
||||||
|
and `--lib` for `kaptan-err-reportable-macros`. Their reported runtime coverage
|
||||||
|
does not establish compiler-phase macro coverage.
|
||||||
|
|
||||||
|
## Ground suite builds
|
||||||
|
|
||||||
|
The separate locked shell supplies Flutter, Dart and desktop dependencies:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
nix develop .#ground-suite
|
||||||
scripts/report-test-coverage.sh
|
cd ground_suite
|
||||||
|
flutter pub get --enforce-lockfile
|
||||||
|
dart format --output=none --set-exit-if-changed lib
|
||||||
|
dart analyze
|
||||||
|
flutter analyze --no-pub
|
||||||
|
flutter build web --release --no-pub
|
||||||
|
flutter build linux --release --no-pub
|
||||||
```
|
```
|
||||||
|
|
||||||
The strict gate requires 100% line and branch coverage for `kaptan-math`,
|
The app currently has no widget-test suite. Windows and macOS need their native
|
||||||
`kaptan-navigation`, `kaptan-control`, and `kaptan-safety`. It isolates each
|
build and runtime checks.
|
||||||
core package's coverage artifacts, unions LCOV source-line records, and uses
|
|
||||||
full LLVM source spans to merge generic branch instantiations without lowering
|
|
||||||
the gate.
|
|
||||||
|
|
||||||
## Simulation acceptance
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd firmware
|
|
||||||
python3 tools/sitl/world-structure-test.py
|
|
||||||
python3 -m unittest tools/sitl/test_kaptan_sim.py
|
|
||||||
tools/sitl/e2e-smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The Docker smoke is intentionally separate from host unit tests because it
|
|
||||||
builds the pinned base images, Rust binary, Gazebo plugin, and full loopback
|
|
||||||
plant.
|
|
||||||
+89
-34
@@ -1,52 +1,107 @@
|
|||||||
# Errors
|
# Errors
|
||||||
|
|
||||||
Public Kaptan error enums derive the pure `KaptanError` implementation:
|
Public reportable Kaptan error enums derive `KaptanError`:
|
||||||
|
|
||||||
```rust
|
```rust,ignore
|
||||||
#[derive(Debug, kaptan_log::KaptanError)]
|
#[derive(Debug, kaptan_err_reportable::KaptanError)]
|
||||||
enum SensorError {
|
enum SensorError {
|
||||||
|
#[kaptan_error(id = 12345)]
|
||||||
TransferFault,
|
TransferFault,
|
||||||
|
#[kaptan_error(id = 12346)]
|
||||||
UnexpectedIdentity(u8),
|
UnexpectedIdentity(u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[kaptan_err_reportable::propagate]
|
||||||
|
fn service_sensor() -> Result<(), SensorError> {
|
||||||
|
read_sensor()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The derive implements `core::error::Error`, `Display`, and `ErrorReport`. It
|
The derive implements `core::error::Error`, `Display`, and `ReportableError`. It
|
||||||
creates a static `module::Type::Variant` error path without allocating,
|
requires a nonzero explicit ID on every variant and retains no type, variant,
|
||||||
publishing telemetry, exposing generic source values, or changing safety
|
or module string. `Display` renders the decimal ID. Renaming, moving, or
|
||||||
state. Renaming a module, type, or variant changes that path; it is not a
|
reordering a Rust variant therefore does not change its receiver contract.
|
||||||
stable machine protocol identity.
|
Missing and same-enum duplicate IDs are rejected by the derive. Reusing an ID
|
||||||
|
across error enums generates the same reserved symbol. Collision detection
|
||||||
|
requires both defining objects to participate in compilation or final linking.
|
||||||
|
It does not inspect unextracted archive members or prove catalog-wide uniqueness.
|
||||||
|
Stable ID allocation still requires review.
|
||||||
|
|
||||||
Normal-task code may use `log::error!("{error}")`; it is routed as a bounded
|
The [final-link fixture](../../firmware/libs/kaptan-err-reportable-macros/tests/final_link.md)
|
||||||
`KAPTAN_ERROR`. ISR-adjacent flight paths may enqueue the derived static
|
checks Rust 1.90.0 release fat-LTO and native Linux static-library links.
|
||||||
message through their existing bounded mailbox. Neither route is a replacement
|
Run it with `make -C firmware native-test`. Each run retains its results and
|
||||||
for safety state, actuator inhibit, or typed configuration results.
|
Rust compiler identity in `results.txt`.
|
||||||
|
|
||||||
|
`#[kaptan_err_reportable::propagate]` turns an annotated function into a
|
||||||
|
reporting point. If a reportable `Err` leaves that function, the attribute makes
|
||||||
|
one nonblocking admission attempt and returns the exact same `Err` to the
|
||||||
|
caller. The record contains the final converted error ID and the compile-time
|
||||||
|
`file:line` of that reporting point. This is where the `Err` enters
|
||||||
|
diagnostics, not necessarily the line where a deeper callee originally
|
||||||
|
constructed it. `?`, explicit returns, result aliases, and `From` conversion
|
||||||
|
retain their normal Rust behavior. An error handled inside the function is
|
||||||
|
silent.
|
||||||
|
|
||||||
|
## Stable IDs
|
||||||
|
|
||||||
|
Production error IDs are plain decimal numbers from `1` through `59999`.
|
||||||
|
Values from `60000` through `65535` are reserved for tests, and `0` is
|
||||||
|
invalid. Once issued, an ID is never renumbered or reused, even if its error
|
||||||
|
variant is removed. Adding a variant therefore requires allocating a new ID;
|
||||||
|
renaming, moving, or reordering an existing variant keeps its assigned ID.
|
||||||
|
The example numbers above are illustrative and must be replaced with
|
||||||
|
unallocated catalog values when used in production code.
|
||||||
|
|
||||||
|
Only the intended reporting point is annotated. If nested functions are both
|
||||||
|
annotated, each function makes its own report attempt; the ingress does not
|
||||||
|
mark or mutate error values to deduplicate them. Expected validation,
|
||||||
|
backpressure, retry, and untrusted-input results remain unannotated. In
|
||||||
|
particular, transient SPI/I²C `Busy` and unavailable range measurements stay
|
||||||
|
inside their service retry/idle paths. In contrast, every logging
|
||||||
|
macro—including `kaptan_log::error!`—produces only
|
||||||
|
`LOG`. Error reporting is not a replacement for safety state,
|
||||||
|
actuator inhibit, or typed configuration results.
|
||||||
|
|
||||||
|
`kaptan-err-reportable` owns reportable-error identity and its independent
|
||||||
|
one-slot ingress. A pending record contains only the compact ID and a static
|
||||||
|
reference to the reporting point's `file:line`. The MAVLink adapter copies that
|
||||||
|
location into its fixed field when constructing `ERROR`. Producers never
|
||||||
|
encode or transmit MAVLink themselves. Admission is bounded and lossy if a
|
||||||
|
different reportable error is already pending.
|
||||||
|
|
||||||
## What the receiver sees
|
## What the receiver sees
|
||||||
|
|
||||||
- Configuration writes receive authoritative structured failures through
|
- A rejected KCFG upload receives an authoritative `FILE_CONTROL`
|
||||||
`KAPTAN_CONFIG_RESULT`: operation, lifecycle state, field IDs, validation
|
status and bounded readable detail. Invalid user content is not a firmware
|
||||||
rule, and protocol error code.
|
error. A durable-storage failure additionally emits one stable
|
||||||
- Runtime driver and link faults reach the F722 MAVLink worker as a
|
`PersistConfigurationError` ID that distinguishes journal geometry, model,
|
||||||
`KAPTAN_ERROR` path, subject to bounded transport admission. The error
|
sequence, flash-controller, bounds, unlock, and verification
|
||||||
payload is `{ flags, path_length, additional_information_length, path[96],
|
failures. Replaying the cached close result does not repeat the report or
|
||||||
additional_information[64] }`; each length identifies the valid UTF-8
|
flash operation. Any persistence error makes committed state unknown and
|
||||||
prefix and the optional information field may be empty. A `KaptanError`
|
blocks ordinary saves until reboot recovery or a successful explicit factory
|
||||||
derive supplies the type/variant path and normally leaves additional
|
reset. Boot-active values remain unchanged.
|
||||||
information empty. `log::error!` uses the Rust log target as the path and
|
- A stale KCFG expected base returns `REQUEST_CONFLICT` before persistence.
|
||||||
the formatted message as additional information.
|
It is a reload-required editing conflict, not a reportable firmware error.
|
||||||
- A rejected configuration operation may enqueue a companion `KAPTAN_ERROR`
|
Invalid boot configuration uses the UART-disabled USB setup path and retains
|
||||||
whose path is the high-level `ProtocolError` identity. It is intentionally
|
readable saved values for repair. See [Configuration](configuration.md).
|
||||||
not correlated and carries no field/rule detail; the matching
|
- SPI completion errors never copy partial data into the caller's receive
|
||||||
`KAPTAN_CONFIG_RESULT` remains the sole precise authority.
|
buffer. If bounded shutdown cannot establish quiescence, the SPI route
|
||||||
- Control, navigation, safety, and simulator error types are derive-ready but
|
quarantines its static DMA storage and refuses further transfers. This is
|
||||||
|
distinct from transient `Busy`. Physical SPI shutdown and fault behavior
|
||||||
|
remain hardware-validation work.
|
||||||
|
- Reported `ReportableError` values reach the F722 MAVLink worker as a
|
||||||
|
`ERROR`, subject to bounded transport admission. The payload is
|
||||||
|
`{ error_id, raised_at[96] }`. `raised_at` is a zero-padded UTF-8
|
||||||
|
`file:line` prefix for the reporting point.
|
||||||
|
Dynamic human-readable context belongs in `LOG`, while
|
||||||
|
structured protocol failures remain in their owning response message.
|
||||||
|
- Control, navigation and safety error types are derive-ready but
|
||||||
their live owners are not yet attached to a MAVLink worker.
|
their live owners are not yet attached to a MAVLink worker.
|
||||||
- `KAPTAN_SHUTDOWN`, defined in `definitions/kaptan/shutdown.xml`, represents
|
|
||||||
only a future completed orderly,
|
F722 uses `panic-halt`, without live panic reporting or retained-record replay.
|
||||||
output-inhibited shutdown. It is not a shutdown request, panic, hard-fault,
|
|
||||||
reset, or retention-record protocol. F722 still uses `panic-halt`; it does
|
|
||||||
not claim a live panic frame or retained-record replay.
|
|
||||||
|
|
||||||
No diagnostic message may synchronously transmit from a panic handler, hard
|
No diagnostic message may synchronously transmit from a panic handler, hard
|
||||||
fault, ISR, DMA callback, or motor-inhibit path. A later target integration
|
fault, ISR, DMA callback, or motor-inhibit path. A later target integration
|
||||||
must inhibit outputs first, persist a validated crash record, then report it
|
must inhibit outputs first, persist a validated crash record, then report it
|
||||||
only after the next boot’s static board telemetry is available.
|
only after MAVLink is available on the next boot.
|
||||||
@@ -4,27 +4,51 @@
|
|||||||
|
|
||||||
Use the firmware toolchain, Rust 1.90. The firmware workspace declares the
|
Use the firmware toolchain, Rust 1.90. The firmware workspace declares the
|
||||||
embedded F7 targets and `llvm-tools-preview` in `firmware/rust-toolchain.toml`.
|
embedded F7 targets and `llvm-tools-preview` in `firmware/rust-toolchain.toml`.
|
||||||
|
The repository development shell also supplies `dfu-util` and the checked
|
||||||
|
`mdbook` version:
|
||||||
|
|
||||||
For host development, run:
|
```sh
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
From the repository root, discover the developer commands with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetch the pinned Rust dependencies once before the offline checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
cd firmware
|
||||||
cargo test --workspace --offline
|
cargo fetch --locked
|
||||||
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
|
```
|
||||||
|
|
||||||
|
From the repository root, run the checks with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make pre-commit
|
||||||
|
make coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
target-specific commands in [Development and verification](development.md).
|
target-specific commands in [Development and verification](development.md).
|
||||||
|
Pre-commit does not run coverage. The separate required coverage gate needs
|
||||||
|
the dated nightly setup documented there. Shared navigation and sensor snapshot
|
||||||
|
conversion remain covered through `kaptan-navigation/sensor-snapshots`.
|
||||||
|
|
||||||
|
For the app, enter `nix develop .#ground-suite`. Its locked Flutter and Dart
|
||||||
|
tools are separate from the firmware shell. App analysis
|
||||||
|
and web/Linux build commands are listed in the development guide.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
| Path | Responsibility |
|
| Path | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `firmware/boards/` | Immutable board declarations and linker layouts. |
|
| `firmware/boards/` | Hardware declarations, UART catalogs and image roots. |
|
||||||
| `firmware/platforms/stm32f7/` | Reviewed F722 RTIC platform and peripheral ownership. |
|
| `firmware/platforms/stm32f7/` | F722 RTIC platform, user schema, factory values, peripheral ownership and linker layout. |
|
||||||
| `firmware/libs/` | Typed math, estimation, control, safety, protocol, and driver crates. |
|
| `firmware/libs/` | Typed math, estimation, control, safety, protocol, and driver crates. |
|
||||||
| `firmware/tools/sitl/` | Host-only closed-loop Gazebo simulation. |
|
| `firmware/tools/configurator/` | Board-agnostic loopback web UI and serial bridge. |
|
||||||
| `ground-suite/` | Desktop ground-control application. |
|
|
||||||
| `docs/` | This Markdown documentation site. |
|
| `docs/` | This Markdown documentation site. |
|
||||||
|
|
||||||
Do not add board-local READMEs. Add a page here instead, then link it from
|
Do not add board-local READMEs. Add a page here instead, then link it from
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# Ground Suite
|
|
||||||
|
|
||||||
`ground-suite/` is Kaptan's desktop Flutter application. Its primary
|
|
||||||
workspace is the Tactical Command surface: a spatial local scene, local units,
|
|
||||||
and session-local drawings and markers. The decoded diagnostic event stream is
|
|
||||||
always reachable from the tactical header.
|
|
||||||
|
|
||||||
## Application model
|
|
||||||
|
|
||||||
The core model keeps these concepts independent:
|
|
||||||
|
|
||||||
- **Vehicle definition** — immutable reviewed profile, catalog identity, and
|
|
||||||
bundled presentation media.
|
|
||||||
- **Local fleet unit** — an operator-created instance of a vehicle definition.
|
|
||||||
Local-unit IDs and in-memory planning placements belong to the workspace,
|
|
||||||
not the vehicle definition. Current placement is local presentation data and
|
|
||||||
is not yet persisted.
|
|
||||||
- **Received vehicle data** — transport input and diagnostic events. It cannot
|
|
||||||
create, position, identify, or authorize a local fleet unit.
|
|
||||||
|
|
||||||
This separation gives the fleet interface a stable path from local planning to
|
|
||||||
future live operation without coupling visual selection to incoming telemetry.
|
|
||||||
|
|
||||||
## Tactical workspace
|
|
||||||
|
|
||||||
The default route is Tactical Command. Its map-edge `UNITS`, `CURSOR`, and
|
|
||||||
`VIEW` groups are local interactions, not MAVLink controls. `Selection cursor`
|
|
||||||
is the default mode for map and rectangle selection. `Pencil cursor` creates
|
|
||||||
session-local freehand strokes; its top toolbar controls hue, saturation,
|
|
||||||
brightness, and thickness. `Marker cursor` has an independent full-color
|
|
||||||
style and creates a named local marker only after a map click and explicit
|
|
||||||
save. The shared disclosure keeps local scene data,
|
|
||||||
vehicle link state, and command authority separate. Local drawings and markers
|
|
||||||
are never reported vehicle positions.
|
|
||||||
|
|
||||||
`Add local unit` opens the horizontal local hangar. Choosing a definition only
|
|
||||||
prepares a local unit draft; the explicit setup confirmation creates and
|
|
||||||
selects a locally scattered scene unit, without asking the operator to click
|
|
||||||
the map. Local setup currently permits only the workspace label. Vehicle
|
|
||||||
configuration remains unavailable until verified binding, compatible schema,
|
|
||||||
command authority, and safe activation are available. This presentation
|
|
||||||
placement is temporary until reviewed MAVLink position data can drive a
|
|
||||||
distinct reported-position layer. The right inspector and bottom selection
|
|
||||||
console are available outside the map for keyboard and assistive-technology
|
|
||||||
access. These interactions create only session-local workspace data.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```text
|
|
||||||
VehicleDataPort -> typed observation updates -> observability state -> event stream
|
|
||||||
Vehicle catalog -> local-unit workspace state -> tactical scene and unit inspector
|
|
||||||
```
|
|
||||||
|
|
||||||
The transport path and local-fleet path intentionally meet only through future
|
|
||||||
explicit bindings. A future MAVLink adapter combines pinned MAVLink Common and
|
|
||||||
Kaptan dialect definitions at the decoding boundary.
|
|
||||||
|
|
||||||
## Follow-up work
|
|
||||||
|
|
||||||
- TODO(gcs-fleet-storage): versioned persistence for local units and catalog
|
|
||||||
identities.
|
|
||||||
- TODO(gcs-source-binding): explicit source-to-unit binding.
|
|
||||||
- TODO(gcs-map-pose): coordinate datum, timestamped pose, uncertainty, and
|
|
||||||
staleness handling for physical map placement.
|
|
||||||
- TODO(gcs-map-renderer): owned Linux GPU map host, pinned MapLibre assets,
|
|
||||||
and reviewed vector/DEM data source.
|
|
||||||
- TODO(gcs-platform-match): compare reported build/schema metadata to the
|
|
||||||
local definition.
|
|
||||||
- TODO(gcs-command-surface): add command interaction only with its reviewed
|
|
||||||
authority, target-binding, and safety contracts.
|
|
||||||
- TODO(gcs-catalog): generate installed definitions and category metadata from
|
|
||||||
reviewed firmware build manifests.
|
|
||||||
+10
-24
@@ -1,36 +1,22 @@
|
|||||||
# Overview
|
# Overview
|
||||||
|
|
||||||
Kaptan is an embedded Rust flight-controller project. This manual is the
|
Kaptan is an embedded Rust flight-controller project.
|
||||||
single source of project documentation: board support, safety boundaries,
|
|
||||||
simulation, development workflow, and design references live here as
|
|
||||||
Markdown.
|
|
||||||
|
|
||||||
> **Status:** the checked-in firmware is telemetry-only. It does not expose
|
> **Status:** The board images do not yet run the flight runtime or implement
|
||||||
> physical motor authority, and this site does not make a physical-flight
|
> motor drivers. Motor pins are inhibited at startup. Physical flight has not
|
||||||
> release claim.
|
> been qualified.
|
||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
- New contributors should read [Getting started](getting-started.md).
|
- New contributors should read [Getting started](getting-started.md).
|
||||||
- Firmware source and its Rust workspace begin in `firmware/`; the desktop
|
- Firmware source and its Rust workspace begin in `firmware/`.
|
||||||
application is a sibling project in `ground-suite/`.
|
|
||||||
- Board work belongs under [Boards](boards/index.md).
|
- Board work belongs under [Boards](boards/index.md).
|
||||||
- The currently supported closed-loop simulation is [Host SITL](sitl.md).
|
- [Configuration](configuration.md) explains target-described user settings
|
||||||
- [Configuration](configuration.md) distinguishes the current SITL profile
|
and immutable F722 hardware policy.
|
||||||
from immutable F722 board policy.
|
- [Logs](logs.md) and [Errors](errors.md) describe MAVLink diagnostics.
|
||||||
- [Logs](logs.md) and [Errors](errors.md) describe the custom MAVLink
|
|
||||||
observability surface and its current safety scope.
|
|
||||||
- Before submitting changes, follow [Development and verification](development.md).
|
- Before submitting changes, follow [Development and verification](development.md).
|
||||||
|
|
||||||
## Documentation conventions
|
## Documentation conventions
|
||||||
|
|
||||||
Documentation is centralized in `docs/src/`; individual crates and board
|
Keep documentation in `docs/src/`, add pages to `SUMMARY.md`, and place commands
|
||||||
directories do not carry README files. The top-level `firmware/README.md`
|
beside their prerequisites.
|
||||||
only defines the firmware workspace boundary. Keep pages in Markdown, add each
|
|
||||||
page to `SUMMARY.md`, and place commands beside the assumptions and safety
|
|
||||||
boundary they depend on.
|
|
||||||
|
|
||||||
The current configuration contract is intentionally deployment-specific. A
|
|
||||||
SITL Quad-X profile is a bounded restart-only CBOR snapshot; F722 hardware
|
|
||||||
selection remains immutable board policy. Neither is permission for an
|
|
||||||
operator to substitute runtime values or bypass an immutable safety decision.
|
|
||||||
+69
-48
@@ -1,68 +1,89 @@
|
|||||||
# Logs
|
# Logs
|
||||||
|
|
||||||
Normal host/SITL task code can use the standard facade:
|
Logging macros work on host and flight targets:
|
||||||
|
|
||||||
```rust
|
```rust,ignore
|
||||||
log::debug!("estimator waiting for IMU");
|
kaptan_log::debug!("estimator waiting for IMU");
|
||||||
log::info!("configuration loaded");
|
kaptan_log::info!("configuration loaded");
|
||||||
log::warn!("range aid stale");
|
kaptan_log::warn!("range aid stale");
|
||||||
log::error!("sensor transfer failed"); // emitted as KAPTAN_ERROR
|
kaptan_log::error!("sensor transfer failed");
|
||||||
|
kaptan_log::trace!("prediction step complete");
|
||||||
```
|
```
|
||||||
|
|
||||||
`kaptan-log` formats once into a fixed bounded buffer, queues locally, and
|
All five macros emit `LOG`; their level never selects a different wire
|
||||||
never sends MAVLink, allocates, blocks, changes flight state, or touches DMA.
|
message. When a reportable firmware error escapes a
|
||||||
The sole MAVLink worker drains admitted records. F722 deliberately does not
|
`#[kaptan_err_reportable::propagate]` function, it makes one bounded admission attempt
|
||||||
link this facade in ISR, DMA, control, actuator, panic, hard-fault, or shutdown
|
to the separate `ERROR` ingress.
|
||||||
contexts; those paths use static fault mailboxes that the worker turns into
|
|
||||||
messages later.
|
`kaptan-log` formats each event once into fixed 160-byte UTF-8 storage. Its core
|
||||||
|
storage and admission paths use no allocation or blocking, and do not send
|
||||||
|
MAVLink, change flight state or touch DMA. Formatting invokes caller-supplied
|
||||||
|
`Display` implementations, which may allocate, block, ignore `fmt::Error` or do
|
||||||
|
unbounded work. Overflow stops formatters that propagate the error and latches
|
||||||
|
truncation even if a formatter ignores it. Bounded storage is not a universal
|
||||||
|
worst-case execution-time guarantee. Producers must choose formatters suitable
|
||||||
|
for their execution context.
|
||||||
|
|
||||||
|
The composition fans the event into each enabled sink. The MAVLink worker drains
|
||||||
|
its bounded sink queue. Reportable-error records contain the explicit ID and a
|
||||||
|
static reference to the reporting point until the MAVLink adapter copies its
|
||||||
|
`file:line` into the 96-byte `raised_at` field.
|
||||||
|
Logs and reportable errors use independent fixed atomic
|
||||||
|
ingresses; interrupt and worker producers make one nonblocking admission
|
||||||
|
attempt, and only the MAVLink worker encodes or transmits a retained event.
|
||||||
|
Panic and hard-fault paths still do not attempt live telemetry.
|
||||||
|
|
||||||
## Receiver contract
|
## Receiver contract
|
||||||
|
|
||||||
`definitions/kaptan/log.xml`, `definitions/kaptan/error.xml`, and
|
`definitions/publish/log.xml` and `definitions/publish/error.xml` define the
|
||||||
`definitions/kaptan/shutdown.xml` separate ordinary logs, errors, and
|
separate ordinary-log and reportable-error publications:
|
||||||
orderly-shutdown observations:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
KAPTAN_LOG { level, flags, message_length, message[160] }
|
LOG { level, flags, message_length, message[160] }
|
||||||
KAPTAN_ERROR {
|
ERROR {
|
||||||
flags, path_length, additional_information_length,
|
error_id, raised_at[96]
|
||||||
path[96], additional_information[64]
|
|
||||||
}
|
}
|
||||||
KAPTAN_SHUTDOWN { reason }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A GCS decoder generated from the same `kaptan.xml` revision must validate
|
A GCS decoder generated from the same `kaptan.xml` revision must validate
|
||||||
`message_length <= 160` for `KAPTAN_LOG`, and `path_length <= 96` plus
|
`message_length <= 160` for `LOG`. `ERROR.raised_at` is a
|
||||||
`additional_information_length <= 64` for `KAPTAN_ERROR`, before slicing the
|
zero-padded UTF-8 `file:line` prefix; receivers use bytes before the first zero,
|
||||||
fixed arrays into UTF-8 prefixes. Generated MAVLink framing alone does not
|
or the complete field when no zero is present.
|
||||||
enforce these semantic length fields. `KAPTAN_LOG` displays its text at the
|
`LOG.level` is the typed severity-ordered Kaptan wire enum Trace (1), Debug
|
||||||
MAVLink severity in `level`; `TRACE` is encoded as `DEBUG` plus the `TRACE`
|
(2), Info (3), Warn (4), or Error (5). Off (0) is a filter-only sentinel and
|
||||||
flag.
|
is never emitted as a `LOG` event. `LOG.flags` currently owns only the
|
||||||
`KAPTAN_ERROR` is error-only and accepts only `TRUNCATED` and `COALESCED`; a
|
Truncated bit (1).
|
||||||
`TRACE` bit is never emitted there. Unknown future flag bits must be ignored.
|
|
||||||
|
|
||||||
The derived error path is intentionally not a numeric error catalog; renaming
|
`error_id` is the stable nonzero catalog number explicitly assigned to the
|
||||||
its Rust module/type/variant changes that identity. Error details requiring
|
Rust error variant. Renaming or reordering a variant does not change it.
|
||||||
machine action stay in their owning protocol, notably `KAPTAN_CONFIG_RESULT`.
|
`raised_at` identifies the reporting point where the `Err` entered
|
||||||
`KAPTAN_SHUTDOWN` has only its typed reason and is valid only after an orderly
|
diagnostics. Protocol failures remain in their owning response; for example, a
|
||||||
output-inhibited completion. Delivery is bounded and lossy under link pressure;
|
rejected KCFG upload returns a `FILE_CONTROL` failure and readable
|
||||||
heartbeat and safety telemetry use separate paths.
|
bounded detail.
|
||||||
|
Delivery is bounded and lossy under link pressure. Heartbeat and sensor
|
||||||
|
telemetry use separate paths. Logs have no publication rate setting and are
|
||||||
|
not periodic streams. The worker offers each retained event to USB and every
|
||||||
|
MAVLink UART, then removes it once at least one transport admits it. Admission
|
||||||
|
does not prove receipt or delivery to every link. If none admits it, the event
|
||||||
|
remains pending. Opening USB does not take diagnostics away from UART.
|
||||||
|
|
||||||
## Filtering
|
## Filtering
|
||||||
|
|
||||||
The SITL profile’s restart-only `observability.maximum_log_level` accepts
|
The single restart-only `mavlink.logging.level_filter` field uses Off (0),
|
||||||
MAVLink severities Error (3) through Debug (7). For example, selecting Warning
|
Trace (1), Debug (2), Info (3), Warn (4), and Error (5). `Info`, for example,
|
||||||
permits warning records while suppressing info and debug records. Error-level
|
accepts Info, Warn, and Error log events. `Off` suppresses `LOG` on MAVLink
|
||||||
records always use `KAPTAN_ERROR` and remain non-filterable. F722 uses its
|
without suppressing reportable `ERROR` events or logs sent to another
|
||||||
immutable board log threshold instead, because it has no active runtime
|
sink. There is no separate enable flag and no MAVLink-specific set of extra
|
||||||
profile.
|
runtime log levels. F722 uses the validated boot-active filter. Empty storage
|
||||||
|
uses factory values. Invalid storage selects the setup fallback, which also
|
||||||
|
uses the factory filter but disables UART roles. Saved changes and factory
|
||||||
|
reset do not alter the active filter until reboot. See
|
||||||
|
[configuration recovery](configuration.md#recovery-and-activation).
|
||||||
|
|
||||||
Cargo `log/max_level_*` features compile calls away before the facade sees
|
Events retain the compact typed `Level` and sink configuration retains the
|
||||||
them; they are not a supported filtering mechanism. The build policy keeps
|
compact typed `LevelFilter`. Schema metadata supplies the filter choices to the
|
||||||
that decision out of workspace dependencies.
|
configurator.
|
||||||
|
Only the MAVLink adapter converts the five event levels to the typed Kaptan
|
||||||
|
wire enum.
|
||||||
|
|
||||||
The tested `SitlConfigRuntime` composition proves `log!` reaches a generated
|
The log ingress is transport-neutral and unfiltered.
|
||||||
`KAPTAN_LOG` payload, and rejected configuration replies enqueue a generated
|
|
||||||
`KAPTAN_ERROR` companion without replacing their authoritative result. `kaptan-sitl
|
|
||||||
serve` does not yet attach a config/diagnostic MAVLink link, and F722 emits only
|
|
||||||
outbound telemetry/diagnostic frames while rejecting all inbound authority.
|
|
||||||
+93
-19
@@ -1,30 +1,104 @@
|
|||||||
# MAVLink and telemetry
|
# MAVLink and telemetry
|
||||||
|
|
||||||
Kaptan emits telemetry using MAVLink 2 framing. The production receiver does
|
Kaptan uses MAVLink 2 with its private dialect. It does not support `common`,
|
||||||
not promote inbound MAVLink frames to authority records:
|
`minimal` or other dialects. Reception follows these rules:
|
||||||
|
|
||||||
- MAVLink 1 is discarded.
|
- MAVLink 1 is discarded.
|
||||||
- CRC-invalid MAVLink 2 frames are discarded.
|
- CRC-invalid MAVLink 2 frames are discarded.
|
||||||
- Signed frames are discarded because no signature verifier or persistent
|
- Signed frames are discarded because no signature verifier or persistent
|
||||||
replay state exists.
|
replay state exists.
|
||||||
- Unsigned MAVLink 2 frames are also discarded; a V2 CRC detects corruption,
|
- CRC-valid unsigned MAVLink 2 frames are retained only when their message ID
|
||||||
not authentication.
|
and extra CRC belong to the Kaptan dialect. The board firmware consumes
|
||||||
|
exact-target Kaptan file requests.
|
||||||
|
|
||||||
`TODO(signing)`: any future inbound authority requires verified MAVLink 2
|
Shared or remote inbound authority is unsupported. Enabling it
|
||||||
signing, configured source/target binding, persistent anti-replay state, and a
|
requires verified MAVLink 2 signing, configured source/target binding,
|
||||||
provisioning/key-rotation policy as one reviewed feature.
|
persistent anti-replay state, and a provisioning/key-rotation policy as one
|
||||||
|
reviewed feature.
|
||||||
|
|
||||||
Telemetry transmission uses bounded frame classes. A critical frame may
|
Each F722 board image initializes USB CDC-ACM as a MAVLink link independently
|
||||||
replace pending best-effort traffic, but never the DMA-owned active buffer.
|
of UART roles. Linux normally names it `/dev/ttyACM*`. It is separate from the
|
||||||
Completion retries preserve ordering and retain a transiently busy frame for a
|
STM32 ROM DFU device used for flashing. Every UART assigned `KaptanMavlink` at
|
||||||
later service turn.
|
boot has its own link. USB needs an enumerated device and asserted DTR to carry
|
||||||
|
traffic. Opening or closing it does not redirect UART publications.
|
||||||
|
|
||||||
|
Each link has independent scheduling, encoding, framing and bounded TX state.
|
||||||
|
Current links share the same boot-active endpoint identity and publication
|
||||||
|
rates, with one shared sensor snapshot per service turn. There are no per-link
|
||||||
|
rate settings. A stalled link does not consume another link's publication state,
|
||||||
|
though all links share the protocol worker and flash-operation stalls. File
|
||||||
|
responses always return on the requesting link.
|
||||||
|
|
||||||
|
The current descriptor uses the pid.codes shared development identity
|
||||||
|
`1209:0001` plus the STM32 factory device ID as its serial number. Obtain and
|
||||||
|
substitute a project-specific PID before distributing hardware as a product.
|
||||||
|
|
||||||
|
Any connected transmitter can issue file requests without authentication. Use a
|
||||||
|
direct USB cable or USB-UART adapter and disconnect other transmitters while
|
||||||
|
configuring. Motor pins remain low throughout configuration.
|
||||||
|
|
||||||
|
One file service serializes transfers across USB and all MAVLink UARTs. Its
|
||||||
|
owner is the claimed source system/component plus ingress identity, so the same
|
||||||
|
wire identity on another port cannot take over a transfer. Completed replies
|
||||||
|
have an exact bounded replay path so a lost close response does not repeat a
|
||||||
|
flash operation. Neither source identity, schema identity nor CRC authenticates
|
||||||
|
a peer. [Configuration](configuration.md#generic-file-dialect) defines the lease
|
||||||
|
and retry contract.
|
||||||
|
|
||||||
|
Telemetry transmission uses bounded frame classes. A critical frame may replace
|
||||||
|
pending best-effort traffic, but never an active frame. UART TX is IRQ-owned:
|
||||||
|
each interrupt writes at most one byte and retires at most one completed frame.
|
||||||
|
USB advances a retained frame through bounded CDC endpoint writes. DTR close,
|
||||||
|
suspend and deconfiguration pause USB TX. Only a bus reset discards its TX state.
|
||||||
|
Closing DTR discards partial input. UART receive faults reset framing. File and
|
||||||
|
replay state remain governed by the protocol lease rather than disconnects.
|
||||||
|
|
||||||
|
USB receive backpressure masks only `GINTMSK.RXFLVLM` while application packet
|
||||||
|
bytes remain unconsumed. The final byte cursor and gate update share the USB
|
||||||
|
resource lock. The OTG FS NVIC vector stays enabled, as do IN/TX completion,
|
||||||
|
reset and suspend interrupt sources. EP0 shares the RX FIFO and cannot bypass
|
||||||
|
a blocked bulk packet at its head. Once buffered bytes are consumed, RXFLVLM is
|
||||||
|
restored. An incomplete MAVLink frame alone does not keep RX masked.
|
||||||
|
|
||||||
|
UART role and baud changes take effect after reboot. See
|
||||||
|
[Configuration](configuration.md) for allowed values and USB repair, and
|
||||||
|
[Architecture](architecture.md#io-ownership) for RX buffering and ownership.
|
||||||
|
Normal USB configuration recovery does not guarantee availability after an
|
||||||
|
arbitrary clock, USB hardware or initialization failure.
|
||||||
|
|
||||||
|
The web configurator groups CRC-valid packets by source and message ID and
|
||||||
|
measures receive frequency in one-second windows. Selecting a message shows
|
||||||
|
its latest decoded fields. This view has bounded storage and keeps no history.
|
||||||
|
|
||||||
|
The separate Runtime logs view keeps a bounded history of `LOG` and `ERROR`
|
||||||
|
events in receive order, with local receive time, level and text.
|
||||||
|
|
||||||
The custom Kaptan definitions are aggregated by `definitions/kaptan.xml` from
|
The custom Kaptan definitions are aggregated by `definitions/kaptan.xml` from
|
||||||
`kaptan/config.xml`, `kaptan/log.xml`, `kaptan/error.xml`, and
|
one-message leaves grouped by interaction contract:
|
||||||
`kaptan/shutdown.xml`.
|
|
||||||
`KAPTAN_LOG` is an outbound human-readable four-field record. `KAPTAN_ERROR`
|
| Directory | Contract |
|
||||||
is its separate bounded error channel, while `KAPTAN_SHUTDOWN` represents only
|
| --- | --- |
|
||||||
a completed orderly output-inhibited shutdown. Configuration messages are GCS
|
| `publish/` | Zero-reply records for any interested receiver. |
|
||||||
groundwork only: the current F722 receiver rejects every inbound frame before
|
| `transfer/` | Exact-endpoint, bounded multi-message sessions. |
|
||||||
configuration dispatch. See [Configuration](configuration.md), [Logs](logs.md),
|
|
||||||
and [Errors](errors.md) for the exact contracts.
|
The root includes each message leaf directly and generates one flat
|
||||||
|
`generated::kaptan` module. These names must not be merged with another dialect.
|
||||||
|
See [Configuration](configuration.md), [Logs](logs.md) and [Errors](errors.md)
|
||||||
|
for the file-transfer and diagnostic contracts.
|
||||||
|
|
||||||
|
Scheduled publications:
|
||||||
|
|
||||||
|
| Publication | Current payload |
|
||||||
|
| --- | --- |
|
||||||
|
| `HEARTBEAT` (51027) | The single current initialized/output-inhibited state. |
|
||||||
|
| `IMU` (51028) | `u64` monotonic microseconds, `i16[3]` body acceleration in mg, `i32[3]` angular rate in mrad/s, and `i16` temperature in cdegC. |
|
||||||
|
| `RANGE` (51029) | Monotonic microseconds and downward distance in mm. |
|
||||||
|
| `OPTICAL_FLOW` (51030) | Monotonic microseconds, integer ground-relative velocity in mm/s, distance in mm, and quality. |
|
||||||
|
|
||||||
|
The IMU payload is 28 bytes with MAVLink extra CRC **22**. Wire field ordering
|
||||||
|
places time at byte 0, angular rate at byte 8, acceleration at byte 20 and
|
||||||
|
temperature at byte 26. The signed 32-bit gyro units preserve the full decoded
|
||||||
|
MPU6000 ±2000°/s range through mounting, mailbox handoff and encoding.
|
||||||
|
|
||||||
|
IMU publications retain the newest sample from each validated FIFO drain.
|
||||||
|
Their timestamp is FC time at packet-read submission, not physical sampling time.
|
||||||
+16
-23
@@ -1,31 +1,24 @@
|
|||||||
# Reference
|
# Reference
|
||||||
|
|
||||||
This page collects stable limits and points to the detailed contracts.
|
|
||||||
|
|
||||||
| Surface | Current contract |
|
| Surface | Current contract |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Flight profile | One 4 KiB-or-smaller raw CBOR blob, `profile_format = 1`, schema ID `KAPTANPROFILE-01`. |
|
| F722 persistence | One bounded native-width scalar snapshot inside each slot of a power-loss-safe two-slot flash journal. |
|
||||||
| SITL file | Private `[generation, raw-profile-blob]` envelope; not a MAVLink upload blob. |
|
| Public config artifact | Canonical KCFG v3 with generated metadata, finite/nonzero flags, factory values, selected-view values and exact expected-base values. |
|
||||||
| Config transaction | One owner-bound RAM transaction; typed writes or 192-byte idempotent CBOR chunks; commit is restart-required. |
|
| Flight profile | Generated schema and required compiled installation for the portable runtime. See [Configuration](configuration.md#flight-configuration). |
|
||||||
| Config discovery | Correlated `KAPTAN_CONFIG_INFO_REQUEST` / `KAPTAN_CONFIG_INFO`, then descriptor/value requests. |
|
| Navigation service | Fusion, prediction and initialization completion run on due 100-Hz control service. Early ticks supervise cached evidence; admission alone cannot renew capabilities. |
|
||||||
| Diagnostics | `KAPTAN_LOG` (51020) for ordinary severity/text; `KAPTAN_ERROR` (51021) for a bounded `{ path[96], additional_information[64] }` error; and `KAPTAN_SHUTDOWN` (51022) for completed orderly output-inhibited shutdowns. |
|
| Navigation model bound | Navigation-owned 25-m/s² free-flight acceleration envelope, checked against the complete profile's thrust and mass. Contact impulses are outside this bound. |
|
||||||
| F722 configuration | Immutable compiled board policy only; inbound MAVLink configuration remains disabled. |
|
| File transfer | Custom `FILE_CONTROL` (51025) and `FILE_DATA` (51026), one owner-bound stop-and-wait transfer, 246-byte data chunks, CRC-32C. |
|
||||||
|
| Diagnostics | `LOG` (51020) for macro-produced level/text; `ERROR` (51021) for a bounded reportable `{ error_id, raised_at[96] }` firmware error. |
|
||||||
|
| Publications | Kaptan-native `HEARTBEAT` (51027), `IMU` (51028), `RANGE` (51029), and `OPTICAL_FLOW` (51030). IMU has a 28-byte payload, extra CRC 22 and `i32` mrad/s gyro axes. |
|
||||||
|
| F722 clock | Shared TIM2 1-MHz epoch, CC1 100-Hz service. DWT is reserved for the SRAM flash-poll timeout. |
|
||||||
|
| F722 configuration | `/config/user.kcfg`; target-described fields and board factory values over local USB CDC-ACM or a directly wired unsigned UART with motor outputs physically inhibited. |
|
||||||
|
|
||||||
Read [Configuration](configuration.md) for the field/consumer matrix and GCS
|
Read [Configuration](configuration.md) for configuration ownership, artifact,
|
||||||
upload sequence, [Logs](logs.md) for receiver decoding and filtering, and
|
and file contracts, [Architecture](architecture.md#monotonic-time) for clock
|
||||||
[Errors](errors.md) for error propagation and panic scope.
|
assumptions, [Logs](logs.md) for receiver decoding and filtering, and
|
||||||
|
[Errors](errors.md) for reportable errors and panic scope.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Before review, run:
|
Use [Development and verification](development.md) for the required gates,
|
||||||
|
tool setup and evidence limits.
|
||||||
```sh
|
|
||||||
cd firmware
|
|
||||||
cargo fmt --all --check
|
|
||||||
cargo test --workspace --locked --offline
|
|
||||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
|
||||||
```
|
|
||||||
|
|
||||||
The embedded no-std binaries require their target-specific build setup; the
|
|
||||||
host workspace test suite continues to assert that the telemetry F722 image
|
|
||||||
has no inbound authority or actuator path.
|
|
||||||
+77
-14
@@ -1,25 +1,88 @@
|
|||||||
# Safety boundary
|
# Safety boundary
|
||||||
|
|
||||||
Every checked-in F722 image inhibits PB0, PB1, PB4, and PB5 before normal
|
F722 startup drives PB0, PB1, PB4 and PB5 low, plus PB6 and PB7 on Kakute.
|
||||||
board initialization. The interlock configures each pin as a low, push-pull
|
It configures them as push-pull GPIO outputs before clock-tree setup, board
|
||||||
GPIO output; it runs before clocks, board routing, UART setup, or RTIC tasks.
|
routing, UART setup or RTIC tasks.
|
||||||
|
|
||||||
There is no checked-in platform actuator route:
|
This is a firmware startup property, not a hardware latch. Reset temporarily
|
||||||
|
returns GPIO configuration to the MCU reset state, and the code cannot prove
|
||||||
|
external routing, pull resistors, ESC polarity or ESC power behavior. Board
|
||||||
|
support must document those electrical assumptions. Continuous inhibition
|
||||||
|
through reset requires board hardware that provides it.
|
||||||
|
|
||||||
- no DShot or PWM peripheral implementation is linked;
|
The board images have no DShot, PWM or other actuator driver. The portable
|
||||||
- no TIM3/TIM5 motor task or DMA output path exists;
|
flight runtime is not yet connected to physical motors. File requests grant
|
||||||
- MAVLink input has no production authority path;
|
no actuator access.
|
||||||
- a future verified arm command is still acknowledged as unsupported.
|
|
||||||
|
|
||||||
The protocol reference crate remains in the workspace only as a framing and
|
|
||||||
arming-sequence reference. It does not grant hardware access or 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
|
cd firmware
|
||||||
cargo test -p platform-stm32f7 --test platform_safety_boundary --offline
|
cargo test -p platform-stm32f7 \
|
||||||
|
--test platform_safety_boundary --locked --offline
|
||||||
```
|
```
|
||||||
|
|
||||||
This boundary is deliberately stronger than an application-level disarm flag:
|
## Shared runtime commit contract
|
||||||
it removes the physical actuator implementation from the platform image.
|
|
||||||
|
`FlightEndpoint` uses `SafetySupervisor::evaluate_and_commit` to
|
||||||
|
evaluate current evidence and invoke an enabled-output callback at most once
|
||||||
|
while exclusively borrowing that supervisor. The callback synchronously retains
|
||||||
|
a core-owned `OutputRecord` in caller-owned storage. This establishes local
|
||||||
|
publication only. The receiving consumer must check the record's binding,
|
||||||
|
sequence and absolute validity at application time. A queued record or successful
|
||||||
|
USB write is not a reusable permission or proof of remote application.
|
||||||
|
|
||||||
|
Runtime ingress carries a producer session and runtime generation, with separate
|
||||||
|
command-owner, arm, offboard and origin bindings. Commands and setpoints have
|
||||||
|
independent strictly increasing sequences. Retransmission retains its original
|
||||||
|
identity and bytes. Replays and wrong bindings cannot renew authority, and checked
|
||||||
|
authority sequences cannot wrap. A local `begin_epoch` is allowed only while
|
||||||
|
disarmed after successful inhibited publication. It resets navigation and request
|
||||||
|
watermarks while preserving the producer's output sequence and clock history.
|
||||||
|
These typed runtime identities do not authenticate a peer.
|
||||||
|
|
||||||
|
Observation admission receives independently captured processing time in that
|
||||||
|
epoch. The measurement-time upper bound must not follow receipt time, receipt
|
||||||
|
must not follow processing time, and conservative measurement age must satisfy
|
||||||
|
the stream's freshness bound.
|
||||||
|
Receipt alone does not establish accepted or current evidence. Explicit clock
|
||||||
|
ticks advance navigation and supervision even when input stops. The current
|
||||||
|
control period is 10 ms on an absolute due grid with a 20-ms control deadline.
|
||||||
|
`supervise` checks time, cached health and deadlines without integrating control
|
||||||
|
or renewing enabled output. A deadline miss is a fault, not permission to replay
|
||||||
|
an old allocation indefinitely.
|
||||||
|
|
||||||
|
Returned enabled-output errors latch a fault and trigger an inhibition attempt.
|
||||||
|
The sink must remain callable for that attempt. At terminal landing, the endpoint
|
||||||
|
clears arm authority and attempts inhibition before publishing terminal command
|
||||||
|
outcomes. Successful stop completion requires accepted inhibition; a rejected
|
||||||
|
inhibition leaves the command failed. Callers must reconcile committed outcomes after
|
||||||
|
both successful and failed core calls. This ordering does not prove physical
|
||||||
|
touchdown or motor stop.
|
||||||
|
|
||||||
|
Output callbacks must not panic. Cleanup guarantees apply to
|
||||||
|
returned errors, not unwinding. A host that catches an unwind must discard the
|
||||||
|
affected endpoint and output owner rather than resume them or use an epoch reset
|
||||||
|
as recovery. Platform panic containment owns the physical
|
||||||
|
response. Abort or halt alone does not establish zero output.
|
||||||
|
|
||||||
|
## Core failure policy
|
||||||
|
|
||||||
|
`TakeoffHoverLand` starts only from `ArmedIdle` on a fresh command. Settled
|
||||||
|
takeoff starts a 30-second hold, followed by landing under the existing deadline
|
||||||
|
and landing reserve. It completes after accepted output inhibition. A superseding
|
||||||
|
command cancels the mission. The board integration prerequisites above still apply.
|
||||||
|
|
||||||
|
With required navigation and output health present, offboard stream expiry
|
||||||
|
after 300 ms latches fallback, holds for the configured duration (at most one
|
||||||
|
second) from expiry, then lands.
|
||||||
|
Late packets do not resume the expired stream. Loss of required IMU/range or
|
||||||
|
other navigation capability, numerical failure, control-deadline failure or
|
||||||
|
output-publication failure can terminate and inhibit instead. Hold-then-land is
|
||||||
|
not promised without its required evidence.
|
||||||
|
|
||||||
|
The core's configured output lifetime is at most 50 ms, further shortened by
|
||||||
|
capability evidence and the current 20-ms control deadline. A future physical
|
||||||
|
composition must enforce output validity at its actuator boundary and establish
|
||||||
|
its own hardware timing and failure behavior. The 1.2-kg/18-N test fixture is
|
||||||
|
uncalibrated. Component verification does not qualify a vehicle.
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Host SITL
|
|
||||||
|
|
||||||
Host SITL is Kaptan's deterministic closed-loop simulation tier. Gazebo sends
|
|
||||||
simulated IMU and declared noisy local position/velocity observations over a
|
|
||||||
private loopback protocol. The Rust process runs the ESKF, safety supervisor,
|
|
||||||
trajectory, controller, and allocator, then returns four virtual motor
|
|
||||||
fractions to Gazebo.
|
|
||||||
|
|
||||||
It is a pre-bench software gate, not a physical-flight validation.
|
|
||||||
|
|
||||||
## Run the closed loop
|
|
||||||
|
|
||||||
```sh
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
For the reproducible command-path gate, use:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd firmware
|
|
||||||
tools/sitl/e2e-smoke.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The smoke scenario verifies ascent, translation, descent, landing, flat-plane
|
|
||||||
proximity, and zero applied motor fractions after landing. Its reference world
|
|
||||||
contains only a visual landing pad; the pad has no collision geometry and
|
|
||||||
never contributes a controller or truth observation.
|
|
||||||
|
|
||||||
Open <https://app.gazebosim.org/visualization>, connect to
|
|
||||||
`ws://localhost:9002`, and leave authorization blank to view the world.
|
|
||||||
|
|
||||||
## Quad-X profile
|
|
||||||
|
|
||||||
SITL starts through the same central `ConfigManager` composition used by the
|
|
||||||
host protocol tests. Create and validate a private host profile file with:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
kaptan-sitl config write-default kaptan-sitl.cbor
|
|
||||||
kaptan-sitl config validate kaptan-sitl.cbor
|
|
||||||
kaptan-sitl serve --config kaptan-sitl.cbor
|
|
||||||
```
|
|
||||||
|
|
||||||
The file selects the restart-only profile used to build the estimator,
|
|
||||||
controller, allocator envelope, safety supervisor, and optional log filter at
|
|
||||||
process start. It is not the raw GCS upload blob. `kaptan-sitl serve` retains
|
|
||||||
the central manager for that boot but does not yet open a MAVLink config/log
|
|
||||||
endpoint; see [Configuration](configuration.md) for the exact upload groundwork
|
|
||||||
and safety boundary.
|
|
||||||
|
|
||||||
## Limits
|
|
||||||
|
|
||||||
This model does not validate terrain, vision, flow/range sensing, wind,
|
|
||||||
vibration, battery/ESC behavior, sensor latency/jitter, actuator faults, or
|
|
||||||
physical touchdown sensing. Add those only as a reviewed sensor/contact model,
|
|
||||||
not as a display-only shortcut.
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
target
|
target
|
||||||
.git
|
.git
|
||||||
tools/hitl
|
|
||||||
Generated
+182
-144
@@ -4,9 +4,9 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
@@ -52,6 +52,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bare-metal",
|
"bare-metal",
|
||||||
"bitfield",
|
"bitfield",
|
||||||
|
"critical-section",
|
||||||
"embedded-hal",
|
"embedded-hal",
|
||||||
"volatile-register",
|
"volatile-register",
|
||||||
]
|
]
|
||||||
@@ -98,6 +99,12 @@ dependencies = [
|
|||||||
"void",
|
"void",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "embedded-io"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embedded-io"
|
name = "embedded-io"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -110,7 +117,7 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
|
checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"embedded-io",
|
"embedded-io 0.7.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -125,12 +132,31 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hash32"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heapless"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||||
|
dependencies = [
|
||||||
|
"hash32",
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@@ -151,18 +177,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
name = "kaptan-actuator"
|
name = "kaptan-actuator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-board"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-config"
|
name = "kaptan-config"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-config-derive",
|
"kaptan-config-derive",
|
||||||
|
"kaptan-err-reportable",
|
||||||
"kaptan-log",
|
"kaptan-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -178,19 +198,24 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-config-mavlink"
|
name = "kaptan-configurator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-config",
|
"kaptan-config",
|
||||||
|
"kaptan-file-mavlink",
|
||||||
"kaptan-log",
|
"kaptan-log",
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
|
"libc",
|
||||||
|
"mavlink-core",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-control"
|
name = "kaptan-control"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-log",
|
"kaptan-err-reportable",
|
||||||
"kaptan-math",
|
"kaptan-math",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -198,77 +223,44 @@ dependencies = [
|
|||||||
name = "kaptan-data-channel"
|
name = "kaptan-data-channel"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-board",
|
"critical-section",
|
||||||
"kaptan-dma",
|
"kaptan-err-reportable",
|
||||||
"kaptan-log",
|
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
|
"kaptan-tx",
|
||||||
|
"mavlink-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-dma"
|
name = "kaptan-diagnostics-mavlink"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-err-reportable",
|
||||||
|
"kaptan-log",
|
||||||
|
"kaptan-mavlink",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-drivers"
|
name = "kaptan-drivers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kaptan-err-reportable",
|
||||||
"kaptan-i2c",
|
"kaptan-i2c",
|
||||||
"kaptan-log",
|
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-spi",
|
"kaptan-spi",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-flight-config"
|
name = "kaptan-err-reportable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-config",
|
"kaptan-err-reportable-macros",
|
||||||
"kaptan-log",
|
|
||||||
"minicbor",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-flight-runtime"
|
name = "kaptan-err-reportable-macros"
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-config",
|
|
||||||
"kaptan-flight-config",
|
|
||||||
"kaptan-log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-geprc-f722-v2"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-board",
|
|
||||||
"platform-stm32f7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-i2c"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-kakute-f7-mini-v3"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-board",
|
|
||||||
"platform-stm32f7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-log"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-log-derive",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-log-derive"
|
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -278,19 +270,70 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-log-mavlink"
|
name = "kaptan-file-mavlink"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-log",
|
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
"mavlink",
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-flight-config"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-config",
|
||||||
|
"kaptan-control",
|
||||||
|
"kaptan-math",
|
||||||
|
"kaptan-navigation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-flight-runtime"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-config",
|
||||||
|
"kaptan-control",
|
||||||
|
"kaptan-flight-config",
|
||||||
|
"kaptan-math",
|
||||||
|
"kaptan-navigation",
|
||||||
|
"kaptan-safety",
|
||||||
|
"kaptan-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-geprc-f722-v2"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"platform-stm32f7",
|
||||||
|
"rtic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-i2c"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-kakute-f7-mini-v3"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"platform-stm32f7",
|
||||||
|
"rtic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-log"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-config-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-math"
|
name = "kaptan-math"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-log",
|
"kaptan-err-reportable",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -298,12 +341,8 @@ name = "kaptan-mavlink"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"kaptan-dma",
|
|
||||||
"kaptan-log",
|
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
"kaptan-tx",
|
|
||||||
"mavlink",
|
|
||||||
"mavlink-bindgen",
|
"mavlink-bindgen",
|
||||||
"mavlink-core",
|
"mavlink-core",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
@@ -314,17 +353,23 @@ dependencies = [
|
|||||||
name = "kaptan-navigation"
|
name = "kaptan-navigation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-log",
|
|
||||||
"kaptan-math",
|
"kaptan-math",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaptan-ports"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-safety"
|
name = "kaptan-safety"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-log",
|
"kaptan-err-reportable",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -335,27 +380,6 @@ dependencies = [
|
|||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "kaptan-sitl"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"kaptan-config",
|
|
||||||
"kaptan-config-mavlink",
|
|
||||||
"kaptan-control",
|
|
||||||
"kaptan-flight-config",
|
|
||||||
"kaptan-flight-runtime",
|
|
||||||
"kaptan-log",
|
|
||||||
"kaptan-log-mavlink",
|
|
||||||
"kaptan-math",
|
|
||||||
"kaptan-mavlink",
|
|
||||||
"kaptan-navigation",
|
|
||||||
"kaptan-safety",
|
|
||||||
"kaptan-time",
|
|
||||||
"log",
|
|
||||||
"mavlink",
|
|
||||||
"minicbor",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-spi"
|
name = "kaptan-spi"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -369,23 +393,10 @@ name = "kaptan-tx"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "libc"
|
||||||
version = "0.4.33"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mavlink"
|
|
||||||
version = "0.18.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ef539c358c31f69d47816dac709eebbcb733166ae1bcdeeb4463383c042e3e7c"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"mavlink-bindgen",
|
|
||||||
"mavlink-core",
|
|
||||||
"num-derive",
|
|
||||||
"num-traits",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mavlink-bindgen"
|
name = "mavlink-bindgen"
|
||||||
@@ -409,7 +420,7 @@ checksum = "b31cc9f930c7edce0c1933659d405a330a28346cdf087bd283a56a6d72eb3d85"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crc-any",
|
"crc-any",
|
||||||
"embedded-io",
|
"embedded-io 0.7.1",
|
||||||
"embedded-io-async",
|
"embedded-io-async",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -419,26 +430,6 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "minicbor"
|
|
||||||
version = "0.25.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0"
|
|
||||||
dependencies = [
|
|
||||||
"minicbor-derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "minicbor-derive"
|
|
||||||
version = "0.15.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nb"
|
name = "nb"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -486,20 +477,27 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cortex-m",
|
"cortex-m",
|
||||||
"cortex-m-rt",
|
"cortex-m-rt",
|
||||||
"kaptan-board",
|
"critical-section",
|
||||||
|
"kaptan-config",
|
||||||
"kaptan-data-channel",
|
"kaptan-data-channel",
|
||||||
"kaptan-dma",
|
"kaptan-diagnostics-mavlink",
|
||||||
"kaptan-drivers",
|
"kaptan-drivers",
|
||||||
|
"kaptan-err-reportable",
|
||||||
|
"kaptan-file-mavlink",
|
||||||
"kaptan-i2c",
|
"kaptan-i2c",
|
||||||
"kaptan-log",
|
"kaptan-log",
|
||||||
"kaptan-log-mavlink",
|
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
|
"kaptan-ports",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-spi",
|
"kaptan-spi",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
|
"kaptan-tx",
|
||||||
"panic-halt",
|
"panic-halt",
|
||||||
"rtic",
|
"rtic",
|
||||||
"stm32f7",
|
"stm32f7",
|
||||||
|
"synopsys-usb-otg",
|
||||||
|
"usb-device",
|
||||||
|
"usbd-serial",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -510,9 +508,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.107"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -528,9 +526,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -549,9 +547,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.16"
|
version = "0.4.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -626,6 +624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -645,7 +644,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -670,6 +669,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stm32f7"
|
name = "stm32f7"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -694,15 +699,26 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.0"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synopsys-usb-otg"
|
||||||
|
version = "0.4.0"
|
||||||
|
dependencies = [
|
||||||
|
"cortex-m",
|
||||||
|
"critical-section",
|
||||||
|
"embedded-hal",
|
||||||
|
"usb-device",
|
||||||
|
"vcell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-triple"
|
name = "target-triple"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -720,22 +736,22 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.19"
|
version = "2.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -798,6 +814,28 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usb-device"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6"
|
||||||
|
dependencies = [
|
||||||
|
"heapless",
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "usbd-serial"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "065e4eaf93db81d5adac82d9cef8f8da314cb640fa7f89534b972383f1cf80fc"
|
||||||
|
dependencies = [
|
||||||
|
"embedded-hal",
|
||||||
|
"embedded-io 0.6.1",
|
||||||
|
"nb 1.1.0",
|
||||||
|
"usb-device",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcell"
|
name = "vcell"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
|
|||||||
+32
-27
@@ -1,33 +1,34 @@
|
|||||||
[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-board",
|
"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-config-mavlink",
|
"libs/kaptan-data-channel",
|
||||||
"libs/kaptan-data-channel",
|
"libs/kaptan-drivers",
|
||||||
"libs/kaptan-dma",
|
"libs/kaptan-err-reportable",
|
||||||
"libs/kaptan-drivers",
|
"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-log-derive",
|
"libs/kaptan-diagnostics-mavlink",
|
||||||
"libs/kaptan-log-mavlink",
|
"libs/kaptan-navigation",
|
||||||
"libs/kaptan-navigation",
|
"libs/kaptan-ports",
|
||||||
"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/configurator",
|
||||||
]
|
]
|
||||||
|
exclude = ["vendor/synopsys-usb-otg"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -38,6 +39,7 @@ rust-version = "1.90"
|
|||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_op_in_unsafe_fn = "deny"
|
unsafe_op_in_unsafe_fn = "deny"
|
||||||
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
|
||||||
|
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
undocumented_unsafe_blocks = "deny"
|
undocumented_unsafe_blocks = "deny"
|
||||||
@@ -48,3 +50,6 @@ codegen-units = 1
|
|||||||
lto = "fat"
|
lto = "fat"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
synopsys-usb-otg = { path = "vendor/synopsys-usb-otg" }
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
SHELL := /bin/sh
|
||||||
|
MAKEFLAGS += --no-print-directory
|
||||||
|
export CARGO_BUILD_JOBS ?= 2
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
|
||||||
|
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 HOST_FEATURES := --features kaptan-navigation/sensor-snapshots
|
||||||
|
|
||||||
|
.NOTPARALLEL:
|
||||||
|
.PHONY: help fmt clippy test native-test docs coverage target-checks verification verification-tools miri kani verification-inventory pre-commit check clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' 'Usage: make -C firmware <target>' '' \
|
||||||
|
'Workspace targets:' \
|
||||||
|
' fmt Check Rust formatting' \
|
||||||
|
' clippy Run host and embedded Clippy checks' \
|
||||||
|
' test Run Rust and firmware script tests' \
|
||||||
|
' native-test Check cross-crate error IDs at Rust/C++ release final link' \
|
||||||
|
' docs Build Rust API documentation with warnings denied' \
|
||||||
|
' coverage Required final strict-core line/branch coverage gate' \
|
||||||
|
' verification Run all gates and retain source-bound evidence' \
|
||||||
|
' verification-tools Install the pinned Miri and Kani tools' \
|
||||||
|
' miri Run the manifest-selected Miri checks' \
|
||||||
|
' kani Run the manifest-selected Kani proofs' \
|
||||||
|
' verification-inventory Show verification owners, claims and gaps' \
|
||||||
|
' target-checks Check sensor-free consumers and link cross-route fixtures' \
|
||||||
|
' pre-commit Run fmt, clippy, test, and docs' \
|
||||||
|
' check Alias for pre-commit' \
|
||||||
|
' clean Remove Cargo build artifacts' '' \
|
||||||
|
'For other Cargo options, run cargo directly from firmware/.' \
|
||||||
|
'Host gates exclude bare-metal boards; navigation/sensor-snapshots is enabled.'
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
cargo fmt --all --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
cargo clippy --workspace --all-targets $(HOST_EXCLUDES) \
|
||||||
|
$(HOST_FEATURES) --locked --offline -- -D warnings
|
||||||
|
cargo clippy -p platform-stm32f7 --all-targets \
|
||||||
|
--locked --offline -- -D warnings
|
||||||
|
cargo clippy $(BOARD_PACKAGE_ARGS) $(addprefix --bin ,$(BOARD_PACKAGES)) --target thumbv7em-none-eabihf \
|
||||||
|
--locked --offline -- -D warnings
|
||||||
|
|
||||||
|
test:
|
||||||
|
cargo +1.90.0 test --workspace $(HOST_EXCLUDES) $(HOST_FEATURES) --locked --offline
|
||||||
|
cargo +1.90.0 test -p platform-stm32f7 -p synopsys-usb-otg --lib --locked --offline
|
||||||
|
node --test tools/configurator/web/view.test.cjs
|
||||||
|
python3 -m unittest scripts/test-analyze-fw.py \
|
||||||
|
scripts/test-summarize-test-coverage.py scripts/test-build-workflow.py \
|
||||||
|
scripts/test-verify.py
|
||||||
|
+@$(MAKE) native-test
|
||||||
|
|
||||||
|
native-test:
|
||||||
|
python3 libs/kaptan-err-reportable-macros/tests/final_link.py \
|
||||||
|
--work-dir "$(or $(CARGO_TARGET_DIR),target)/native-tests/final-link"
|
||||||
|
|
||||||
|
docs:
|
||||||
|
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps \
|
||||||
|
$(HOST_EXCLUDES) $(HOST_FEATURES) --locked --offline
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
scripts/report-test-coverage.sh
|
||||||
|
|
||||||
|
verification:
|
||||||
|
python3 scripts/verify.py all
|
||||||
|
|
||||||
|
verification-tools:
|
||||||
|
python3 scripts/verify.py setup
|
||||||
|
|
||||||
|
miri kani:
|
||||||
|
python3 scripts/verify.py $@
|
||||||
|
|
||||||
|
verification-inventory:
|
||||||
|
python3 scripts/verify.py inventory
|
||||||
|
|
||||||
|
target-checks:
|
||||||
|
cargo check -p kaptan-configurator -p kaptan-diagnostics-mavlink --locked --offline
|
||||||
|
cargo check -p kaptan-mavlink -p kaptan-log --no-default-features --locked --offline
|
||||||
|
cargo check -p kaptan-flight-runtime --no-default-features \
|
||||||
|
--target thumbv7em-none-eabihf --locked --offline
|
||||||
|
RUSTFLAGS='-C link-arg=-Tlink.x -L platforms/stm32f7/link/f722' \
|
||||||
|
cargo build -p platform-stm32f7 --example mpu_dual_uart --example icm_dual_uart \
|
||||||
|
--release --target thumbv7em-none-eabihf --locked --offline
|
||||||
|
|
||||||
|
pre-commit: fmt clippy test docs
|
||||||
|
@printf '%s\n' 'Pre-commit passed. Coverage was not run; make coverage is a required final gate.'
|
||||||
|
|
||||||
|
check: pre-commit
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
+13
-14
@@ -1,26 +1,25 @@
|
|||||||
# Firmware
|
# Firmware
|
||||||
|
|
||||||
This directory is the Kaptan flight-controller firmware workspace. It owns the
|
Kaptan flight-controller firmware workspace.
|
||||||
Rust workspace manifest, board declarations, platform implementation, reusable
|
|
||||||
firmware crates, simulator, firmware scripts, and firmware test fixtures.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
firmware/
|
firmware/
|
||||||
├── boards/ immutable board declarations and linker layouts
|
├── boards/ board hardware declarations and entry points
|
||||||
├── libs/ reusable no_std firmware crates
|
├── libs/ reusable no_std firmware crates
|
||||||
├── platforms/ hardware platform composition
|
├── platforms/ hardware composition, configuration and linker layouts
|
||||||
├── tools/ SITL and firmware development tooling
|
├── tools/ local configuration and telemetry tools
|
||||||
├── scripts/ packaging, analysis, and coverage helpers
|
└── scripts/ packaging, analysis, and coverage helpers
|
||||||
└── tests/ firmware integration-test fixtures
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run Cargo commands from this directory:
|
List commands from this directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd firmware
|
make help
|
||||||
cargo test --workspace --locked --offline
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The sibling `ground-suite/` directory is the desktop ground-control
|
Run Cargo directly from this directory. Build and package every supported board
|
||||||
application. It is intentionally a separate project and does not participate
|
with `make -C boards build`, or one with `make -C boards/<board> build`.
|
||||||
in the firmware Cargo workspace.
|
The workspace pins Rust 1.90.0. See
|
||||||
|
[development checks](../docs/src/development.md) for the supported workflows.
|
||||||
|
The board images do not yet run the flight runtime or implement motor drivers.
|
||||||
|
Motor pins are inhibited at startup. Physical flight has not been qualified.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
SHELL := /bin/sh
|
||||||
|
MAKEFLAGS += --no-print-directory
|
||||||
|
export CARGO_BUILD_JOBS ?= 2
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
|
||||||
|
override EMBEDDED_TARGET := thumbv7em-none-eabihf
|
||||||
|
CARGO_TARGET_DIR ?= ../target
|
||||||
|
override TARGET_DIR := $(CARGO_TARGET_DIR)/$(EMBEDDED_TARGET)/release
|
||||||
|
|
||||||
|
override BOARDS := geprc-f722-v2 kakute-f7-mini-v3
|
||||||
|
override BOARD_PACKAGES := $(addprefix kaptan-,$(BOARDS))
|
||||||
|
override BOARD_PACKAGE_ARGS := $(addprefix -p ,$(BOARD_PACKAGES))
|
||||||
|
override BOARD_BIN_ARGS := $(addprefix --bin ,$(BOARD_PACKAGES))
|
||||||
|
|
||||||
|
.NOTPARALLEL:
|
||||||
|
.PHONY: help build
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' 'Usage: make -C firmware/boards build' '' \
|
||||||
|
' build Build and package every board in release mode' \
|
||||||
|
'CARGO_TARGET_DIR defaults to firmware/target; relative overrides are relative to firmware/boards/.' \
|
||||||
|
'For one board: make -C firmware/boards/<board> build|flash'
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build --release --locked --offline $(BOARD_PACKAGE_ARGS) $(BOARD_BIN_ARGS) \
|
||||||
|
--target $(EMBEDDED_TARGET) --target-dir "$(CARGO_TARGET_DIR)"
|
||||||
|
@set -e; for board in $(BOARD_PACKAGES); do \
|
||||||
|
../scripts/create-dfu-image.sh "$(TARGET_DIR)/$$board"; \
|
||||||
|
done
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
SHELL := /bin/sh
|
||||||
|
MAKEFLAGS += --no-print-directory
|
||||||
|
export CARGO_BUILD_JOBS ?= 2
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
|
||||||
|
override EMBEDDED_TARGET := thumbv7em-none-eabihf
|
||||||
|
override PACKAGE := kaptan-$(BOARD)
|
||||||
|
CARGO_TARGET_DIR ?= ../../target
|
||||||
|
override ELF := $(CARGO_TARGET_DIR)/$(EMBEDDED_TARGET)/release/$(PACKAGE)
|
||||||
|
|
||||||
|
.NOTPARALLEL:
|
||||||
|
.PHONY: help build flash
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' 'Usage: make -C firmware/boards/$(BOARD) build|flash' '' \
|
||||||
|
' build Build and package $(PACKAGE) in release mode' \
|
||||||
|
' flash Build, package, and write $(PACKAGE) over DFU' '' \
|
||||||
|
'CARGO_TARGET_DIR defaults to firmware/target; relative overrides are relative to this board directory.' \
|
||||||
|
'For custom Cargo or DFU options, invoke cargo or dfu-util directly.'
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build --release --locked --offline -p $(PACKAGE) --bin $(PACKAGE) \
|
||||||
|
--target $(EMBEDDED_TARGET) --target-dir "$(CARGO_TARGET_DIR)"
|
||||||
|
../../scripts/create-dfu-image.sh "$(ELF)"
|
||||||
|
|
||||||
|
flash: build
|
||||||
|
dfu-util --device 0483:df11 --alt 0 --dfuse-address 0x08000000:leave \
|
||||||
|
--download "$(ELF).bin"
|
||||||
@@ -10,8 +10,11 @@ build = "build.rs"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-board = { path = "../../libs/kaptan-board" }
|
|
||||||
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
||||||
|
# RTIC's macro emits absolute `rtic` paths in the board crate.
|
||||||
|
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
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed=build.rs");
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
println!("cargo:rerun-if-changed=memory.x");
|
let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||||
println!("cargo:rustc-link-arg=-Tlink.x");
|
.join("../../platforms/stm32f7/link/f722");
|
||||||
println!(
|
println!(
|
||||||
"cargo:rustc-link-search={}",
|
"cargo:rerun-if-changed={}",
|
||||||
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
|
layout.join("memory.x").display()
|
||||||
);
|
);
|
||||||
|
println!("cargo:rustc-link-arg=-Tlink.x");
|
||||||
|
println!("cargo:rustc-link-search={}", layout.display());
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
MEMORY
|
|
||||||
{
|
|
||||||
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
|
|
||||||
/*
|
|
||||||
* STM32F722 RAM totals 256 KiB:
|
|
||||||
* - DTCM: 64 KiB at 0x2000_0000
|
|
||||||
* - SRAM1: 176 KiB at 0x2001_0000
|
|
||||||
* - SRAM2: 16 KiB at 0x2003_C000
|
|
||||||
*
|
|
||||||
* SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2.
|
|
||||||
* They form one 192 KiB default RAM region from 0x2001_0000 through
|
|
||||||
* 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit
|
|
||||||
* placement policy rather than mixing CPU-critical and general allocations.
|
|
||||||
*
|
|
||||||
* TODO: Utilize DTCM.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
RAM : ORIGIN = 0x20010000, LENGTH = 192K
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,35 @@
|
|||||||
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
|
//! Declarative support for the GEPRC GEP-F722-HD V2.
|
||||||
|
|
||||||
use kaptan_board::{
|
#![forbid(unsafe_code)]
|
||||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
|
||||||
Severity, TelemetryProfile, VerticalSensingKind,
|
use platform_stm32f7::kaptan_ports::{PortCatalog, PortDescriptor};
|
||||||
};
|
|
||||||
use platform_stm32f7::{
|
use platform_stm32f7::{
|
||||||
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
|
F7Board, F722Hardware, F722ImmutableConfig, F722UserConfig, Icm42688Spi3, ImuMount, MotorPins,
|
||||||
|
SerialInventory, StatusLed, UART1, UART2, UART3, UART4, UART6, VerticalSensor,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
|
|
||||||
pub struct GeprcF722V2;
|
pub struct GeprcF722V2;
|
||||||
|
|
||||||
|
impl PortCatalog<5> for GeprcF722V2 {
|
||||||
|
const DESCRIPTORS: [PortDescriptor; 5] = [UART1, UART2, UART3, UART4, UART6];
|
||||||
|
}
|
||||||
|
|
||||||
impl F7Board for GeprcF722V2 {
|
impl F7Board for GeprcF722V2 {
|
||||||
const CONFIG: BoardConfig = BoardConfig {
|
type UserConfig = F722UserConfig<Self, 5>;
|
||||||
|
type ImmutableConfig = F722ImmutableConfig<Icm42688Spi3>;
|
||||||
|
|
||||||
|
const FACTORY_USER_CONFIG: Self::UserConfig = Self::UserConfig::FACTORY;
|
||||||
|
const IMMUTABLE_CONFIG: Self::ImmutableConfig = F722ImmutableConfig {
|
||||||
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
|
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
|
||||||
mavlink: MavlinkEndpoint {
|
service_hz: 100,
|
||||||
system_id: 1,
|
hardware: F722Hardware::new(
|
||||||
component_id: 1,
|
Icm42688Spi3 {
|
||||||
port: MavlinkPort::Serial2,
|
mount: ImuMount::Yaw180,
|
||||||
baud: 115_200,
|
|
||||||
},
|
|
||||||
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
|
|
||||||
imu: ImuDevice::Icm42688,
|
|
||||||
imu_mount: ImuMount::Yaw180,
|
|
||||||
vertical_sensing: VerticalSensingKind::None,
|
|
||||||
data_channel: DataChannelRates {
|
|
||||||
service_hz: 100,
|
|
||||||
raw_imu_hz: 20,
|
|
||||||
range_hz: None,
|
|
||||||
optical_flow_hz: None,
|
|
||||||
heartbeat_hz: 2,
|
|
||||||
},
|
},
|
||||||
maximum_log_level: Severity::Info,
|
SerialInventory::new(&Self::DESCRIPTORS),
|
||||||
}),
|
StatusLed::ActiveHighPa13,
|
||||||
|
VerticalSensor::None,
|
||||||
|
MotorPins::Four,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
|
|
||||||
platform
|
|
||||||
.imu(Icm42688::on_spi3().with_drdy(Pc3))
|
|
||||||
.mavlink(Mavlink::on_usart2())
|
|
||||||
.vertical_sensor(VerticalSensor::none())
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
|
|
||||||
mod board;
|
mod board;
|
||||||
|
|
||||||
platform_stm32f7::flight_firmware!(board::GeprcF722V2);
|
platform_stm32f7::flight_firmware!(crate::board::GeprcF722V2);
|
||||||
@@ -8,12 +8,12 @@ version.workspace = true
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
lidar-lite-v3 = []
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-board = { path = "../../libs/kaptan-board" }
|
|
||||||
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
platform-stm32f7 = { path = "../../platforms/stm32f7" }
|
||||||
|
# RTIC's macro emits absolute `rtic` paths in the board crate.
|
||||||
|
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
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-changed=build.rs");
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
println!("cargo:rerun-if-changed=memory.x");
|
let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||||
println!("cargo:rustc-link-arg=-Tlink.x");
|
.join("../../platforms/stm32f7/link/f722");
|
||||||
println!(
|
println!(
|
||||||
"cargo:rustc-link-search={}",
|
"cargo:rerun-if-changed={}",
|
||||||
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
|
layout.join("memory.x").display()
|
||||||
);
|
);
|
||||||
|
println!("cargo:rustc-link-arg=-Tlink.x");
|
||||||
|
println!("cargo:rustc-link-search={}", layout.display());
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
MEMORY
|
|
||||||
{
|
|
||||||
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
|
|
||||||
/*
|
|
||||||
* STM32F722 RAM totals 256 KiB:
|
|
||||||
* - DTCM: 64 KiB at 0x2000_0000
|
|
||||||
* - SRAM1: 176 KiB at 0x2001_0000
|
|
||||||
* - SRAM2: 16 KiB at 0x2003_C000
|
|
||||||
*
|
|
||||||
* SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2.
|
|
||||||
* They form one 192 KiB default RAM region from 0x2001_0000 through
|
|
||||||
* 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit
|
|
||||||
* placement policy rather than mixing CPU-critical and general allocations.
|
|
||||||
*
|
|
||||||
* TODO: Utilize DTCM.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
RAM : ORIGIN = 0x20010000, LENGTH = 192K
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,42 @@
|
|||||||
//! Declarative Kakute F7 Mini V3 board description.
|
//! Declarative Kakute F7 Mini V3 board description.
|
||||||
//!
|
|
||||||
//! This module deliberately contains no MCU registers, RTIC concepts, unsafe
|
|
||||||
//! code, interrupt names, DMA handling, or task scheduling. Those concerns
|
|
||||||
//! belong to the STM32F7 platform crate.
|
|
||||||
|
|
||||||
use kaptan_board::{
|
#![forbid(unsafe_code)]
|
||||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
|
||||||
Severity, TelemetryProfile, VerticalSensingKind,
|
use platform_stm32f7::kaptan_ports::{PortCatalog, PortDescriptor};
|
||||||
};
|
|
||||||
use platform_stm32f7::{
|
use platform_stm32f7::{
|
||||||
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor,
|
AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware, F722ImmutableConfig,
|
||||||
|
F722UserConfig, GyroscopeRange, ImuMount, MotorPins, Mpu6000Config, Mpu6000Spi1,
|
||||||
|
SerialInventory, StatusLed, UART1, UART2, UART3, UART4, UART6, VerticalSensor,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Holybro Kakute F7 Mini V3.
|
|
||||||
pub struct KakuteF7MiniV3;
|
pub struct KakuteF7MiniV3;
|
||||||
|
|
||||||
const CONFIG: BoardConfig = BoardConfig {
|
impl PortCatalog<5> for KakuteF7MiniV3 {
|
||||||
name: "Kakute F7 Mini V3",
|
const DESCRIPTORS: [PortDescriptor; 5] = [UART1, UART2, UART3, UART4, UART6];
|
||||||
mavlink: MavlinkEndpoint {
|
}
|
||||||
system_id: 1,
|
|
||||||
component_id: 1,
|
|
||||||
port: MavlinkPort::Serial1,
|
|
||||||
baud: 115_200,
|
|
||||||
},
|
|
||||||
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
|
|
||||||
imu: ImuDevice::Mpu6000,
|
|
||||||
imu_mount: ImuMount::BodyAligned,
|
|
||||||
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
|
||||||
data_channel: DataChannelRates {
|
|
||||||
service_hz: 100,
|
|
||||||
raw_imu_hz: 20,
|
|
||||||
range_hz: Some(20),
|
|
||||||
optical_flow_hz: Some(20),
|
|
||||||
heartbeat_hz: 2,
|
|
||||||
},
|
|
||||||
maximum_log_level: Severity::Info,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
impl F7Board for KakuteF7MiniV3 {
|
impl F7Board for KakuteF7MiniV3 {
|
||||||
const CONFIG: BoardConfig = CONFIG;
|
type UserConfig = F722UserConfig<Self, 5>;
|
||||||
|
type ImmutableConfig = F722ImmutableConfig<Mpu6000Spi1>;
|
||||||
|
|
||||||
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
|
const FACTORY_USER_CONFIG: Self::UserConfig = Self::UserConfig::FACTORY;
|
||||||
platform
|
const IMMUTABLE_CONFIG: Self::ImmutableConfig = F722ImmutableConfig {
|
||||||
.imu(Mpu6000::on_spi1().with_drdy(Pa4))
|
name: "Kakute F7 Mini V3",
|
||||||
.mavlink(Mavlink::on_usart1())
|
service_hz: 100,
|
||||||
.vertical_sensor(VerticalSensor::mtf01_on_uart4())
|
hardware: F722Hardware::new(
|
||||||
.build()
|
Mpu6000Spi1 {
|
||||||
}
|
mount: ImuMount::Yaw270,
|
||||||
|
config: Mpu6000Config {
|
||||||
|
accelerometer: AccelerometerRange::PlusMinus4G,
|
||||||
|
gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond,
|
||||||
|
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
||||||
|
sample_rate_divider: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SerialInventory::new(&Self::DESCRIPTORS),
|
||||||
|
StatusLed::ActiveLowPd2,
|
||||||
|
VerticalSensor::None,
|
||||||
|
MotorPins::Six,
|
||||||
|
),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@
|
|||||||
#![no_main]
|
#![no_main]
|
||||||
|
|
||||||
mod board;
|
mod board;
|
||||||
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
|
platform_stm32f7::flight_firmware!(crate::board::KakuteF7MiniV3);
|
||||||
@@ -18,15 +18,12 @@ impl Config {
|
|||||||
rate: Rate,
|
rate: Rate,
|
||||||
armed_idle_throttle: Throttle,
|
armed_idle_throttle: Throttle,
|
||||||
arming_duration: ArmingDuration,
|
arming_duration: ArmingDuration,
|
||||||
) -> Option<Self> {
|
) -> Self {
|
||||||
if rate.frames_for_millis(arming_duration.milliseconds()) == 0 {
|
Self {
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Self {
|
|
||||||
rate,
|
rate,
|
||||||
armed_idle_throttle,
|
armed_idle_throttle,
|
||||||
arming_duration,
|
arming_duration,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn rate(self) -> Rate {
|
pub const fn rate(self) -> Rate {
|
||||||
@@ -62,17 +59,6 @@ impl Rate {
|
|||||||
pub const fn timer_base_hz(self) -> u32 {
|
pub const fn timer_base_hz(self) -> u32 {
|
||||||
self.hertz() * 20
|
self.hertz() * 20
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn output_rate_hz(self) -> u32 {
|
|
||||||
match self {
|
|
||||||
Self::Dshot150 => 4_000,
|
|
||||||
Self::Dshot300 | Self::Dshot600 => 8_000,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn frames_for_millis(self, milliseconds: u16) -> u16 {
|
|
||||||
((self.output_rate_hz() * milliseconds as u32) / 1_000) as u16
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -83,10 +69,12 @@ pub struct Timing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Timing {
|
impl Timing {
|
||||||
|
/// Rounds to the nearest symbol tick, with half ticks rounded up.
|
||||||
|
/// Rejects clocks whose symbol period cannot fit 3..=65,535 ticks.
|
||||||
pub const fn from_timer_clock(timer_hz: u32, rate: Rate) -> Option<Self> {
|
pub const fn from_timer_clock(timer_hz: u32, rate: Rate) -> Option<Self> {
|
||||||
let symbol_hz = rate.hertz();
|
let symbol_hz = rate.hertz() as u64;
|
||||||
let ticks = (timer_hz + symbol_hz / 2) / symbol_hz;
|
let ticks = (timer_hz as u64 + symbol_hz / 2) / symbol_hz;
|
||||||
if ticks < 3 || ticks > u16::MAX as u32 {
|
if ticks < 3 || ticks > u16::MAX as u64 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let bit_ticks = ticks as u16;
|
let bit_ticks = ticks as u16;
|
||||||
@@ -214,8 +202,7 @@ pub const fn symbols(packet: u16, zero_high: u16, one_high: u16) -> [u16; FRAME_
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ArmSequence, Config, FrameValue, Rate, Throttle, Timing, packet, symbols};
|
use super::{ArmSequence, FrameValue, Rate, Throttle, Timing, packet, symbols};
|
||||||
use crate::ArmingDuration;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn packet_matches_standard_dshot_vectors() {
|
fn packet_matches_standard_dshot_vectors() {
|
||||||
@@ -250,9 +237,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn requires_an_arming_interval_with_frames() {
|
fn timer_clock_boundaries_do_not_overflow_or_clamp() {
|
||||||
let duration = ArmingDuration::from_millis(2_000).unwrap();
|
for (rate, maximum_profile) in [
|
||||||
let throttle = Throttle::new(300).unwrap();
|
(Rate::Dshot150, (28_633, 10_022, 20_043)),
|
||||||
assert!(Config::new(Rate::Dshot600, throttle, duration).is_some());
|
(Rate::Dshot300, (14_317, 5_011, 10_022)),
|
||||||
|
(Rate::Dshot600, (7_158, 2_505, 5_011)),
|
||||||
|
] {
|
||||||
|
assert!(Timing::from_timer_clock(0, rate).is_none());
|
||||||
|
let first_supported = rate.hertz() * 5 / 2;
|
||||||
|
assert!(Timing::from_timer_clock(first_supported - 1, rate).is_none());
|
||||||
|
let minimum = Timing::from_timer_clock(first_supported, rate).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
minimum.bit_ticks(),
|
||||||
|
minimum.zero_high_ticks(),
|
||||||
|
minimum.one_high_ticks()
|
||||||
|
),
|
||||||
|
(3, 1, 2)
|
||||||
|
);
|
||||||
|
let maximum = Timing::from_timer_clock(u32::MAX, rate).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
maximum.bit_ticks(),
|
||||||
|
maximum.zero_high_ticks(),
|
||||||
|
maximum.one_high_ticks()
|
||||||
|
),
|
||||||
|
maximum_profile
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//! Integer wire and timing contracts.
|
||||||
|
|
||||||
|
use super::dshot::{FrameValue, Rate, Throttle, Timing, symbols};
|
||||||
|
|
||||||
|
fn any_rate() -> Rate {
|
||||||
|
match kani::any::<u8>() {
|
||||||
|
0 => Rate::Dshot150,
|
||||||
|
1 => Rate::Dshot300,
|
||||||
|
_ => Rate::Dshot600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[kani::proof]
|
||||||
|
fn dshot_frame_preserves_payload_and_checksum() {
|
||||||
|
let raw: u16 = kani::any();
|
||||||
|
let stop: bool = kani::any();
|
||||||
|
let frame = if stop {
|
||||||
|
FrameValue::Stop
|
||||||
|
} else {
|
||||||
|
let Some(throttle) = Throttle::new(raw) else {
|
||||||
|
assert!(!(48..=2_047).contains(&raw));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert!((48..=2_047).contains(&raw));
|
||||||
|
FrameValue::Throttle(throttle)
|
||||||
|
};
|
||||||
|
let packet = frame.packet();
|
||||||
|
assert_eq!(packet >> 5, if stop { 0 } else { raw });
|
||||||
|
assert_eq!(packet & 0x10, 0);
|
||||||
|
assert_eq!(
|
||||||
|
(packet ^ (packet >> 4) ^ (packet >> 8) ^ (packet >> 12)) & 0xf,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
kani::cover!(stop);
|
||||||
|
kani::cover!(!stop && raw == 48);
|
||||||
|
kani::cover!(!stop && raw == 2_047);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[kani::proof]
|
||||||
|
#[kani::unwind(17)]
|
||||||
|
fn dshot_symbols_round_trip_and_end_low() {
|
||||||
|
let packet: u16 = kani::any();
|
||||||
|
let zero: u16 = kani::any();
|
||||||
|
let one: u16 = kani::any();
|
||||||
|
let encoded = symbols(packet, zero, one);
|
||||||
|
let mut decoded = 0_u16;
|
||||||
|
for symbol in &encoded[..16] {
|
||||||
|
assert!(*symbol == zero || *symbol == one);
|
||||||
|
decoded = (decoded << 1) | u16::from(*symbol == one);
|
||||||
|
if zero == one {
|
||||||
|
assert_eq!(*symbol, zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zero != one {
|
||||||
|
assert_eq!(decoded, packet);
|
||||||
|
}
|
||||||
|
assert_eq!(encoded[16], 0);
|
||||||
|
assert_eq!(encoded[17], 0);
|
||||||
|
// Equal levels are legal arguments. The framing function does not claim
|
||||||
|
// that every caller-supplied pair is physically distinguishable.
|
||||||
|
kani::cover!(zero == one);
|
||||||
|
kani::cover!(zero != one && packet != 0 && packet != u16::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[kani::proof]
|
||||||
|
fn dshot_timer_rounding_keeps_compares_inside_period() {
|
||||||
|
let rate = any_rate();
|
||||||
|
let timer_hz: u32 = kani::any();
|
||||||
|
let timing = Timing::from_timer_clock(timer_hz, rate);
|
||||||
|
let requested = u64::from(timer_hz);
|
||||||
|
let symbol_hz = u64::from(rate.hertz());
|
||||||
|
if let Some(timing) = timing {
|
||||||
|
let represented = u64::from(timing.bit_ticks()) * symbol_hz;
|
||||||
|
// Nearest period, with half-symbol ties rounded upward.
|
||||||
|
assert!(2 * represented <= 2 * requested + symbol_hz);
|
||||||
|
assert!(2 * represented + symbol_hz > 2 * requested);
|
||||||
|
assert!(timing.zero_high_ticks() > 0);
|
||||||
|
assert!(timing.zero_high_ticks() < timing.one_high_ticks());
|
||||||
|
assert!(timing.one_high_ticks() < timing.bit_ticks());
|
||||||
|
} else {
|
||||||
|
// At all supported DShot rates u32::MAX still fits a u16 period.
|
||||||
|
// Therefore refusal is exactly below the minimum three-tick period.
|
||||||
|
assert!(2 * requested < 5 * symbol_hz);
|
||||||
|
}
|
||||||
|
kani::cover!(timing.is_none());
|
||||||
|
kani::cover!(timing.is_some() && timer_hz == u32::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[kani::proof]
|
||||||
|
fn arming_duration_product() {
|
||||||
|
let milliseconds: u16 = kani::any();
|
||||||
|
let cadence_hz: u32 = kani::any();
|
||||||
|
let Some(duration) = super::ArmingDuration::from_millis(milliseconds) else {
|
||||||
|
assert!(milliseconds == 0 || milliseconds > 8_000);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
assert_eq!(duration.milliseconds(), milliseconds);
|
||||||
|
assert!((1..=8_000).contains(&milliseconds));
|
||||||
|
let requested = u64::from(duration.milliseconds()) * u64::from(cadence_hz);
|
||||||
|
assert!(requested <= 8_000 * u64::from(u32::MAX));
|
||||||
|
assert_eq!(requested == 0, cadence_hz == 0);
|
||||||
|
kani::cover!(milliseconds == 1 && cadence_hz == 0);
|
||||||
|
kani::cover!(milliseconds == 8_000 && cadence_hz == u32::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[kani::proof]
|
||||||
|
fn arming_frame_count() {
|
||||||
|
let requested: u64 = kani::any();
|
||||||
|
let frames = super::frame_count(requested);
|
||||||
|
if let Some(frames) = frames {
|
||||||
|
let frames = u64::from(frames);
|
||||||
|
assert!(requested != 0 && frames != 0);
|
||||||
|
assert!(frames * 1_000 >= requested);
|
||||||
|
assert!((frames - 1) * 1_000 < requested);
|
||||||
|
} else {
|
||||||
|
assert!(requested == 0 || requested > u64::from(u16::MAX) * 1_000);
|
||||||
|
}
|
||||||
|
kani::cover!(requested == 0 && frames.is_none());
|
||||||
|
kani::cover!(frames.is_some() && requested % 1_000 != 0);
|
||||||
|
kani::cover!(requested == u64::MAX && frames.is_none());
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
//! Protocol-neutral physical-motor protocol reference.
|
//! DShot/PWM framing and local arming sequences.
|
||||||
//!
|
//!
|
||||||
//! This crate deliberately remains in the workspace for future, separately
|
//! This reference crate grants no peripheral access or flight authority and is
|
||||||
//! reviewed physical-flight work. No checked-in board platform links it, and
|
//! not linked by the current board images.
|
||||||
//! its types model DShot/PWM framing and
|
|
||||||
//! local arming sequences only; they grant neither peripheral access nor
|
|
||||||
//! flight authority.
|
|
||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
pub mod dshot;
|
pub mod dshot;
|
||||||
pub mod pwm;
|
pub mod pwm;
|
||||||
|
|
||||||
|
#[cfg(kani)]
|
||||||
|
mod kani_proofs;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub struct ArmingDuration(u16);
|
pub struct ArmingDuration(u16);
|
||||||
|
|
||||||
@@ -26,6 +26,18 @@ impl ArmingDuration {
|
|||||||
pub const fn milliseconds(self) -> u16 {
|
pub const fn milliseconds(self) -> u16 {
|
||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimum frame count at the caller's periodic dispatch cadence.
|
||||||
|
pub const fn frames_at(self, cadence_hz: u32) -> Option<u16> {
|
||||||
|
frame_count(self.0 as u64 * cadence_hz as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn frame_count(requested: u64) -> Option<u16> {
|
||||||
|
if requested == 0 || requested > u16::MAX as u64 * 1_000 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((requested as u32).div_ceil(1_000) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -33,3 +45,23 @@ pub enum MotorProtocol {
|
|||||||
Pwm(pwm::Config),
|
Pwm(pwm::Config),
|
||||||
Dshot(dshot::Config),
|
Dshot(dshot::Config),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ArmingDuration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frame_count_uses_dispatch_cadence() {
|
||||||
|
let duration = ArmingDuration::from_millis(8_000).unwrap();
|
||||||
|
assert_eq!(duration.frames_at(0), None);
|
||||||
|
assert_eq!(duration.frames_at(4_000), Some(32_000));
|
||||||
|
assert_eq!(duration.frames_at(8_000), Some(64_000));
|
||||||
|
assert_eq!(duration.frames_at(8_191), Some(65_528));
|
||||||
|
assert_eq!(duration.frames_at(8_192), None);
|
||||||
|
assert_eq!(duration.frames_at(u32::MAX), None);
|
||||||
|
assert_eq!(
|
||||||
|
ArmingDuration::from_millis(1).unwrap().frames_at(333),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,16 +66,22 @@ impl Rate {
|
|||||||
self.0
|
self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns an integral period representable by a 16-bit microsecond timer.
|
||||||
|
/// Rates with fractional periods or periods above 65,535 µs are unsupported.
|
||||||
pub const fn period_microseconds(self) -> Option<u16> {
|
pub const fn period_microseconds(self) -> Option<u16> {
|
||||||
if 1_000_000 % self.0 as u32 == 0 {
|
let period = 1_000_000 / self.0 as u32;
|
||||||
Some((1_000_000 / self.0 as u32) as u16)
|
if 1_000_000 % self.0 as u32 == 0 && period <= u16::MAX as u32 {
|
||||||
|
Some(period as u16)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rounds up so the frames last at least the requested duration.
|
||||||
pub const fn frames_for_millis(self, milliseconds: u16) -> u16 {
|
pub const fn frames_for_millis(self, milliseconds: u16) -> u16 {
|
||||||
((self.0 as u32 * milliseconds as u32) / 1_000) as u16
|
// Rate is at most 500 Hz. The product is at most 32,767,500 and
|
||||||
|
// the rounded-up result at most 32,768 for every u16 duration.
|
||||||
|
(self.0 as u32 * milliseconds as u32).div_ceil(1_000) as u16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,4 +190,59 @@ mod tests {
|
|||||||
assert!(Config::new(Rate::new(333).unwrap(), minimum, idle, duration).is_none());
|
assert!(Config::new(Rate::new(333).unwrap(), minimum, idle, duration).is_none());
|
||||||
assert!(Config::new(rate, idle, minimum, duration).is_none());
|
assert!(Config::new(rate, idle, minimum, duration).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timer_period_support_is_exact_over_the_entire_rate_domain() {
|
||||||
|
let duration = ArmingDuration::from_millis(1).unwrap();
|
||||||
|
let minimum = PulseWidth::new(1_000).unwrap();
|
||||||
|
let idle = PulseWidth::new(1_100).unwrap();
|
||||||
|
for hertz in 0..=u16::MAX {
|
||||||
|
let Some(rate) = Rate::new(hertz) else {
|
||||||
|
assert!(hertz == 0 || hertz > 500);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Divisors of 10^6 in 1..=500 whose quotient fits u16.
|
||||||
|
let supported = matches!(
|
||||||
|
hertz,
|
||||||
|
16 | 20
|
||||||
|
| 25
|
||||||
|
| 32
|
||||||
|
| 40
|
||||||
|
| 50
|
||||||
|
| 64
|
||||||
|
| 80
|
||||||
|
| 100
|
||||||
|
| 125
|
||||||
|
| 160
|
||||||
|
| 200
|
||||||
|
| 250
|
||||||
|
| 320
|
||||||
|
| 400
|
||||||
|
| 500
|
||||||
|
);
|
||||||
|
assert_eq!(rate.period_microseconds().is_some(), supported);
|
||||||
|
assert_eq!(
|
||||||
|
Config::new(rate, minimum, idle, duration).is_some(),
|
||||||
|
supported
|
||||||
|
);
|
||||||
|
if let Some(period) = rate.period_microseconds() {
|
||||||
|
assert_eq!(u64::from(period) * u64::from(hertz), 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_rate_and_duration_uses_the_least_sufficient_frame_count() {
|
||||||
|
// All accepted rates and every public u16 duration, including values
|
||||||
|
// beyond ArmingDuration's 8-second configuration limit.
|
||||||
|
for hertz in 1..=500 {
|
||||||
|
let rate = Rate::new(hertz).unwrap();
|
||||||
|
for milliseconds in 0..=u16::MAX {
|
||||||
|
let frames = u64::from(rate.frames_for_millis(milliseconds));
|
||||||
|
let requested = u64::from(hertz) * u64::from(milliseconds);
|
||||||
|
assert!(frames * 1_000 >= requested);
|
||||||
|
assert!(frames == 0 || (frames - 1) * 1_000 < requested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
//! Portable board-support contract for Kaptan.
|
|
||||||
//!
|
|
||||||
//! This crate describes logical board identity, sensing capability and
|
|
||||||
//! outbound data-channel policy. It intentionally has no dependency on an MCU
|
|
||||||
//! family, peripheral access crate, RTIC, or a runtime framework.
|
|
||||||
|
|
||||||
#![no_std]
|
|
||||||
|
|
||||||
use kaptan_log::KaptanError;
|
|
||||||
pub use kaptan_log::Severity;
|
|
||||||
|
|
||||||
/// Physical IMU device selected by a board declaration.
|
|
||||||
///
|
|
||||||
/// This is immutable board topology, not a runtime-selectable sensor driver.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum ImuDevice {
|
|
||||||
/// InvenSense MPU-6000 on the board's selected SPI route.
|
|
||||||
Mpu6000,
|
|
||||||
/// TDK ICM-42688-P on the board's selected SPI route.
|
|
||||||
Icm42688,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed board-to-body IMU mounting transform.
|
|
||||||
///
|
|
||||||
/// Calibration remains a separate, safety-gated workflow. This declaration
|
|
||||||
/// only selects the compiled transform for the physical board route.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum ImuMount {
|
|
||||||
/// Sensor axes already match the controller body axes.
|
|
||||||
BodyAligned,
|
|
||||||
/// Sensor is yawed 180° relative to the controller body axes.
|
|
||||||
Yaw180,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Logical outbound MAVLink serial endpoint selected by a board declaration.
|
|
||||||
///
|
|
||||||
/// The platform maps these logical names to its checked pin/DMA routes.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum MavlinkPort {
|
|
||||||
/// First supported board serial endpoint.
|
|
||||||
Serial1,
|
|
||||||
/// Second supported board serial endpoint.
|
|
||||||
Serial2,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The vertical-sensing data a board support package makes available.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
#[repr(u8)]
|
|
||||||
pub enum VerticalSensingKind {
|
|
||||||
/// No range or optical-flow data is supported.
|
|
||||||
None,
|
|
||||||
/// Range data is supported.
|
|
||||||
Range,
|
|
||||||
/// Coherent range and optical-flow data are supported.
|
|
||||||
RangeAndOpticalFlow,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MAVLink endpoint identity selected by one board support package.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct MavlinkEndpoint {
|
|
||||||
/// MAVLink system identifier.
|
|
||||||
pub system_id: u8,
|
|
||||||
/// MAVLink component identifier.
|
|
||||||
pub component_id: u8,
|
|
||||||
/// Immutable physical serial endpoint selected for this board image.
|
|
||||||
pub port: MavlinkPort,
|
|
||||||
/// UART line rate used by that physical endpoint.
|
|
||||||
pub baud: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed outbound data-channel rates driven by a platform service timer.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct DataChannelRates {
|
|
||||||
/// Frequency of the timer that calls the data-channel scheduler.
|
|
||||||
pub service_hz: usize,
|
|
||||||
/// Raw-IMU publication rate.
|
|
||||||
pub raw_imu_hz: usize,
|
|
||||||
/// Range publication rate. `None` means range is not published.
|
|
||||||
pub range_hz: Option<usize>,
|
|
||||||
/// Optical-flow publication rate. `None` means flow is not published.
|
|
||||||
pub optical_flow_hz: Option<usize>,
|
|
||||||
/// Link heartbeat publication rate.
|
|
||||||
pub heartbeat_hz: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Telemetry-only physical-sensor and outbound-data policy.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct TelemetryProfile {
|
|
||||||
/// Immutable physical IMU driver selected by this board image.
|
|
||||||
pub imu: ImuDevice,
|
|
||||||
/// Immutable board-to-body transform applied to that IMU's samples.
|
|
||||||
pub imu_mount: ImuMount,
|
|
||||||
/// Supported physical vertical-sensing capability.
|
|
||||||
pub vertical_sensing: VerticalSensingKind,
|
|
||||||
/// Data-channel timer and publication rates.
|
|
||||||
pub data_channel: DataChannelRates,
|
|
||||||
/// Highest optional diagnostic severity forwarded to MAVLink.
|
|
||||||
///
|
|
||||||
/// Errors remain observable even when optional debug/info diagnostics are
|
|
||||||
/// filtered. This setting is compile-time board policy until the target
|
|
||||||
/// has the reviewed configuration activation path.
|
|
||||||
pub maximum_log_level: Severity,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exclusive build-time image selection.
|
|
||||||
///
|
|
||||||
/// `ExternalControlOnly` remains representable for an explicit validation
|
|
||||||
/// rejection rather than silently falling back to telemetry.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum BoardProfile {
|
|
||||||
/// Physical-sensor telemetry image.
|
|
||||||
TelemetryOnly(TelemetryProfile),
|
|
||||||
/// Reserved and deliberately unimplemented production authority profile.
|
|
||||||
ExternalControlOnly,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Static board identity and exclusive build-time image configuration.
|
|
||||||
///
|
|
||||||
/// The selected platform trait determines the MCU family. Keeping a free-form
|
|
||||||
/// MCU string here would duplicate that type-level fact without validating it.
|
|
||||||
///
|
|
||||||
/// Board policy is compiled and non-deserializable. The current F722 platform
|
|
||||||
/// validates it against typestate-selected electrical routes before it touches
|
|
||||||
/// hardware. Target-side runtime configuration remains disabled until its
|
|
||||||
/// separate authenticated, journalled, disarmed activation contract exists.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct BoardConfig {
|
|
||||||
/// Stable board name used in build and diagnostic tooling.
|
|
||||||
pub name: &'static str,
|
|
||||||
/// MAVLink endpoint identity.
|
|
||||||
pub mavlink: MavlinkEndpoint,
|
|
||||||
/// Exactly one immutable image profile.
|
|
||||||
pub profile: BoardProfile,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BoardConfig {
|
|
||||||
/// Returns physical telemetry configuration only for a telemetry image.
|
|
||||||
pub const fn telemetry(self) -> Option<TelemetryProfile> {
|
|
||||||
match self.profile {
|
|
||||||
BoardProfile::TelemetryOnly(profile) => Some(profile),
|
|
||||||
BoardProfile::ExternalControlOnly => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates the selected exclusive profile.
|
|
||||||
pub const fn validate(self) -> Result<(), BoardConfigError> {
|
|
||||||
if self.mavlink.system_id == 0 || self.mavlink.component_id == 0 {
|
|
||||||
return Err(BoardConfigError::InvalidMavlinkEndpoint);
|
|
||||||
}
|
|
||||||
if self.mavlink.baud == 0 {
|
|
||||||
return Err(BoardConfigError::ZeroMavlinkBaud);
|
|
||||||
}
|
|
||||||
match self.profile {
|
|
||||||
BoardProfile::TelemetryOnly(profile) => Self::validate_telemetry(profile),
|
|
||||||
BoardProfile::ExternalControlOnly => {
|
|
||||||
// TODO(flight-config): replace this hard rejection only with a
|
|
||||||
// complete runtime and atomic configuration activation. Required
|
|
||||||
// inputs are vehicle geometry, calibrated sensors, actuator
|
|
||||||
// limits, authenticated MAVLink-v2 authority, persistent replay
|
|
||||||
// state, estimator/controller/allocator readiness, and a
|
|
||||||
// verified output watchdog.
|
|
||||||
Err(BoardConfigError::UnsupportedFlightProfile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn validate_telemetry(profile: TelemetryProfile) -> Result<(), BoardConfigError> {
|
|
||||||
let rates = profile.data_channel;
|
|
||||||
if rates.service_hz == 0 {
|
|
||||||
return Err(BoardConfigError::ZeroServiceFrequency);
|
|
||||||
}
|
|
||||||
if rates.raw_imu_hz == 0 || rates.heartbeat_hz == 0 {
|
|
||||||
return Err(BoardConfigError::MissingRequiredDataChannel);
|
|
||||||
}
|
|
||||||
if rates.raw_imu_hz > rates.service_hz || rates.heartbeat_hz > rates.service_hz {
|
|
||||||
return Err(BoardConfigError::RateExceedsServiceFrequency);
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(mut scheduled_hz) = rates.raw_imu_hz.checked_add(rates.heartbeat_hz) else {
|
|
||||||
return Err(BoardConfigError::DataChannelOvercommitted);
|
|
||||||
};
|
|
||||||
if let Some(range_hz) = rates.range_hz {
|
|
||||||
if range_hz == 0 || range_hz > rates.service_hz {
|
|
||||||
return Err(BoardConfigError::RateExceedsServiceFrequency);
|
|
||||||
}
|
|
||||||
let Some(total) = scheduled_hz.checked_add(range_hz) else {
|
|
||||||
return Err(BoardConfigError::DataChannelOvercommitted);
|
|
||||||
};
|
|
||||||
scheduled_hz = total;
|
|
||||||
}
|
|
||||||
if let Some(flow_hz) = rates.optical_flow_hz {
|
|
||||||
if flow_hz == 0 || flow_hz > rates.service_hz {
|
|
||||||
return Err(BoardConfigError::RateExceedsServiceFrequency);
|
|
||||||
}
|
|
||||||
let Some(total) = scheduled_hz.checked_add(flow_hz) else {
|
|
||||||
return Err(BoardConfigError::DataChannelOvercommitted);
|
|
||||||
};
|
|
||||||
scheduled_hz = total;
|
|
||||||
}
|
|
||||||
if scheduled_hz > rates.service_hz {
|
|
||||||
return Err(BoardConfigError::DataChannelOvercommitted);
|
|
||||||
}
|
|
||||||
|
|
||||||
let vertical_schedule_is_valid = match profile.vertical_sensing {
|
|
||||||
VerticalSensingKind::None
|
|
||||||
if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() =>
|
|
||||||
{
|
|
||||||
true
|
|
||||||
}
|
|
||||||
VerticalSensingKind::Range
|
|
||||||
if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() =>
|
|
||||||
{
|
|
||||||
true
|
|
||||||
}
|
|
||||||
VerticalSensingKind::RangeAndOpticalFlow
|
|
||||||
if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() =>
|
|
||||||
{
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
if !vertical_schedule_is_valid {
|
|
||||||
return Err(BoardConfigError::CapabilityDataChannelMismatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Invalid board-support configuration.
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
|
||||||
pub enum BoardConfigError {
|
|
||||||
/// MAVLink system/component identifiers must be exact nonzero values.
|
|
||||||
InvalidMavlinkEndpoint,
|
|
||||||
/// A physical MAVLink endpoint needs a nonzero UART rate.
|
|
||||||
ZeroMavlinkBaud,
|
|
||||||
/// A data-channel schedule needs a nonzero service frequency.
|
|
||||||
ZeroServiceFrequency,
|
|
||||||
/// Raw IMU and heartbeat are mandatory on every flight board.
|
|
||||||
MissingRequiredDataChannel,
|
|
||||||
/// A configured stream is faster than its service timer.
|
|
||||||
RateExceedsServiceFrequency,
|
|
||||||
/// The schedule requests more than one frame per service tick.
|
|
||||||
DataChannelOvercommitted,
|
|
||||||
/// Declared vertical sensing and published streams disagree.
|
|
||||||
CapabilityDataChannelMismatch,
|
|
||||||
/// The selected operation profile has no complete runtime implementation.
|
|
||||||
UnsupportedFlightProfile,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{
|
|
||||||
BoardConfig, BoardConfigError, BoardProfile, DataChannelRates, ImuDevice, ImuMount,
|
|
||||||
MavlinkEndpoint, MavlinkPort, TelemetryProfile, VerticalSensingKind,
|
|
||||||
};
|
|
||||||
use kaptan_log::Severity;
|
|
||||||
|
|
||||||
const BASE: BoardConfig = BoardConfig {
|
|
||||||
name: "test",
|
|
||||||
mavlink: MavlinkEndpoint {
|
|
||||||
system_id: 1,
|
|
||||||
component_id: 1,
|
|
||||||
port: MavlinkPort::Serial1,
|
|
||||||
baud: 115_200,
|
|
||||||
},
|
|
||||||
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
|
|
||||||
imu: ImuDevice::Mpu6000,
|
|
||||||
imu_mount: ImuMount::BodyAligned,
|
|
||||||
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
|
||||||
data_channel: DataChannelRates {
|
|
||||||
service_hz: 100,
|
|
||||||
raw_imu_hz: 20,
|
|
||||||
range_hz: Some(20),
|
|
||||||
optical_flow_hz: Some(20),
|
|
||||||
heartbeat_hz: 2,
|
|
||||||
},
|
|
||||||
maximum_log_level: Severity::Info,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_a_schedulable_range_and_flow_board() {
|
|
||||||
assert_eq!(BASE.validate(), Ok(()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_streams_that_exceed_the_service_budget() {
|
|
||||||
let mut config = BASE;
|
|
||||||
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
|
|
||||||
unreachable!();
|
|
||||||
};
|
|
||||||
profile.data_channel.optical_flow_hz = Some(80);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.validate(),
|
|
||||||
Err(BoardConfigError::DataChannelOvercommitted)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_capability_and_data_channel_mismatch() {
|
|
||||||
let mut config = BASE;
|
|
||||||
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
|
|
||||||
unreachable!();
|
|
||||||
};
|
|
||||||
profile.vertical_sensing = VerticalSensingKind::Range;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.validate(),
|
|
||||||
Err(BoardConfigError::CapabilityDataChannelMismatch)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_external_control_until_its_runtime_exists() {
|
|
||||||
let mut config = BASE;
|
|
||||||
config.profile = BoardProfile::ExternalControlOnly;
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
config.validate(),
|
|
||||||
Err(BoardConfigError::UnsupportedFlightProfile)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_immutable_link_policy() {
|
|
||||||
let mut config = BASE;
|
|
||||||
config.mavlink.baud = 0;
|
|
||||||
assert_eq!(config.validate(), Err(BoardConfigError::ZeroMavlinkBaud));
|
|
||||||
|
|
||||||
let mut config = BASE;
|
|
||||||
config.mavlink.component_id = 0;
|
|
||||||
assert_eq!(
|
|
||||||
config.validate(),
|
|
||||||
Err(BoardConfigError::InvalidMavlinkEndpoint)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use proc_macro2::TokenStream;
|
||||||
|
use quote::quote;
|
||||||
|
use syn::{
|
||||||
|
Attribute, Data, DeriveInput, Expr, Fields, Ident, Lit, LitStr, Result, Token,
|
||||||
|
punctuated::Punctuated, spanned::Spanned,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_CONFIG_ENUM_LABEL_BYTES: usize = 32;
|
||||||
|
|
||||||
|
pub(crate) fn expand(input: DeriveInput) -> Result<TokenStream> {
|
||||||
|
if !input.generics.params.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.generics.span(),
|
||||||
|
"ConfigEnum does not support generic enums",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
require_u8_representation(&input.attrs, input.ident.span())?;
|
||||||
|
if input
|
||||||
|
.attrs
|
||||||
|
.iter()
|
||||||
|
.any(|attribute| attribute.path().is_ident("config_enum"))
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.ident.span(),
|
||||||
|
"config_enum options belong on enum variants",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Data::Enum(data) = input.data else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.ident.span(),
|
||||||
|
"ConfigEnum can only be derived for enums",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if data.variants.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.ident.span(),
|
||||||
|
"ConfigEnum requires at least one variant",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if data.variants.len() > usize::from(u8::MAX) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
input.ident.span(),
|
||||||
|
"ConfigEnum supports at most 255 variants",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut variants = Vec::with_capacity(data.variants.len());
|
||||||
|
let mut values = Vec::with_capacity(data.variants.len());
|
||||||
|
let mut labels = Vec::with_capacity(data.variants.len());
|
||||||
|
for variant in data.variants {
|
||||||
|
if !matches!(variant.fields, Fields::Unit) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
variant.fields.span(),
|
||||||
|
"ConfigEnum variants cannot contain fields",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some((_, discriminant)) = variant.discriminant else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
variant.ident.span(),
|
||||||
|
"ConfigEnum variants require explicit u8 discriminants",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let value = discriminant_value(&discriminant)?;
|
||||||
|
if values.contains(&value) {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
discriminant.span(),
|
||||||
|
"duplicate ConfigEnum discriminant",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let label = variant_label(&variant.attrs, &variant.ident)?;
|
||||||
|
if labels
|
||||||
|
.iter()
|
||||||
|
.any(|previous: &LitStr| previous.value() == label.value())
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
label.span(),
|
||||||
|
"duplicate ConfigEnum display label",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
variants.push(variant.ident);
|
||||||
|
values.push(value);
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ident = input.ident;
|
||||||
|
Ok(quote! {
|
||||||
|
impl #ident {
|
||||||
|
/// Canonical stable values and display labels for generic
|
||||||
|
/// configuration discovery.
|
||||||
|
pub const CONFIG_ENUM_OPTIONS: &'static [(u8, &'static str)] = &[
|
||||||
|
#((#values, #labels)),*
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns this variant's stable compact representation.
|
||||||
|
pub const fn wire_value(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
#(Self::#variants => #values),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes one stable compact representation.
|
||||||
|
pub const fn try_from_wire(value: u8) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
#(#values => Some(Self::#variants),)*
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_u8_representation(attributes: &[Attribute], span: proc_macro2::Span) -> Result<()> {
|
||||||
|
let representations = attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|attribute| attribute.path().is_ident("repr"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if representations.len() != 1 {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
span,
|
||||||
|
"ConfigEnum requires exactly #[repr(u8)]",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let arguments =
|
||||||
|
representations[0].parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
|
||||||
|
if arguments.len() != 1 || !arguments[0].eq("u8") {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
representations[0].span(),
|
||||||
|
"ConfigEnum requires exactly #[repr(u8)]",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discriminant_value(expression: &Expr) -> Result<u8> {
|
||||||
|
let Expr::Lit(expression) = expression else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"ConfigEnum discriminants must be u8 integer literals",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Lit::Int(literal) = &expression.lit else {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"ConfigEnum discriminants must be u8 integer literals",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
literal.base10_parse::<u8>().map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
"ConfigEnum discriminant is outside the u8 domain",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn variant_label(attributes: &[Attribute], ident: &Ident) -> Result<LitStr> {
|
||||||
|
let mut label = None;
|
||||||
|
for attribute in attributes
|
||||||
|
.iter()
|
||||||
|
.filter(|attribute| attribute.path().is_ident("config_enum"))
|
||||||
|
{
|
||||||
|
attribute.parse_nested_meta(|meta| {
|
||||||
|
if meta.path.is_ident("label") {
|
||||||
|
if label.is_some() {
|
||||||
|
return Err(meta.error("duplicate ConfigEnum display label"));
|
||||||
|
}
|
||||||
|
label = Some(meta.value()?.parse::<LitStr>()?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(meta.error("the only variant config_enum option is label = \"...\""))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let label = label.unwrap_or_else(|| LitStr::new(&ident.to_string(), ident.span()));
|
||||||
|
if label.value().is_empty() || label.value().contains('\0') {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
label.span(),
|
||||||
|
"ConfigEnum display labels must be non-empty text without NUL bytes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if label.value().len() > MAX_CONFIG_ENUM_LABEL_BYTES {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
label.span(),
|
||||||
|
"ConfigEnum display label exceeds the 32-byte wire limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(label)
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ pub(crate) fn expand(model: Model) -> Result<TokenStream> {
|
|||||||
} = model;
|
} = model;
|
||||||
match kind {
|
match kind {
|
||||||
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
|
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
|
||||||
ModelKind::Sections => expand_sections(ident, generics, crate_path, post_validate, fields),
|
ModelKind::Sections => expand_children(ident, generics, crate_path, post_validate, fields),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,46 +29,72 @@ fn expand_scalars(
|
|||||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
let mut checks = Vec::new();
|
let mut checks = Vec::new();
|
||||||
let mut descriptors = Vec::new();
|
let mut descriptors = Vec::new();
|
||||||
let mut defaults = Vec::new();
|
|
||||||
let mut reads = Vec::new();
|
let mut reads = Vec::new();
|
||||||
let mut writes = Vec::new();
|
let mut writes = Vec::new();
|
||||||
|
let mut snapshot_widths = Vec::new();
|
||||||
for field in &fields {
|
for field in &fields {
|
||||||
let FieldMetadata {
|
let FieldMetadata {
|
||||||
descriptor,
|
descriptor,
|
||||||
checks: field_checks,
|
checks: field_checks,
|
||||||
} = expand_field(&crate_path, field)?;
|
} = expand_field(&crate_path, field)?;
|
||||||
if let Some(descriptor) = descriptor {
|
descriptors.push(descriptor);
|
||||||
descriptors.push(descriptor);
|
checks.extend(field_checks.iter().cloned());
|
||||||
}
|
let id = field.id.expect("scalar configuration fields have IDs");
|
||||||
checks.extend(field_checks);
|
let ty = &field.ty;
|
||||||
let id = field.id.expect("ConfigValidate requires configured fields");
|
|
||||||
let default = field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("ConfigValidate requires defaults");
|
|
||||||
let (kind, value) = config_value_tokens(&crate_path, &field.ty)?;
|
|
||||||
let field_ident = &field.ident;
|
let field_ident = &field.ident;
|
||||||
defaults.push(quote! { #field_ident: #default });
|
let (kind, read, decode, out_of_domain_rule) = if !field.enum_backed {
|
||||||
|
snapshot_widths.push(quote! { <#ty as #crate_path::ConfigScalar>::KIND.width() });
|
||||||
|
(
|
||||||
|
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
|
||||||
|
quote! { #crate_path::ConfigScalar::into_config_value(self.#field_ident) },
|
||||||
|
quote! { <#ty as #crate_path::ConfigScalar>::try_from_config_value(value) },
|
||||||
|
quote! { #crate_path::ValidationRule::AboveMaximum },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
snapshot_widths.push(quote! { 1usize });
|
||||||
|
(
|
||||||
|
quote! { #crate_path::ValueKind::U8 },
|
||||||
|
quote! { #crate_path::ConfigValue::U8(self.#field_ident.wire_value()) },
|
||||||
|
quote! {
|
||||||
|
match value {
|
||||||
|
#crate_path::ConfigValue::U8(value) => <#ty>::try_from_wire(value)
|
||||||
|
.ok_or(#crate_path::ScalarValueError::OutOfDomain),
|
||||||
|
value => Err(#crate_path::ScalarValueError::WrongKind(value.kind())),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
quote! { #crate_path::ValidationRule::InvalidEnumValue },
|
||||||
|
)
|
||||||
|
};
|
||||||
reads.push(quote! {
|
reads.push(quote! {
|
||||||
#id => Some(#value(self.#field_ident)),
|
#id => Some(#read),
|
||||||
});
|
});
|
||||||
writes.push(quote! {
|
writes.push(quote! {
|
||||||
#id => {
|
#id => {
|
||||||
let next = match value {
|
let next = match #decode {
|
||||||
#value(next) => next,
|
Ok(next) => next,
|
||||||
actual => {
|
Err(#crate_path::ScalarValueError::WrongKind(actual)) => {
|
||||||
return Err(#crate_path::FieldWriteError::WrongType {
|
return Err(#crate_path::FieldWriteError::WrongType {
|
||||||
field: #crate_path::FieldId::new(#id),
|
field: #crate_path::FieldId::new(#id),
|
||||||
expected: #kind,
|
expected: #kind,
|
||||||
actual: actual.kind(),
|
actual,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Err(#crate_path::ScalarValueError::OutOfDomain) => {
|
||||||
|
return Err(#crate_path::FieldWriteError::Violation(
|
||||||
|
#crate_path::ConfigViolation::field(
|
||||||
|
#crate_path::FieldId::new(#id),
|
||||||
|
#out_of_domain_rule,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let previous = self.#field_ident;
|
let previous = self.#field_ident;
|
||||||
self.#field_ident = next;
|
self.#field_ident = next;
|
||||||
if let Err(violation) =
|
let validation = (|| -> Result<(), #crate_path::ConfigViolation> {
|
||||||
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
|
#(#field_checks)*
|
||||||
{
|
Ok(())
|
||||||
|
})();
|
||||||
|
if let Err(violation) = validation {
|
||||||
self.#field_ident = previous;
|
self.#field_ident = previous;
|
||||||
return Err(#crate_path::FieldWriteError::Violation(violation));
|
return Err(#crate_path::FieldWriteError::Violation(violation));
|
||||||
}
|
}
|
||||||
@@ -78,30 +104,33 @@ fn expand_scalars(
|
|||||||
}
|
}
|
||||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
impl #impl_generics #ident #type_generics #where_clause {
|
||||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
/// Field metadata generated from the same declarations as validation.
|
||||||
<Self as #crate_path::ConfigSection>::validate_local_fields(self)?;
|
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
|
||||||
#post_validate
|
#(#descriptors),*
|
||||||
|
];
|
||||||
|
|
||||||
|
fn __validate_local_configuration(
|
||||||
|
&self,
|
||||||
|
) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
|
if !#crate_path::validation::__valid_enum_options(Self::DESCRIPTORS) {
|
||||||
|
return Err(#crate_path::ConfigViolation::global(
|
||||||
|
#crate_path::ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#(#checks)*
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl #impl_generics #ident #type_generics #where_clause {
|
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
|
||||||
/// Field metadata generated from the same attributes as validation.
|
|
||||||
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
|
|
||||||
#(#descriptors),*
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause {
|
|
||||||
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
|
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
|
||||||
|
const SNAPSHOT_VALUE_BYTES: usize = 0 #(+ #snapshot_widths)*;
|
||||||
|
const SCHEMA_ID: [u8; 16] =
|
||||||
|
#crate_path::validation::__schema_id(Self::DESCRIPTORS);
|
||||||
|
|
||||||
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
|
fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
|
||||||
let section = Self {
|
Self::DESCRIPTORS.get(index)
|
||||||
#(#defaults),*
|
|
||||||
};
|
|
||||||
<Self as #crate_path::ValidateFields>::validate_fields(§ion)?;
|
|
||||||
Ok(section)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||||
@@ -122,15 +151,16 @@ fn expand_scalars(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
#(#checks)*
|
self.__validate_local_configuration()?;
|
||||||
|
#post_validate
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_sections(
|
fn expand_children(
|
||||||
ident: syn::Ident,
|
ident: syn::Ident,
|
||||||
generics: syn::Generics,
|
generics: syn::Generics,
|
||||||
crate_path: syn::Path,
|
crate_path: syn::Path,
|
||||||
@@ -138,71 +168,58 @@ fn expand_sections(
|
|||||||
fields: Vec<crate::parse::FieldModel>,
|
fields: Vec<crate::parse::FieldModel>,
|
||||||
) -> Result<TokenStream> {
|
) -> Result<TokenStream> {
|
||||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
let validations = fields.iter().map(|field| {
|
let mut validations = Vec::new();
|
||||||
|
let mut reads = Vec::new();
|
||||||
|
let mut writes = Vec::new();
|
||||||
|
let mut counts = Vec::new();
|
||||||
|
let mut snapshot_widths = Vec::new();
|
||||||
|
let mut schema_ids = Vec::new();
|
||||||
|
let mut descriptor_lookups = Vec::new();
|
||||||
|
for field in &fields {
|
||||||
let field_ident = &field.ident;
|
let field_ident = &field.ident;
|
||||||
let ty = &field.ty;
|
let ty = &field.ty;
|
||||||
quote! {
|
validations.push(quote! {
|
||||||
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
|
<#ty as #crate_path::Config>::validate(&self.#field_ident)?;
|
||||||
}
|
});
|
||||||
});
|
reads.push(quote! {
|
||||||
let defaults = fields.iter().map(|field| {
|
if let Some(value) = <#ty as #crate_path::Config>::field_value(
|
||||||
let field_ident = &field.ident;
|
|
||||||
let ty = &field.ty;
|
|
||||||
quote! {
|
|
||||||
#field_ident: <#ty as #crate_path::ConfigSection>::default_section()?
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let reads = fields.iter().map(|field| {
|
|
||||||
let field_ident = &field.ident;
|
|
||||||
let ty = &field.ty;
|
|
||||||
quote! {
|
|
||||||
if let Some(value) = <#ty as #crate_path::ConfigSection>::field_value(
|
|
||||||
&self.#field_ident,
|
&self.#field_ident,
|
||||||
id,
|
id,
|
||||||
) {
|
) {
|
||||||
return Some(value);
|
return Some(value);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
writes.push(quote! {
|
||||||
let writes = fields.iter().map(|field| {
|
if <#ty as #crate_path::Config>::field_value(&self.#field_ident, id).is_some() {
|
||||||
let field_ident = &field.ident;
|
return <#ty as #crate_path::Config>::set_field(
|
||||||
let ty = &field.ty;
|
|
||||||
quote! {
|
|
||||||
if <#ty as #crate_path::ConfigSection>::field_value(&self.#field_ident, id).is_some() {
|
|
||||||
return <#ty as #crate_path::ConfigSection>::set_field(
|
|
||||||
&mut self.#field_ident,
|
&mut self.#field_ident,
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT });
|
||||||
let counts = fields.iter().map(|field| {
|
snapshot_widths.push(quote! { <#ty as #crate_path::Config>::SNAPSHOT_VALUE_BYTES });
|
||||||
let ty = &field.ty;
|
schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID });
|
||||||
quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT }
|
descriptor_lookups.push(quote! {
|
||||||
});
|
if index < <#ty as #crate_path::Config>::FIELD_COUNT {
|
||||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
return <#ty as #crate_path::Config>::descriptor(index);
|
||||||
Ok(quote! {
|
|
||||||
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
|
||||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
|
||||||
#(#validations)*
|
|
||||||
#post_validate
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
index -= <#ty as #crate_path::Config>::FIELD_COUNT;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||||
|
|
||||||
impl #impl_generics #ident #type_generics #where_clause {
|
Ok(quote! {
|
||||||
/// Number of fields aggregated from derived nested sections.
|
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
|
||||||
pub const FIELD_COUNT: usize = 0 #(+ #counts)*;
|
const FIELD_COUNT: usize = 0 #(+ #counts)*;
|
||||||
}
|
const SNAPSHOT_VALUE_BYTES: usize = 0 #(+ #snapshot_widths)*;
|
||||||
|
const SCHEMA_ID: [u8; 16] =
|
||||||
|
#crate_path::validation::__combine_schema_ids(&[#(#schema_ids),*]);
|
||||||
|
|
||||||
impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause {
|
fn descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
|
||||||
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> {
|
#(#descriptor_lookups)*
|
||||||
let aggregate = Self {
|
None
|
||||||
#(#defaults),*
|
|
||||||
};
|
|
||||||
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
|
|
||||||
Ok(aggregate)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||||
@@ -218,23 +235,39 @@ fn expand_sections(
|
|||||||
#(#writes)*
|
#(#writes)*
|
||||||
Err(#crate_path::FieldWriteError::UnknownField(id))
|
Err(#crate_path::FieldWriteError::UnknownField(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||||
|
if !#crate_path::validation::__unique_field_ids::<Self>() {
|
||||||
|
return Err(#crate_path::ConfigViolation::global(
|
||||||
|
#crate_path::ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !#crate_path::validation::__unique_display_keys::<Self>() {
|
||||||
|
return Err(#crate_path::ConfigViolation::global(
|
||||||
|
#crate_path::ValidationRule::Inconsistent,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#(#validations)*
|
||||||
|
#post_validate
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #impl_generics #ident #type_generics #where_clause {
|
||||||
|
/// Number of fields contributed by the complete generated tree.
|
||||||
|
pub const FIELD_COUNT: usize = <Self as #crate_path::Config>::FIELD_COUNT;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FieldMetadata {
|
struct FieldMetadata {
|
||||||
descriptor: Option<TokenStream>,
|
descriptor: TokenStream,
|
||||||
checks: Vec<TokenStream>,
|
checks: Vec<TokenStream>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
|
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
|
||||||
let mut checks = Vec::new();
|
let mut checks = Vec::new();
|
||||||
let Some(id) = field.id else {
|
let id = field.id.expect("scalar configuration fields have IDs");
|
||||||
return Ok(FieldMetadata {
|
|
||||||
descriptor: None,
|
|
||||||
checks,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let minimum = field.checks.iter().find_map(|check| match check {
|
let minimum = field.checks.iter().find_map(|check| match check {
|
||||||
Check::Minimum(value) => Some(value),
|
Check::Minimum(value) => Some(value),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -243,28 +276,67 @@ fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Res
|
|||||||
Check::Maximum(value) => Some(value),
|
Check::Maximum(value) => Some(value),
|
||||||
_ => None,
|
_ => None,
|
||||||
});
|
});
|
||||||
|
let finite = field
|
||||||
|
.checks
|
||||||
|
.iter()
|
||||||
|
.any(|check| matches!(check, Check::Finite));
|
||||||
|
let nonzero = field
|
||||||
|
.checks
|
||||||
|
.iter()
|
||||||
|
.any(|check| matches!(check, Check::Nonzero));
|
||||||
|
let float = matches!(&field.ty, syn::Type::Path(path)
|
||||||
|
if path.path.is_ident("f32") || path.path.is_ident("f64"));
|
||||||
|
if float && !finite && (minimum.is_some() || maximum.is_some()) {
|
||||||
|
let field_ident = &field.ident;
|
||||||
|
checks.push(quote! {
|
||||||
|
if self.#field_ident.is_nan() {
|
||||||
|
return Err(#crate_path::ConfigViolation::field(
|
||||||
|
#crate_path::FieldId::new(#id),
|
||||||
|
#crate_path::ValidationRule::Unordered,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
for check in &field.checks {
|
for check in &field.checks {
|
||||||
checks.push(expand_check(crate_path, &field.ident, id, check.clone()));
|
checks.push(expand_check(
|
||||||
|
crate_path,
|
||||||
|
&field.ident,
|
||||||
|
id,
|
||||||
|
check.clone(),
|
||||||
|
float,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let key = field.display_key.as_ref().expect("id fields have a key");
|
let key = field.display_key.as_ref().expect("id fields have a key");
|
||||||
let unit = field.unit.as_ref().expect("id fields have a unit");
|
let unit = field.unit.as_ref().expect("id fields have a unit");
|
||||||
let default = field.default.as_ref().expect("id fields have a default");
|
let ty = &field.ty;
|
||||||
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
|
let (kind, enum_options) = if !field.enum_backed {
|
||||||
|
(
|
||||||
|
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
|
||||||
|
quote! { &[] },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
quote! { #crate_path::ValueKind::U8 },
|
||||||
|
quote! { <#ty>::CONFIG_ENUM_OPTIONS },
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let value = config_value_constructor_tokens(crate_path, &field.ty)?;
|
||||||
let minimum = option_value_tokens(value.clone(), minimum);
|
let minimum = option_value_tokens(value.clone(), minimum);
|
||||||
let maximum = option_value_tokens(value.clone(), maximum);
|
let maximum = option_value_tokens(value.clone(), maximum);
|
||||||
Ok(FieldMetadata {
|
Ok(FieldMetadata {
|
||||||
descriptor: Some(quote! {
|
descriptor: quote! {
|
||||||
#crate_path::FieldDescriptor {
|
#crate_path::FieldDescriptor {
|
||||||
id: #crate_path::FieldId::new(#id),
|
id: #crate_path::FieldId::new(#id),
|
||||||
display_key: #key,
|
display_key: #key,
|
||||||
kind: #kind,
|
kind: #kind,
|
||||||
minimum: #minimum,
|
minimum: #minimum,
|
||||||
maximum: #maximum,
|
maximum: #maximum,
|
||||||
default: #value(#default),
|
enum_options: #enum_options,
|
||||||
unit: #unit,
|
unit: #unit,
|
||||||
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
|
finite: #finite,
|
||||||
|
nonzero: #nonzero,
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
checks,
|
checks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -276,10 +348,7 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_value_tokens(
|
fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result<TokenStream> {
|
||||||
crate_path: &syn::Path,
|
|
||||||
ty: &syn::Type,
|
|
||||||
) -> Result<(TokenStream, TokenStream)> {
|
|
||||||
let syn::Type::Path(path) = ty else {
|
let syn::Type::Path(path) = ty else {
|
||||||
return Err(syn::Error::new_spanned(
|
return Err(syn::Error::new_spanned(
|
||||||
ty,
|
ty,
|
||||||
@@ -292,42 +361,34 @@ fn config_value_tokens(
|
|||||||
"configuration metadata requires a scalar type",
|
"configuration metadata requires a scalar type",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let (kind, value) = match segment.ident.to_string().as_str() {
|
let value = match segment.ident.to_string().as_str() {
|
||||||
"bool" => (
|
"bool" => quote! { #crate_path::ConfigValue::Bool },
|
||||||
quote! { #crate_path::ValueKind::Bool },
|
"i32" => quote! { #crate_path::ConfigValue::I32 },
|
||||||
quote! { #crate_path::ConfigValue::Bool },
|
"u8" => quote! { #crate_path::ConfigValue::U8 },
|
||||||
),
|
"u16" => quote! { #crate_path::ConfigValue::U16 },
|
||||||
"i32" => (
|
"u32" => quote! { #crate_path::ConfigValue::U32 },
|
||||||
quote! { #crate_path::ValueKind::I32 },
|
"u64" => quote! { #crate_path::ConfigValue::U64 },
|
||||||
quote! { #crate_path::ConfigValue::I32 },
|
"f32" => quote! { #crate_path::ConfigValue::F32 },
|
||||||
),
|
"f64" => quote! { #crate_path::ConfigValue::F64 },
|
||||||
"u32" => (
|
|
||||||
quote! { #crate_path::ValueKind::U32 },
|
|
||||||
quote! { #crate_path::ConfigValue::U32 },
|
|
||||||
),
|
|
||||||
"u64" => (
|
|
||||||
quote! { #crate_path::ValueKind::U64 },
|
|
||||||
quote! { #crate_path::ConfigValue::U64 },
|
|
||||||
),
|
|
||||||
"f32" => (
|
|
||||||
quote! { #crate_path::ValueKind::F32 },
|
|
||||||
quote! { #crate_path::ConfigValue::F32 },
|
|
||||||
),
|
|
||||||
"f64" => (
|
|
||||||
quote! { #crate_path::ValueKind::F64 },
|
|
||||||
quote! { #crate_path::ConfigValue::F64 },
|
|
||||||
),
|
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new_spanned(
|
return Ok(quote! { #crate_path::ConfigValue::U32 });
|
||||||
ty,
|
|
||||||
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok((kind, value))
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
|
fn expand_check(
|
||||||
|
crate_path: &syn::Path,
|
||||||
|
field: &syn::Ident,
|
||||||
|
id: u16,
|
||||||
|
check: Check,
|
||||||
|
float: bool,
|
||||||
|
) -> TokenStream {
|
||||||
|
let zero = if float {
|
||||||
|
quote! { 0.0 }
|
||||||
|
} else {
|
||||||
|
quote! { 0 }
|
||||||
|
};
|
||||||
match check {
|
match check {
|
||||||
Check::Finite => quote! {
|
Check::Finite => quote! {
|
||||||
if !self.#field.is_finite() {
|
if !self.#field.is_finite() {
|
||||||
@@ -338,7 +399,7 @@ fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Chec
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Check::Nonzero => quote! {
|
Check::Nonzero => quote! {
|
||||||
if self.#field == 0 {
|
if self.#field == #zero {
|
||||||
return Err(#crate_path::ConfigViolation::field(
|
return Err(#crate_path::ConfigViolation::field(
|
||||||
#crate_path::FieldId::new(#id),
|
#crate_path::FieldId::new(#id),
|
||||||
#crate_path::ValidationRule::Zero,
|
#crate_path::ValidationRule::Zero,
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
//! Procedural validation derive for Kaptan configuration records.
|
//! Procedural derive for recursively composable Kaptan configuration records.
|
||||||
|
|
||||||
|
mod config_enum;
|
||||||
mod expand;
|
mod expand;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use syn::parse_macro_input;
|
use syn::parse_macro_input;
|
||||||
|
|
||||||
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers.
|
/// Generates validation, metadata, routing, and schema identity.
|
||||||
#[proc_macro_derive(ConfigValidate, attributes(config))]
|
///
|
||||||
pub fn derive_config_validate(input: TokenStream) -> TokenStream {
|
/// Primitive fields retain their native scalar type. Enum-backed fields must
|
||||||
|
/// opt in with `#[config(enum)]` and derive [`ConfigEnum`].
|
||||||
|
#[proc_macro_derive(Config, attributes(config))]
|
||||||
|
pub fn derive_config(input: TokenStream) -> TokenStream {
|
||||||
let input = parse_macro_input!(input as syn::DeriveInput);
|
let input = parse_macro_input!(input as syn::DeriveInput);
|
||||||
match parse::Model::parse(input).and_then(expand::expand) {
|
match parse::Model::parse(input).and_then(expand::expand) {
|
||||||
Ok(tokens) => tokens.into(),
|
Ok(tokens) => tokens.into(),
|
||||||
Err(error) => error.into_compile_error().into(),
|
Err(error) => error.into_compile_error().into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the canonical table and conversions for a field configured as a
|
||||||
|
/// compact enum.
|
||||||
|
///
|
||||||
|
/// The enum must use `#[repr(u8)]`, have explicit integer discriminants, and
|
||||||
|
/// contain only fieldless variants. Variant identifiers are the default GCS
|
||||||
|
/// labels; `#[config_enum(label = "...")]` overrides one label.
|
||||||
|
#[proc_macro_derive(ConfigEnum, attributes(config_enum))]
|
||||||
|
pub fn derive_config_enum(input: TokenStream) -> TokenStream {
|
||||||
|
let input = parse_macro_input!(input as syn::DeriveInput);
|
||||||
|
match config_enum::expand(input) {
|
||||||
|
Ok(tokens) => tokens.into(),
|
||||||
|
Err(error) => error.into_compile_error().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ use syn::{
|
|||||||
spanned::Spanned,
|
spanned::Spanned,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_DISPLAY_KEY_BYTES: usize = 48;
|
||||||
|
const MAX_UNIT_BYTES: usize = 16;
|
||||||
|
|
||||||
pub(crate) struct Model {
|
pub(crate) struct Model {
|
||||||
pub(crate) ident: Ident,
|
pub(crate) ident: Ident,
|
||||||
pub(crate) generics: syn::Generics,
|
pub(crate) generics: syn::Generics,
|
||||||
@@ -24,9 +27,8 @@ pub(crate) struct FieldModel {
|
|||||||
pub(crate) id: Option<u16>,
|
pub(crate) id: Option<u16>,
|
||||||
pub(crate) display_key: Option<syn::LitStr>,
|
pub(crate) display_key: Option<syn::LitStr>,
|
||||||
pub(crate) unit: Option<syn::LitStr>,
|
pub(crate) unit: Option<syn::LitStr>,
|
||||||
pub(crate) default: Option<Expr>,
|
|
||||||
pub(crate) checks: Vec<Check>,
|
pub(crate) checks: Vec<Check>,
|
||||||
pub(crate) section: bool,
|
pub(crate) enum_backed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -43,19 +45,19 @@ impl Model {
|
|||||||
let Data::Struct(data) = input.data else {
|
let Data::Struct(data) = input.data else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
input.ident.span(),
|
input.ident.span(),
|
||||||
"ConfigValidate can only be derived for structs",
|
"Config can only be derived for structs",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let Fields::Named(fields) = data.fields else {
|
let Fields::Named(fields) = data.fields else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
input.ident.span(),
|
input.ident.span(),
|
||||||
"ConfigValidate requires named struct fields",
|
"Config requires named struct fields",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if fields.named.is_empty() {
|
if fields.named.is_empty() {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
input.ident.span(),
|
input.ident.span(),
|
||||||
"ConfigValidate requires at least one configured field or section",
|
"Config requires at least one field",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut models = Vec::with_capacity(fields.named.len());
|
let mut models = Vec::with_capacity(fields.named.len());
|
||||||
@@ -73,38 +75,63 @@ impl Model {
|
|||||||
}
|
}
|
||||||
models.push(model);
|
models.push(model);
|
||||||
}
|
}
|
||||||
let kind = if models.iter().all(|field| field.section) {
|
let kind = if models.iter().all(|field| field.id.is_some()) {
|
||||||
ModelKind::Sections
|
|
||||||
} else if models.iter().all(|field| field.id.is_some()) {
|
|
||||||
ModelKind::Scalars
|
ModelKind::Scalars
|
||||||
|
} else if models.iter().all(|field| field.id.is_none()) {
|
||||||
|
ModelKind::Sections
|
||||||
} else {
|
} else {
|
||||||
let field = models
|
let field = models
|
||||||
.iter()
|
.iter()
|
||||||
.find(|field| field.section || field.id.is_none())
|
.find(|field| field.id.is_none())
|
||||||
.expect("mixed configuration model has one invalid field");
|
.expect("mixed configuration model has one invalid field");
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
"ConfigValidate records must contain either scalar config fields or config(section) fields exclusively",
|
"Config records must contain either scalar fields or child configuration records exclusively",
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if kind == ModelKind::Scalars {
|
if kind == ModelKind::Scalars {
|
||||||
|
let mut display_keys = Vec::with_capacity(models.len());
|
||||||
for field in &models {
|
for field in &models {
|
||||||
if field.display_key.is_none() {
|
let Some(display_key) = &field.display_key else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
"configuration fields require config(key = \"...\")",
|
"configuration fields require config(key = \"...\")",
|
||||||
));
|
));
|
||||||
|
};
|
||||||
|
validate_metadata_text(
|
||||||
|
display_key,
|
||||||
|
MAX_DISPLAY_KEY_BYTES,
|
||||||
|
"configuration display key",
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
if display_keys
|
||||||
|
.iter()
|
||||||
|
.any(|previous: &&syn::LitStr| previous.value() == display_key.value())
|
||||||
|
{
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
display_key.span(),
|
||||||
|
"duplicate configuration display key",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if field.unit.is_none() {
|
display_keys.push(display_key);
|
||||||
|
let Some(unit) = &field.unit else {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ident.span(),
|
||||||
"configuration fields require config(unit = \"...\")",
|
"configuration fields require config(unit = \"...\")",
|
||||||
));
|
));
|
||||||
}
|
};
|
||||||
if field.default.is_none() {
|
validate_metadata_text(unit, MAX_UNIT_BYTES, "configuration unit", true)?;
|
||||||
|
let primitive = scalar_type_name(&field.ty).is_some();
|
||||||
|
if field.enum_backed && primitive {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
field.ident.span(),
|
field.ty.span(),
|
||||||
"configuration fields require config(default = ...)",
|
"config(enum) requires a ConfigEnum-derived enum type",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !field.enum_backed && !primitive {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
field.ty.span(),
|
||||||
|
"non-primitive configuration fields require config(enum)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
validate_static_constraints(field)?;
|
validate_static_constraints(field)?;
|
||||||
@@ -121,6 +148,34 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_metadata_text(
|
||||||
|
literal: &syn::LitStr,
|
||||||
|
maximum_bytes: usize,
|
||||||
|
name: &str,
|
||||||
|
allow_empty: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let value = literal.value();
|
||||||
|
if !allow_empty && value.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
format!("{name} must not be empty"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.contains('\0') {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
format!("{name} cannot contain NUL bytes"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if value.len() > maximum_bytes {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
literal.span(),
|
||||||
|
format!("{name} exceeds the {maximum_bytes}-byte wire limit"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
|
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
|
||||||
let mut crate_path = syn::parse_quote!(::kaptan_config);
|
let mut crate_path = syn::parse_quote!(::kaptan_config);
|
||||||
let mut has_crate_path = false;
|
let mut has_crate_path = false;
|
||||||
@@ -160,40 +215,17 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
let mut id = None;
|
let mut id = None;
|
||||||
let mut display_key = None;
|
let mut display_key = None;
|
||||||
let mut unit = None;
|
let mut unit = None;
|
||||||
let mut default = None;
|
|
||||||
let mut finite = false;
|
let mut finite = false;
|
||||||
let mut nonzero = false;
|
let mut nonzero = false;
|
||||||
let mut range_minimum = false;
|
let mut range_minimum = false;
|
||||||
let mut range_maximum = false;
|
let mut range_maximum = false;
|
||||||
let mut section = false;
|
let mut enum_backed = false;
|
||||||
for attribute in field
|
for attribute in field
|
||||||
.attrs
|
.attrs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|attribute| attribute.path().is_ident("config"))
|
.filter(|attribute| attribute.path().is_ident("config"))
|
||||||
{
|
{
|
||||||
attribute.parse_nested_meta(|meta| {
|
attribute.parse_nested_meta(|meta| {
|
||||||
if section && !meta.path.is_ident("section") {
|
|
||||||
return Err(meta.error(
|
|
||||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if meta.path.is_ident("section") {
|
|
||||||
if section {
|
|
||||||
return Err(meta.error("duplicate config(section)"));
|
|
||||||
}
|
|
||||||
if id.is_some()
|
|
||||||
|| display_key.is_some()
|
|
||||||
|| unit.is_some()
|
|
||||||
|| default.is_some()
|
|
||||||
|| !checks.is_empty()
|
|
||||||
{
|
|
||||||
return Err(meta.error(
|
|
||||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
section = true;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if meta.path.is_ident("id") {
|
if meta.path.is_ident("id") {
|
||||||
if id.is_some() {
|
if id.is_some() {
|
||||||
return Err(meta.error("duplicate configuration field id"));
|
return Err(meta.error("duplicate configuration field id"));
|
||||||
@@ -220,13 +252,11 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
unit = Some(meta.value()?.parse()?);
|
unit = Some(meta.value()?.parse()?);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if meta.path.is_ident("default") {
|
if meta.path.is_ident("enum") {
|
||||||
if default.is_some() {
|
if enum_backed {
|
||||||
return Err(meta.error("duplicate configuration default"));
|
return Err(meta.error("duplicate enum configuration marker"));
|
||||||
}
|
}
|
||||||
let expression = meta.value()?.parse()?;
|
enum_backed = true;
|
||||||
validate_default_literal(&expression, &field.ty)?;
|
|
||||||
default = Some(expression);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if meta.path.is_ident("finite") {
|
if meta.path.is_ident("finite") {
|
||||||
@@ -273,26 +303,16 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(meta.error(
|
Err(meta.error(
|
||||||
"supported field config rules are id, key, unit, default, finite, nonzero, and range",
|
"supported field config rules are id, key, unit, enum, finite, nonzero, and range",
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
if !section && !checks.is_empty() && id.is_none() {
|
if id.is_none()
|
||||||
return Err(syn::Error::new(
|
&& (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
|
||||||
ident.span(),
|
|
||||||
"validated configuration fields require config(id = N)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if section
|
|
||||||
&& (id.is_some()
|
|
||||||
|| display_key.is_some()
|
|
||||||
|| unit.is_some()
|
|
||||||
|| default.is_some()
|
|
||||||
|| !checks.is_empty())
|
|
||||||
{
|
{
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
ident.span(),
|
ident.span(),
|
||||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
"scalar configuration fields require config(id = N)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(FieldModel {
|
Ok(FieldModel {
|
||||||
@@ -301,9 +321,8 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
|||||||
id,
|
id,
|
||||||
display_key,
|
display_key,
|
||||||
unit,
|
unit,
|
||||||
default,
|
|
||||||
checks,
|
checks,
|
||||||
section,
|
enum_backed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,33 +372,19 @@ fn validate_numeric_literal(expression: &Expr) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> {
|
|
||||||
if scalar_type_name(ty) == Some("bool") {
|
|
||||||
return match expression {
|
|
||||||
Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()),
|
|
||||||
_ => Err(syn::Error::new(
|
|
||||||
expression.span(),
|
|
||||||
"bool configuration defaults must be bool literals",
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
validate_numeric_literal(expression)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
||||||
let ty = scalar_type_name(&field.ty).ok_or_else(|| {
|
let Some(ty) = scalar_type_name(&field.ty) else {
|
||||||
syn::Error::new(
|
if field.checks.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return Err(syn::Error::new(
|
||||||
field.ty.span(),
|
field.ty.span(),
|
||||||
"configuration metadata requires a supported scalar type",
|
"validation rules require a supported primitive scalar type",
|
||||||
)
|
));
|
||||||
})?;
|
};
|
||||||
if ty == "bool" {
|
if ty == "bool" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let default = scalar_literal(
|
|
||||||
field.default.as_ref().expect("scalar fields have defaults"),
|
|
||||||
ty,
|
|
||||||
)?;
|
|
||||||
let minimum = field.checks.iter().find_map(|check| match check {
|
let minimum = field.checks.iter().find_map(|check| match check {
|
||||||
Check::Minimum(value) => Some(value),
|
Check::Minimum(value) => Some(value),
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -406,70 +411,6 @@ fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
|||||||
"configuration range minimum exceeds maximum",
|
"configuration range minimum exceeds maximum",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if field
|
|
||||||
.checks
|
|
||||||
.iter()
|
|
||||||
.any(|check| matches!(check, Check::Finite))
|
|
||||||
&& !default.is_finite()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default violates finite",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !default.is_finite() {
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default is not finite for its field type",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if field
|
|
||||||
.checks
|
|
||||||
.iter()
|
|
||||||
.any(|check| matches!(check, Check::Nonzero))
|
|
||||||
&& default.is_zero()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default violates nonzero",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(minimum) = minimum
|
|
||||||
&& compare_scalar(default, minimum).is_lt()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default is below its declared minimum",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(maximum) = maximum
|
|
||||||
&& compare_scalar(default, maximum).is_gt()
|
|
||||||
{
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
field
|
|
||||||
.default
|
|
||||||
.as_ref()
|
|
||||||
.expect("scalar fields have defaults")
|
|
||||||
.span(),
|
|
||||||
"configuration default is above its declared maximum",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,14 +428,6 @@ impl ScalarLiteral {
|
|||||||
Self::Float(value) => value.is_finite(),
|
Self::Float(value) => value.is_finite(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_zero(self) -> bool {
|
|
||||||
match self {
|
|
||||||
Self::Signed(value) => value == 0,
|
|
||||||
Self::Unsigned(value) => value == 0,
|
|
||||||
Self::Float(value) => value == 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
||||||
@@ -519,6 +452,26 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
|||||||
})?;
|
})?;
|
||||||
Ok(ScalarLiteral::Unsigned(value))
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
}
|
}
|
||||||
|
"u8" => {
|
||||||
|
let value = unsigned_literal(expression)?;
|
||||||
|
u8::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside u8 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
|
}
|
||||||
|
"u16" => {
|
||||||
|
let value = unsigned_literal(expression)?;
|
||||||
|
u16::try_from(value).map_err(|_| {
|
||||||
|
syn::Error::new(
|
||||||
|
expression.span(),
|
||||||
|
"configuration literal is outside u16 range",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
|
}
|
||||||
"u64" => {
|
"u64" => {
|
||||||
let value = unsigned_literal(expression)?;
|
let value = unsigned_literal(expression)?;
|
||||||
u64::try_from(value).map_err(|_| {
|
u64::try_from(value).map_err(|_| {
|
||||||
@@ -529,13 +482,15 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
|||||||
})?;
|
})?;
|
||||||
Ok(ScalarLiteral::Unsigned(value))
|
Ok(ScalarLiteral::Unsigned(value))
|
||||||
}
|
}
|
||||||
"f32" => Ok(ScalarLiteral::Float(f64::from(
|
// Parse at the emitted literal's native precision. Parsing through f64
|
||||||
float_literal(expression)? as f32
|
// first can double-round an f32 bound across an ordering boundary.
|
||||||
))),
|
"f32" => Ok(ScalarLiteral::Float(f64::from(float_literal::<f32>(
|
||||||
"f64" => float_literal(expression).map(ScalarLiteral::Float),
|
expression,
|
||||||
|
)?))),
|
||||||
|
"f64" => float_literal::<f64>(expression).map(ScalarLiteral::Float),
|
||||||
_ => Err(syn::Error::new(
|
_ => Err(syn::Error::new(
|
||||||
expression.span(),
|
expression.span(),
|
||||||
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
"configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64",
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,7 +544,11 @@ fn unsigned_literal(expression: &Expr) -> Result<u128> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn float_literal(expression: &Expr) -> Result<f64> {
|
fn float_literal<T>(expression: &Expr) -> Result<T>
|
||||||
|
where
|
||||||
|
T: std::str::FromStr + core::ops::Neg<Output = T>,
|
||||||
|
T::Err: core::fmt::Display,
|
||||||
|
{
|
||||||
let (negative, literal) = match expression {
|
let (negative, literal) = match expression {
|
||||||
Expr::Lit(expression) => (false, &expression.lit),
|
Expr::Lit(expression) => (false, &expression.lit),
|
||||||
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
||||||
@@ -610,10 +569,10 @@ fn float_literal(expression: &Expr) -> Result<f64> {
|
|||||||
};
|
};
|
||||||
let value = match literal {
|
let value = match literal {
|
||||||
Lit::Int(value) => value
|
Lit::Int(value) => value
|
||||||
.base10_parse::<f64>()
|
.base10_parse::<T>()
|
||||||
.map_err(|error| syn::Error::new(value.span(), error))?,
|
.map_err(|error| syn::Error::new(value.span(), error))?,
|
||||||
Lit::Float(value) => value
|
Lit::Float(value) => value
|
||||||
.base10_parse::<f64>()
|
.base10_parse::<T>()
|
||||||
.map_err(|error| syn::Error::new(value.span(), error))?,
|
.map_err(|error| syn::Error::new(value.span(), error))?,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
@@ -648,30 +607,14 @@ fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> {
|
fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> {
|
||||||
if scalar_type_name(ty).is_some_and(|name| {
|
if scalar_type_name(ty)
|
||||||
matches!(
|
.is_some_and(|name| matches!(name, "f32" | "f64" | "i32" | "u8" | "u16" | "u32" | "u64"))
|
||||||
name,
|
{
|
||||||
"f32"
|
|
||||||
| "f64"
|
|
||||||
| "i8"
|
|
||||||
| "i16"
|
|
||||||
| "i32"
|
|
||||||
| "i64"
|
|
||||||
| "i128"
|
|
||||||
| "isize"
|
|
||||||
| "u8"
|
|
||||||
| "u16"
|
|
||||||
| "u32"
|
|
||||||
| "u64"
|
|
||||||
| "u128"
|
|
||||||
| "usize"
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(syn::Error::new(
|
Err(syn::Error::new(
|
||||||
span.span(),
|
span.span(),
|
||||||
"nonzero and range require a primitive numeric field",
|
"nonzero and range require a supported primitive numeric field",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,18 +634,11 @@ fn scalar_type_name(ty: &Type) -> Option<&str> {
|
|||||||
ident if ident == "bool" => "bool",
|
ident if ident == "bool" => "bool",
|
||||||
ident if ident == "f32" => "f32",
|
ident if ident == "f32" => "f32",
|
||||||
ident if ident == "f64" => "f64",
|
ident if ident == "f64" => "f64",
|
||||||
ident if ident == "i8" => "i8",
|
|
||||||
ident if ident == "i16" => "i16",
|
|
||||||
ident if ident == "i32" => "i32",
|
ident if ident == "i32" => "i32",
|
||||||
ident if ident == "i64" => "i64",
|
|
||||||
ident if ident == "i128" => "i128",
|
|
||||||
ident if ident == "isize" => "isize",
|
|
||||||
ident if ident == "u8" => "u8",
|
ident if ident == "u8" => "u8",
|
||||||
ident if ident == "u16" => "u16",
|
ident if ident == "u16" => "u16",
|
||||||
ident if ident == "u32" => "u32",
|
ident if ident == "u32" => "u32",
|
||||||
ident if ident == "u64" => "u64",
|
ident if ident == "u64" => "u64",
|
||||||
ident if ident == "u128" => "u128",
|
|
||||||
ident if ident == "usize" => "usize",
|
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
|
use kaptan_config::{Config, ConfigViolation};
|
||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct Child {
|
struct Child {
|
||||||
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
|
#[config(id = 1, key = "child.value", unit = "count", range(min = 0, max = 2))]
|
||||||
value: u32,
|
value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
#[config(post = Self::validate_complete)]
|
#[config(post = Self::validate_complete)]
|
||||||
struct Root {
|
struct Root {
|
||||||
#[config(section)]
|
|
||||||
child: Child,
|
child: Child,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Root {
|
impl Root {
|
||||||
fn validate_complete(&self) -> Result<(), ConfigViolation> {
|
fn validate_complete(&self) -> Result<(), ConfigViolation> {
|
||||||
self.child.validate_fields()
|
self.child.validate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let root = Root::default_aggregate().unwrap();
|
let root = Root {
|
||||||
|
child: Child { value: 1 },
|
||||||
|
};
|
||||||
assert_eq!(Root::FIELD_COUNT, 1);
|
assert_eq!(Root::FIELD_COUNT, 1);
|
||||||
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
|
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use kaptan_config_derive::{Config, ConfigEnum};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Mode {
|
||||||
|
Safe = 1,
|
||||||
|
#[config_enum(label = "Full authority")]
|
||||||
|
FullAuthority = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct EnumConfig {
|
||||||
|
#[config(enum, id = 1, key = "mode", unit = "mode")]
|
||||||
|
mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
assert_eq!(Mode::Safe.wire_value(), 1);
|
||||||
|
assert!(matches!(Mode::try_from_wire(7), Some(Mode::FullAuthority)));
|
||||||
|
assert_eq!(Mode::CONFIG_ENUM_OPTIONS[1], (7, "Full authority"));
|
||||||
|
assert_eq!(
|
||||||
|
EnumConfig::DESCRIPTORS[0].enum_options,
|
||||||
|
Mode::CONFIG_ENUM_OPTIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct RootBooleanConfig {
|
struct RootBooleanConfig {
|
||||||
#[config(id = 1, key = "enabled", unit = "bool", default = true)]
|
#[config(id = 1, key = "enabled", unit = "bool")]
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,22 @@
|
|||||||
use kaptan_config::{
|
use kaptan_config::{
|
||||||
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
|
Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
|
||||||
ValidateFields, ValidationRule,
|
|
||||||
};
|
};
|
||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct LocalSection {
|
struct LocalSection {
|
||||||
#[config(
|
#[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
|
||||||
id = 1,
|
|
||||||
key = "local.first",
|
|
||||||
unit = "count",
|
|
||||||
default = 2,
|
|
||||||
range(min = 1, max = 9)
|
|
||||||
)]
|
|
||||||
first: u32,
|
first: u32,
|
||||||
#[config(
|
#[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
|
||||||
id = 2,
|
|
||||||
key = "local.second",
|
|
||||||
unit = "count",
|
|
||||||
default = 4,
|
|
||||||
range(min = 1, max = 9)
|
|
||||||
)]
|
|
||||||
second: u32,
|
second: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
#[config(post = Self::validate_order)]
|
#[config(post = Self::validate_order)]
|
||||||
struct OrderedSection {
|
struct OrderedSection {
|
||||||
#[config(
|
#[config(
|
||||||
id = 3,
|
id = 3,
|
||||||
key = "ordered.minimum",
|
key = "ordered.minimum",
|
||||||
unit = "count",
|
unit = "count",
|
||||||
default = 1,
|
|
||||||
range(min = 0, max = 9)
|
range(min = 0, max = 9)
|
||||||
)]
|
)]
|
||||||
minimum: u32,
|
minimum: u32,
|
||||||
@@ -39,7 +24,6 @@ struct OrderedSection {
|
|||||||
id = 4,
|
id = 4,
|
||||||
key = "ordered.maximum",
|
key = "ordered.maximum",
|
||||||
unit = "count",
|
unit = "count",
|
||||||
default = 2,
|
|
||||||
range(min = 0, max = 9)
|
range(min = 0, max = 9)
|
||||||
)]
|
)]
|
||||||
maximum: u32,
|
maximum: u32,
|
||||||
@@ -58,17 +42,51 @@ impl OrderedSection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct Aggregate {
|
struct Aggregate {
|
||||||
#[config(section)]
|
|
||||||
local: LocalSection,
|
local: LocalSection,
|
||||||
#[config(section)]
|
|
||||||
ordered: OrderedSection,
|
ordered: OrderedSection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct BoardOwnedSection {
|
||||||
|
#[config(
|
||||||
|
id = 10,
|
||||||
|
key = "board.endpoint",
|
||||||
|
unit = "id",
|
||||||
|
range(min = 1, max = 255)
|
||||||
|
)]
|
||||||
|
endpoint: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct BoardOwnedAggregate {
|
||||||
|
endpoint: BoardOwnedSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum TestMode {
|
||||||
|
Quiet = 1,
|
||||||
|
Loud = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct CompactSection {
|
||||||
|
#[config(id = 20, key = "compact.endpoint", unit = "id", range(min = 1))]
|
||||||
|
endpoint: u8,
|
||||||
|
#[config(enum, id = 21, key = "compact.mode", unit = "mode")]
|
||||||
|
mode: TestMode,
|
||||||
|
#[config(id = 22, key = "compact.port", unit = "id", range(min = 1))]
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_section_owns_every_declared_scalar_path() {
|
fn generated_section_owns_every_declared_scalar_path() {
|
||||||
let mut section = LocalSection::default_section().unwrap();
|
let mut section = LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
};
|
||||||
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
section.field_value(FieldId::new(2)),
|
section.field_value(FieldId::new(2)),
|
||||||
@@ -94,9 +112,67 @@ fn generated_section_owns_every_declared_scalar_path() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
|
||||||
|
let mut section = CompactSection {
|
||||||
|
endpoint: 7,
|
||||||
|
mode: TestMode::Quiet,
|
||||||
|
port: 4861,
|
||||||
|
};
|
||||||
|
assert_eq!(CompactSection::SNAPSHOT_VALUE_BYTES, 1 + 1 + 2);
|
||||||
|
assert_eq!(
|
||||||
|
section.field_value(FieldId::new(20)),
|
||||||
|
Some(ConfigValue::U8(7))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CompactSection::DESCRIPTORS[0].minimum,
|
||||||
|
Some(ConfigValue::U8(1))
|
||||||
|
);
|
||||||
|
assert_eq!(CompactSection::DESCRIPTORS[0].maximum, None);
|
||||||
|
assert_eq!(
|
||||||
|
CompactSection::DESCRIPTORS[1].enum_options,
|
||||||
|
TestMode::CONFIG_ENUM_OPTIONS
|
||||||
|
);
|
||||||
|
assert_eq!(TestMode::Quiet.wire_value(), 1);
|
||||||
|
assert_eq!(TestMode::try_from_wire(3), Some(TestMode::Loud));
|
||||||
|
assert_eq!(TestMode::try_from_wire(2), None);
|
||||||
|
|
||||||
|
section
|
||||||
|
.set_field(FieldId::new(21), ConfigValue::U8(3))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(section.mode, TestMode::Loud);
|
||||||
|
assert!(matches!(
|
||||||
|
section.set_field(FieldId::new(21), ConfigValue::U8(2)),
|
||||||
|
Err(FieldWriteError::Violation(issue))
|
||||||
|
if issue.rule() == ValidationRule::InvalidEnumValue
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
section.set_field(FieldId::new(20), ConfigValue::U8(0)),
|
||||||
|
Err(FieldWriteError::Violation(issue))
|
||||||
|
if issue.rule() == ValidationRule::BelowMinimum
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
section.field_value(FieldId::new(22)),
|
||||||
|
Some(ConfigValue::U16(4861))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CompactSection::DESCRIPTORS[2].kind,
|
||||||
|
kaptan_config::ValueKind::U16
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
||||||
let mut aggregate = Aggregate::default_aggregate().unwrap();
|
let mut aggregate = Aggregate {
|
||||||
|
local: LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
},
|
||||||
|
ordered: OrderedSection {
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
aggregate.field_value(FieldId::new(2)),
|
aggregate.field_value(FieldId::new(2)),
|
||||||
@@ -106,7 +182,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
|||||||
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
aggregate.validate_fields(),
|
aggregate.validate(),
|
||||||
Err(issue)
|
Err(issue)
|
||||||
if issue.primary() == Some(FieldId::new(3))
|
if issue.primary() == Some(FieldId::new(3))
|
||||||
&& issue.related_field() == Some(FieldId::new(4))
|
&& issue.related_field() == Some(FieldId::new(4))
|
||||||
@@ -115,5 +191,190 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
|||||||
aggregate
|
aggregate
|
||||||
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
aggregate.validate_fields().unwrap();
|
aggregate.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregate_derives_its_flattened_descriptor_order() {
|
||||||
|
assert_eq!(Aggregate::SNAPSHOT_VALUE_BYTES, 4 * 4);
|
||||||
|
let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT)
|
||||||
|
.map(|index| Aggregate::descriptor(index).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
descriptors
|
||||||
|
.iter()
|
||||||
|
.map(|descriptor| descriptor.id.get())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
assert!(Aggregate::descriptor(Aggregate::FIELD_COUNT).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn factory_values_are_owned_outside_the_generated_schema() {
|
||||||
|
let mut aggregate = BoardOwnedAggregate {
|
||||||
|
endpoint: BoardOwnedSection { endpoint: 42 },
|
||||||
|
};
|
||||||
|
assert_eq!(BoardOwnedAggregate::FIELD_COUNT, 1);
|
||||||
|
assert_eq!(
|
||||||
|
aggregate.field_value(FieldId::new(10)),
|
||||||
|
Some(ConfigValue::U32(42))
|
||||||
|
);
|
||||||
|
aggregate
|
||||||
|
.set_field(FieldId::new(10), ConfigValue::U32(43))
|
||||||
|
.unwrap();
|
||||||
|
aggregate.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DuplicateChildren {
|
||||||
|
first: LocalSection,
|
||||||
|
second: LocalSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct TransparentRoot {
|
||||||
|
local: LocalSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct FirstKeyOwner {
|
||||||
|
#[config(id = 30, key = "shared.key", unit = "count")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct SecondKeyOwner {
|
||||||
|
#[config(id = 31, key = "shared.key", unit = "count")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DuplicateKeysAcrossChildren {
|
||||||
|
first: FirstKeyOwner,
|
||||||
|
second: SecondKeyOwner,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_fingerprint_is_generated_and_one_child_wrappers_are_transparent() {
|
||||||
|
assert_eq!(TransparentRoot::SCHEMA_ID, LocalSection::SCHEMA_ID);
|
||||||
|
assert_ne!(Aggregate::SCHEMA_ID, LocalSection::SCHEMA_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recursive_tree_rejects_duplicate_field_ids() {
|
||||||
|
let duplicate = DuplicateChildren {
|
||||||
|
first: LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
},
|
||||||
|
second: LocalSection {
|
||||||
|
first: 2,
|
||||||
|
second: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
duplicate.validate(),
|
||||||
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recursive_tree_rejects_duplicate_display_keys() {
|
||||||
|
let duplicate = DuplicateKeysAcrossChildren {
|
||||||
|
first: FirstKeyOwner { value: 1 },
|
||||||
|
second: SecondKeyOwner { value: 2 },
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
duplicate.validate(),
|
||||||
|
Err(issue) if issue.rule() == ValidationRule::Inconsistent
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct FloatRules {
|
||||||
|
#[config(id = 1, key = "finite", unit = "", finite)]
|
||||||
|
finite: f32,
|
||||||
|
#[config(id = 2, key = "nonzero", unit = "", nonzero)]
|
||||||
|
nonzero: f64,
|
||||||
|
#[config(id = 3, key = "lower", unit = "", range(min = 0.0))]
|
||||||
|
lower: f32,
|
||||||
|
#[config(id = 4, key = "upper", unit = "", range(max = 1.0))]
|
||||||
|
upper: f64,
|
||||||
|
#[config(id = 5, key = "free", unit = "")]
|
||||||
|
free: f64,
|
||||||
|
#[config(id = 6, key = "nonzero32", unit = "", nonzero)]
|
||||||
|
nonzero32: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_rules_preserve_local_repair_and_float_domains() {
|
||||||
|
let mut values = FloatRules {
|
||||||
|
finite: f32::NAN,
|
||||||
|
nonzero: 0.0,
|
||||||
|
lower: f32::NAN,
|
||||||
|
upper: f64::NAN,
|
||||||
|
free: f64::NAN,
|
||||||
|
nonzero32: 0.0,
|
||||||
|
};
|
||||||
|
// Every other field is invalid. A local repair must still succeed.
|
||||||
|
values
|
||||||
|
.set_field(FieldId::new(1), ConfigValue::F32(1.0))
|
||||||
|
.unwrap();
|
||||||
|
for (id, value, rule) in [
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
ConfigValue::F32(f32::INFINITY),
|
||||||
|
ValidationRule::NonFinite,
|
||||||
|
),
|
||||||
|
(2, ConfigValue::F64(0.0), ValidationRule::Zero),
|
||||||
|
(2, ConfigValue::F64(-0.0), ValidationRule::Zero),
|
||||||
|
(3, ConfigValue::F32(f32::NAN), ValidationRule::Unordered),
|
||||||
|
(4, ConfigValue::F64(f64::NAN), ValidationRule::Unordered),
|
||||||
|
(6, ConfigValue::F32(0.0), ValidationRule::Zero),
|
||||||
|
(6, ConfigValue::F32(-0.0), ValidationRule::Zero),
|
||||||
|
] {
|
||||||
|
let id = FieldId::new(id);
|
||||||
|
let before = values.field_value(id);
|
||||||
|
assert_eq!(
|
||||||
|
values.set_field(id, value),
|
||||||
|
Err(FieldWriteError::Violation(ConfigViolation::field(id, rule)))
|
||||||
|
);
|
||||||
|
assert_eq!(values.field_value(id), before);
|
||||||
|
}
|
||||||
|
// Neither nonzero nor a one-sided range implies finite.
|
||||||
|
for (id, value) in [
|
||||||
|
(2, ConfigValue::F64(f64::from_bits(0xfff0_0000_0000_0042))),
|
||||||
|
(3, ConfigValue::F32(f32::INFINITY)),
|
||||||
|
(4, ConfigValue::F64(f64::NEG_INFINITY)),
|
||||||
|
(5, ConfigValue::F64(f64::from_bits(0x7ff8_0000_0000_1234))),
|
||||||
|
(6, ConfigValue::F32(f32::INFINITY)),
|
||||||
|
] {
|
||||||
|
values.set_field(FieldId::new(id), value).unwrap();
|
||||||
|
assert_eq!(values.field_value(FieldId::new(id)), Some(value));
|
||||||
|
}
|
||||||
|
values.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rule_metadata_changes_fingerprints() {
|
||||||
|
let descriptor = FloatRules::DESCRIPTORS[0];
|
||||||
|
assert!(descriptor.finite);
|
||||||
|
assert!(!descriptor.nonzero);
|
||||||
|
assert!(FloatRules::DESCRIPTORS[1].nonzero);
|
||||||
|
let original = kaptan_config::validation::__schema_id(&[descriptor]);
|
||||||
|
assert_ne!(
|
||||||
|
original,
|
||||||
|
kaptan_config::validation::__schema_id(&[kaptan_config::FieldDescriptor {
|
||||||
|
finite: false,
|
||||||
|
..descriptor
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
original,
|
||||||
|
kaptan_config::validation::__schema_id(&[kaptan_config::FieldDescriptor {
|
||||||
|
nonzero: true,
|
||||||
|
..descriptor
|
||||||
|
}])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum ImplicitDiscriminant {
|
||||||
|
First = 1,
|
||||||
|
Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum variants require explicit u8 discriminants
|
||||||
|
--> tests/ui/config_enum_implicit_discriminant.rs:7:5
|
||||||
|
|
|
||||||
|
7 | Second,
|
||||||
|
| ^^^^^^
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum LabelWithNul {
|
||||||
|
#[config_enum(label = "invalid\0label")]
|
||||||
|
Value = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum display labels must be non-empty text without NUL bytes
|
||||||
|
--> tests/ui/config_enum_label_nul.rs:6:27
|
||||||
|
|
|
||||||
|
6 | #[config_enum(label = "invalid\0label")]
|
||||||
|
| ^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum LabelTooManyBytes {
|
||||||
|
#[config_enum(label = "şşşşşşşşşşşşşşşşş")]
|
||||||
|
Value = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum display label exceeds the 32-byte wire limit
|
||||||
|
--> tests/ui/config_enum_label_too_many_bytes.rs:6:27
|
||||||
|
|
|
||||||
|
6 | #[config_enum(label = "şşşşşşşşşşşşşşşşş")]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
enum MissingRepresentation {
|
||||||
|
First = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum requires exactly #[repr(u8)]
|
||||||
|
--> tests/ui/config_enum_missing_repr.rs:4:6
|
||||||
|
|
|
||||||
|
4 | enum MissingRepresentation {
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::ConfigEnum;
|
||||||
|
|
||||||
|
#[derive(ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Payload {
|
||||||
|
Value(u8) = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: ConfigEnum variants cannot contain fields
|
||||||
|
--> tests/ui/config_enum_payload.rs:6:10
|
||||||
|
|
|
||||||
|
6 | Value(u8) = 1,
|
||||||
|
| ^^^^
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
|
||||||
struct DefaultBelowMinimum {
|
|
||||||
#[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
|
|
||||||
value: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
error: configuration default is below its declared minimum
|
|
||||||
--> tests/ui/default_below_minimum.rs:5:63
|
|
||||||
|
|
|
||||||
5 | #[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
|
|
||||||
| ^
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
|
||||||
struct NonFiniteDefault {
|
|
||||||
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
|
||||||
value: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
error: configuration default violates finite
|
|
||||||
--> tests/ui/default_violates_finite.rs:5:63
|
|
||||||
|
|
|
||||||
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
|
||||||
| ^^^^^
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
|
||||||
struct ZeroDefault {
|
|
||||||
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
|
||||||
value: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
error: configuration default violates nonzero
|
|
||||||
--> tests/ui/default_violates_nonzero.rs:5:63
|
|
||||||
|
|
|
||||||
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
|
||||||
| ^
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DisplayKeyTooLong {
|
||||||
|
#[config(
|
||||||
|
id = 1,
|
||||||
|
key = "configuration.display.key.with.more.than.forty.eight.bytes",
|
||||||
|
unit = "count"
|
||||||
|
)]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration display key exceeds the 48-byte wire limit
|
||||||
|
--> tests/ui/display_key_too_long.rs:7:15
|
||||||
|
|
|
||||||
|
7 | key = "configuration.display.key.with.more.than.forty.eight.bytes",
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct DuplicateDisplayKey {
|
||||||
|
#[config(id = 1, key = "duplicate.key", unit = "count")]
|
||||||
|
first: u32,
|
||||||
|
#[config(id = 2, key = "duplicate.key", unit = "count")]
|
||||||
|
second: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: duplicate configuration display key
|
||||||
|
--> tests/ui/duplicate_display_key.rs:7:28
|
||||||
|
|
|
||||||
|
7 | #[config(id = 2, key = "duplicate.key", unit = "count")]
|
||||||
|
| ^^^^^^^^^^^^^^^
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct DuplicateId {
|
struct DuplicateId {
|
||||||
#[config(id = 7, nonzero)]
|
#[config(id = 7, nonzero)]
|
||||||
first: u32,
|
first: u32,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct DuplicateRange {
|
struct DuplicateRange {
|
||||||
#[config(id = 7, range(min = 1), range(min = 2))]
|
#[config(id = 7, range(min = 1), range(min = 2))]
|
||||||
value: u32,
|
value: u32,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct Empty {}
|
struct Empty {}
|
||||||
|
|
||||||
fn main() {}
|
fn main() {}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
error: ConfigValidate requires at least one configured field or section
|
error: Config requires at least one field
|
||||||
--> tests/ui/empty.rs:4:8
|
--> tests/ui/empty.rs:4:8
|
||||||
|
|
|
|
||||||
4 | struct Empty {}
|
4 | struct Empty {}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct PrimitiveEnumMarker {
|
||||||
|
#[config(enum, id = 1, key = "mode", unit = "mode")]
|
||||||
|
mode: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: config(enum) requires a ConfigEnum-derived enum type
|
||||||
|
--> tests/ui/enum_marker_on_primitive.rs:6:11
|
||||||
|
|
|
||||||
|
6 | mode: u8,
|
||||||
|
| ^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct InvertedNativeBounds {
|
||||||
|
#[config(id = 1, key = "value", unit = "", range(min = 1.0000000596046448, max = 1.0))]
|
||||||
|
value: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration range minimum exceeds maximum
|
||||||
|
--> tests/ui/f32_double_rounded_bounds.rs:6:5
|
||||||
|
|
|
||||||
|
6 | value: f32,
|
||||||
|
| ^^^^^
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use kaptan_config_derive::{Config, ConfigEnum};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Mode {
|
||||||
|
Safe = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct MissingEnumMarker {
|
||||||
|
#[config(id = 1, key = "mode", unit = "mode")]
|
||||||
|
mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: non-primitive configuration fields require config(enum)
|
||||||
|
--> tests/ui/missing_enum_marker.rs:12:11
|
||||||
|
|
|
||||||
|
12 | mode: Mode,
|
||||||
|
| ^^^^
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct MissingId {
|
struct MissingId {
|
||||||
#[config(nonzero)]
|
#[config(nonzero)]
|
||||||
value: u32,
|
value: u32,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
error: validated configuration fields require config(id = N)
|
error: scalar configuration fields require config(id = N)
|
||||||
--> tests/ui/missing_id.rs:6:5
|
--> tests/ui/missing_id.rs:6:5
|
||||||
|
|
|
|
||||||
6 | value: u32,
|
6 | value: u32,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct MissingMetadata {
|
struct MissingMetadata {
|
||||||
#[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))]
|
#[config(id = 7, unit = "count", range(min = 0, max = 10))]
|
||||||
value: u32,
|
value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct MixedSectionAndScalar {
|
struct MixedSectionAndScalar {
|
||||||
#[config(section)]
|
|
||||||
child: u32,
|
child: u32,
|
||||||
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
|
#[config(id = 1, key = "scalar", unit = "count", range(min = 0, max = 2))]
|
||||||
scalar: u32,
|
scalar: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
|
error: Config records must contain either scalar fields or child configuration records exclusively
|
||||||
--> tests/ui/mixed_section_scalar.rs:6:5
|
--> tests/ui/mixed_section_scalar.rs:5:5
|
||||||
|
|
|
|
||||||
6 | child: u32,
|
5 | child: u32,
|
||||||
| ^^^^^
|
| ^^^^^
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct NonzeroBool {
|
||||||
|
#[config(id = 1, key = "value", unit = "")]
|
||||||
|
#[config(nonzero)]
|
||||||
|
value: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct RangedBool {
|
||||||
|
#[config(id = 1, key = "value", unit = "")]
|
||||||
|
#[config(range(min = 0, max = 1))]
|
||||||
|
value: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct FiniteBool {
|
||||||
|
#[config(id = 1, key = "value", unit = "")]
|
||||||
|
#[config(finite)]
|
||||||
|
value: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
error: nonzero and range require a supported primitive numeric field
|
||||||
|
--> tests/ui/numeric_rules_on_bool.rs:6:14
|
||||||
|
|
|
||||||
|
6 | #[config(nonzero)]
|
||||||
|
| ^^^^^^^
|
||||||
|
|
||||||
|
error: nonzero and range require a supported primitive numeric field
|
||||||
|
--> tests/ui/numeric_rules_on_bool.rs:13:14
|
||||||
|
|
|
||||||
|
13 | #[config(range(min = 0, max = 1))]
|
||||||
|
| ^^^^^
|
||||||
|
|
||||||
|
error: finite requires an f32 or f64 field
|
||||||
|
--> tests/ui/numeric_rules_on_bool.rs:20:14
|
||||||
|
|
|
||||||
|
20 | #[config(finite)]
|
||||||
|
| ^^^^^^
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use kaptan_config_derive::{Config, ConfigEnum};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, ConfigEnum)]
|
||||||
|
#[repr(u8)]
|
||||||
|
enum Mode {
|
||||||
|
Off = 0,
|
||||||
|
On = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct NonzeroEnum {
|
||||||
|
#[config(enum, id = 1, key = "value", unit = "")]
|
||||||
|
#[config(nonzero)]
|
||||||
|
value: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct RangedEnum {
|
||||||
|
#[config(enum, id = 1, key = "value", unit = "")]
|
||||||
|
#[config(range(min = 0, max = 1))]
|
||||||
|
value: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct FiniteEnum {
|
||||||
|
#[config(enum, id = 1, key = "value", unit = "")]
|
||||||
|
#[config(finite)]
|
||||||
|
value: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
error: nonzero and range require a supported primitive numeric field
|
||||||
|
--> tests/ui/numeric_rules_on_enum.rs:13:14
|
||||||
|
|
|
||||||
|
13 | #[config(nonzero)]
|
||||||
|
| ^^^^^^^
|
||||||
|
|
||||||
|
error: nonzero and range require a supported primitive numeric field
|
||||||
|
--> tests/ui/numeric_rules_on_enum.rs:20:14
|
||||||
|
|
|
||||||
|
20 | #[config(range(min = 0, max = 1))]
|
||||||
|
| ^^^^^
|
||||||
|
|
||||||
|
error: finite requires an f32 or f64 field
|
||||||
|
--> tests/ui/numeric_rules_on_enum.rs:27:14
|
||||||
|
|
|
||||||
|
27 | #[config(finite)]
|
||||||
|
| ^^^^^^
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use kaptan_config_derive::ConfigValidate;
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
#[derive(ConfigValidate)]
|
#[derive(Config)]
|
||||||
struct InvalidRange {
|
struct InvalidRange {
|
||||||
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
|
#[config(id = 1, key = "value", unit = "count", range(min = 3, max = 1))]
|
||||||
value: u32,
|
value: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct UnitTooLong {
|
||||||
|
#[config(id = 1, key = "value", unit = "seventeen_bytes___")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: configuration unit exceeds the 16-byte wire limit
|
||||||
|
--> tests/ui/unit_too_long.rs:5:44
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "seventeen_bytes___")]
|
||||||
|
| ^^^^^^^^^^^^^^^^^^^^
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
struct UnsupportedDefault {
|
||||||
|
#[config(id = 1, key = "value", unit = "count", default = 1)]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: supported field config rules are id, key, unit, enum, finite, nonzero, and range
|
||||||
|
--> tests/ui/unsupported_default.rs:5:53
|
||||||
|
|
|
||||||
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1)]
|
||||||
|
| ^^^^^^^
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use kaptan_config_derive::Config;
|
||||||
|
|
||||||
|
#[derive(Config)]
|
||||||
|
#[config(external_defaults)]
|
||||||
|
struct UnsupportedExternalDefaults {
|
||||||
|
#[config(id = 1, key = "value", unit = "count")]
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: struct config options are crate = path and post = path
|
||||||
|
--> tests/ui/unsupported_external_defaults.rs:4:10
|
||||||
|
|
|
||||||
|
4 | #[config(external_defaults)]
|
||||||
|
| ^^^^^^^^^^^^^^^^^
|
||||||
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user