Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e15063502 |
No files matched your search
@@ -1,41 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
- 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.
|
||||
@@ -1,88 +0,0 @@
|
||||
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
|
||||
+7
-21
@@ -1,26 +1,12 @@
|
||||
SHELL := /bin/sh
|
||||
MAKEFLAGS += --no-print-directory
|
||||
MDBOOK ?= mdbook
|
||||
|
||||
.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/.'
|
||||
.PHONY: build serve test
|
||||
|
||||
build:
|
||||
mdbook build
|
||||
$(MDBOOK) build
|
||||
|
||||
serve:
|
||||
$(MDBOOK) serve --open
|
||||
|
||||
test:
|
||||
mdbook test
|
||||
|
||||
check: test build
|
||||
|
||||
clean:
|
||||
mdbook clean
|
||||
$(MDBOOK) test
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[book]
|
||||
title = "Kaptan documentation"
|
||||
description = "Architecture, board bring-up, and contributor guidance for Kaptan."
|
||||
description = "Architecture, board bring-up, simulation, and contributor guidance for Kaptan."
|
||||
authors = ["Kaptan contributors"]
|
||||
language = "en"
|
||||
src = "src"
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
- [Configuration](configuration.md)
|
||||
- [Logs](logs.md)
|
||||
- [Errors](errors.md)
|
||||
- [Ground Suite](ground-suite.md)
|
||||
- [Boards](boards/index.md)
|
||||
- [Kakute F7 Mini V3](boards/kakute-f7-mini-v3.md)
|
||||
- [GEPRC GEP-F722-HD V2](boards/geprc-f722-v2.md)
|
||||
- [Host SITL](sitl.md)
|
||||
- [Reference](reference.md)
|
||||
+56
-166
@@ -10,182 +10,73 @@ board declaration
|
||||
F722 platform / RTIC application ──► data channel ──► MAVLink telemetry
|
||||
│
|
||||
├── physical sensor drivers
|
||||
└── startup motor-pin inhibition
|
||||
└── motor-pin inhibit interlock
|
||||
|
||||
Portable flight libraries (not connected to physical actuators)
|
||||
FlightEndpoint ──► Navigation / control / phases / safety
|
||||
│
|
||||
└── caller-owned synchronous output sink
|
||||
portable libraries
|
||||
math ─► navigation ─► control ─► safety
|
||||
```
|
||||
|
||||
Each F722 board combines `F722UserConfig` with an immutable hardware manifest.
|
||||
The manifest selects the IMU route, driver settings, mount, serial inventory,
|
||||
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 board contract selects one immutable `BoardProfile`. The checked-in
|
||||
profile is `TelemetryOnly`; `ExternalControlOnly` is represented explicitly
|
||||
but rejected at validation time because it has no reviewed runtime.
|
||||
|
||||
`F722ImmutableConfig<R>` retains the IMU route type through initialization and
|
||||
interrupt acknowledgement. The sealed `Mpu6000Spi1` and `Icm42688Spi3` routes
|
||||
select IMU wiring and backend independently of serial inventory and LED wiring.
|
||||
The data channel owns rate selection, mailbox consumption, message encoding,
|
||||
and bounded TX classification. Board code supplies physical routes and
|
||||
telemetry configuration; it does not receive generic output authority.
|
||||
|
||||
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:
|
||||
Configuration and runtime observability are separate, one-way planes:
|
||||
|
||||
```text
|
||||
FILE_CONTROL / FILE_DATA
|
||||
│
|
||||
Kaptan GCS custom MAVLink V2 messages
|
||||
│ (future: verified signing, source binding, replay state)
|
||||
▼
|
||||
kaptan-file-mavlink ──► virtual FileProvider
|
||||
kaptan-config-mavlink ──► state-only ConfigProtocol
|
||||
│ borrows
|
||||
kaptan-flight-runtime
|
||||
├── one ConfigManager ──► kaptan-config ──► flight schema/CBOR
|
||||
└── one kaptan-log hub
|
||||
│
|
||||
KCFG importer/exporter
|
||||
kaptan-log-mavlink payload adapter
|
||||
│
|
||||
ConfigManager ──► private store
|
||||
|
||||
kaptan-log / kaptan-err-reportable
|
||||
│
|
||||
▼
|
||||
kaptan-diagnostics-mavlink ──► per-link encoder / bounded TX
|
||||
platform-owned sole encoder / DMA TX link
|
||||
```
|
||||
|
||||
The Kaptan-native file dialect owns bounded transfers and retries across links.
|
||||
Its provider maps saved, active and factory-reset endpoints to streaming KCFG v3
|
||||
and the generic configuration lifecycle.
|
||||
The generated dialect is intentionally custom rather than a `PARAM_*` or QGC
|
||||
adapter. The core uses numeric field IDs and typed scalar values; descriptor
|
||||
text is metadata, not a wire identity. Discovery/value reads, bounded
|
||||
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.
|
||||
|
||||
`ConfigManager` owns boot-active values, known committed values or unknown durable
|
||||
state, and the factory candidate. F722 supplies its UART-disabled setup fallback
|
||||
through `recover_with`. Readable invalid settings remain inspectable for USB
|
||||
repair. Saving or factory reset never changes active consumers until reboot.
|
||||
One current CBOR profile format is a bounded complete snapshot. There is no
|
||||
migration reader: any other format is rejected. SITL supplies the host file
|
||||
adapter and persists a nonzero generation alongside its raw profile payload.
|
||||
Motor allocation/mixer topology is immutable compiled
|
||||
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.
|
||||
|
||||
Target-owned stores use private persistence formats. F722 alternates compact
|
||||
`KCSP` snapshots between two erase sectors, retaining only the old and replacement
|
||||
records across the commit boundary. That format is not exposed as KCFG. Motor
|
||||
allocation/mixer topology remains compiled vehicle capability data.
|
||||
The MAVLink dialect root includes `kaptan/config.xml`, `kaptan/log.xml`,
|
||||
`kaptan/error.xml`, and `kaptan/shutdown.xml`.
|
||||
The build watches every fragment and generates one `message::kaptan` module;
|
||||
fragments are never separately generated Rust modules. `kaptan-log` itself has
|
||||
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.
|
||||
|
||||
## I/O ownership
|
||||
|
||||
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)
|
||||
See [Configuration](configuration.md) for the deployment matrix and GCS upload
|
||||
contract, [Logs](logs.md) for the receiver contract, and [Errors](errors.md)
|
||||
for derived-error and failure-reporting scope.
|
||||
|
||||
## Ownership rules
|
||||
@@ -193,8 +84,7 @@ for derived-error and failure-reporting scope.
|
||||
- A peripheral owner is local to the platform runtime.
|
||||
- Portable crates use typed units and validated values rather than raw wire or
|
||||
timer representations.
|
||||
- `SafetySupervisor::evaluate_and_commit` checks current evidence at the
|
||||
synchronous enabled-output boundary. No transferable permit escapes it.
|
||||
- Rejected numerical updates do not partially commit computational candidates.
|
||||
Fault latches, inhibition and ingress replay watermarks follow their explicit
|
||||
failure contracts rather than rolling back authority or replay history.
|
||||
- A safety permit is epoch-bound and must be revalidated at the only nonzero
|
||||
output boundary.
|
||||
- A failed observation or numerical check does not partially commit estimator,
|
||||
controller, allocator, or safety state.
|
||||
@@ -1,66 +1,21 @@
|
||||
# GEPRC GEP-F722-HD V2
|
||||
|
||||
The GEPRC GEP-F722-HD V2 uses the ICM-42688-P on SPI3.
|
||||
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`.
|
||||
The GEPRC GEP-F722-HD V2 board declaration uses the ICM-42688-P on SPI3 and
|
||||
MAVLink telemetry on USART2. It declares no vertical sensor stream.
|
||||
|
||||
The board declares its yaw-180° IMU mount, UART catalog, active-high PA13 LED
|
||||
and no I²C range sensor.
|
||||
Before fallible initialization, the platform holds the four documented motor
|
||||
pins PB0/PB1/PB4/PB5 low.
|
||||
Its immutable board policy selects the ICM-42688-P yaw-180° mount, logical
|
||||
MAVLink `Serial2`, endpoint `1/1`, 115200 baud, the documented telemetry
|
||||
schedule, and an `INFO` optional-log threshold. The platform verifies these
|
||||
values against the checked SPI3/USART2 typestate route before it enables
|
||||
peripherals. They are compiled image policy, not ground-station parameters.
|
||||
|
||||
## Build and package
|
||||
|
||||
From the repository root:
|
||||
Build and package from the firmware workspace:
|
||||
|
||||
```sh
|
||||
make -C firmware/boards/geprc-f722-v2 build
|
||||
cd firmware
|
||||
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 packaging script validates the ELF against the shared F722 linker layout
|
||||
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.
|
||||
The resulting image is telemetry-only and has the shared physical motor-pin
|
||||
inhibit boundary.
|
||||
@@ -3,20 +3,10 @@
|
||||
Board pages describe supported hardware topology, build products, and safe
|
||||
verification. They do not declare a board flight-ready.
|
||||
|
||||
| Board | IMU route | Serial inventory | Factory UART roles |
|
||||
| Board | MCU family | Primary IMU | MAVLink UART |
|
||||
| --- | --- | --- | --- |
|
||||
| [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) | `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.
|
||||
| [Kakute F7 Mini V3](kakute-f7-mini-v3.md) | STM32F722 | MPU-6000 | USART1 |
|
||||
| [GEPRC GEP-F722-HD V2](geprc-f722-v2.md) | STM32F722 | ICM-42688-P | USART2 |
|
||||
|
||||
All board images share the motor-pin inhibit boundary described in
|
||||
[Safety boundary](../safety-boundary.md).
|
||||
@@ -1,62 +1,43 @@
|
||||
# Kakute F7 Mini V3
|
||||
|
||||
The Holybro Kakute F7 Mini V3 uses the MPU-6000 on SPI1.
|
||||
The Holybro Kakute F7 Mini V3 board declaration 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
|
||||
`firmware/boards/kakute-f7-mini-v3/datasheet.pdf`.
|
||||
|
||||
The board maps MPU axes `(X, Y, Z)` to `(-Y, X, Z)`, matching the manufacturer's
|
||||
`KAKUTEF7MINIV3` target, and supplies the driver settings. It declares the UART
|
||||
catalog, active-low PD2 LED and no I²C range sensor.
|
||||
Before fallible initialization, the platform holds all six documented
|
||||
motor pins PB0/PB1/PB4/PB5/PB6/PB7 low.
|
||||
Its immutable board policy selects the MPU-6000 body-aligned mount, logical
|
||||
MAVLink `Serial1`, endpoint `1/1`, 115200 baud, the documented telemetry
|
||||
schedule, and an `INFO` optional-log threshold. The platform verifies these
|
||||
values against the checked SPI1/USART1/MTF01 typestate route before it enables
|
||||
peripherals. They are compiled image policy, not ground-station parameters.
|
||||
|
||||
## Build and package
|
||||
|
||||
From the repository root:
|
||||
From the firmware workspace:
|
||||
|
||||
```sh
|
||||
make -C firmware/boards/kakute-f7-mini-v3 build
|
||||
cd firmware
|
||||
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 uses the shared F722 linker layout and prints the flash
|
||||
and RAM report before producing the raw binary.
|
||||
The packaging script derives the matching binary and linker layout, then
|
||||
prints the flash and RAM report.
|
||||
|
||||
## 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.
|
||||
2. Hold the board's `Boot` button.
|
||||
3. Connect USB, then release `Boot` once the STM32 DFU device enumerates.
|
||||
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:
|
||||
4. Flash the generated image:
|
||||
|
||||
```sh
|
||||
dfu-util --list
|
||||
make -C firmware/boards/kakute-f7-mini-v3 flash
|
||||
dfu-util --alt 0 --dfuse-address 0x08000000:leave --download target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3.bin
|
||||
```
|
||||
|
||||
## MAVLink over USB
|
||||
## Telemetry wiring
|
||||
|
||||
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:
|
||||
Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground:
|
||||
|
||||
| Adapter | Flight controller |
|
||||
| --- | --- |
|
||||
@@ -64,9 +45,4 @@ a 3.3 V USB-UART adapter with crossed data lines and common ground:
|
||||
| `TX` | `R1` |
|
||||
| `GND` | `G` |
|
||||
|
||||
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.
|
||||
This wiring is telemetry only. It does not enable motor control.
|
||||
+78
-280
@@ -1,304 +1,102 @@
|
||||
# Configuration
|
||||
|
||||
`kaptan-config` owns typed fields, validation and the committed-versus-active
|
||||
lifecycle. The F722 platform supplies the shared user schema and factory values.
|
||||
Boards declare their UART catalog and immutable hardware context. The
|
||||
configurator renders target metadata without a board or parameter catalog.
|
||||
Kaptan has one generic configuration lifecycle crate, `kaptan-config`. It
|
||||
owns descriptors, typed writes, transactions, validation, generations, and
|
||||
stores. It does not grow a crate per subsystem. Flight-specific values live in
|
||||
the single `kaptan-flight-config` candidate; board electrical topology remains
|
||||
in the immutable board declaration.
|
||||
|
||||
## Flight configuration
|
||||
## Current deployment matrix
|
||||
|
||||
`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 |
|
||||
| Deployment | Configuration consumed today | Remote writes |
|
||||
| --- | --- | --- |
|
||||
| 41 | `control.attitude_roll_pitch_gain_per_s` | Roll/pitch attitude gain in 1/s. |
|
||||
| 42 | `control.attitude_yaw_gain_per_s` | Yaw attitude gain in 1/s. |
|
||||
| 43 | `control.yaw_rate_pid.proportional` | Yaw rate proportional gain. |
|
||||
| 44 | `control.yaw_rate_pid.integral` | Yaw rate integral gain. |
|
||||
| `kaptan-sitl serve --config FILE.cbor` | One validated Quad-X profile at process construction. | No live MAVLink endpoint is attached yet. |
|
||||
| `kaptan-sitl serve` | The validated factory Quad-X profile. | No persistent store; commits are rejected. |
|
||||
| STM32F722 telemetry image | Immutable compiled `BoardConfig` plus checked typestate routes. | Disabled: all inbound MAVLink remains discarded. |
|
||||
|
||||
Roll/pitch rate gains use the rate-PID fields. Derivative and contribution limits
|
||||
are shared.
|
||||
The current SITL profile has only settings consumed by its estimator,
|
||||
controller, safety supervisor, allocator envelope, or log hub:
|
||||
|
||||
Use the generated `kaptan-flight-config::SCHEMA_ID` from the source/build being
|
||||
used. Schema identity describes the field contract; it does not qualify a
|
||||
physical vehicle or identify calibrated values.
|
||||
- prearm dwell;
|
||||
- IMU, local-aiding, and command freshness/timing;
|
||||
- position/velocity aiding variance;
|
||||
- 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 test fixture has a 10-ms control period, 20-ms control deadline, 250-ms
|
||||
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.
|
||||
The fixed Quad-X mixer is intentionally not configurable. It is reviewed
|
||||
capability data, not an operator-editable array.
|
||||
|
||||
Navigation owns `MAXIMUM_FREE_ACCELERATION_MPS2 = 25`. Complete-profile
|
||||
validation checks maximum collective thrust `T_max <= m * (25 - g)` using `f64`
|
||||
arithmetic, and requires thrust above weight. The bound applies to free flight,
|
||||
not contact impulses. Mixer and installation remain compiled capabilities.
|
||||
Each editable field declares its ID, display key, unit, default, scalar type,
|
||||
and local validation in one `#[config(...)]` attribute. The derive generates
|
||||
the descriptor registry and local validation from that declaration; the only
|
||||
handwritten checks are whole-profile physical relationships such as thrust
|
||||
margin, timing ordering, and actuator limits.
|
||||
|
||||
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.
|
||||
## Immutable F722 board policy
|
||||
|
||||
## Web configurator
|
||||
The checked-in F722 boards consume these compiled `BoardConfig` settings before
|
||||
peripheral initialization:
|
||||
|
||||
Build and flash the board image, boot normally, connect the board's USB port,
|
||||
and run from the repository root:
|
||||
- IMU model and its fixed body mount (`BodyAligned` or `Yaw180`);
|
||||
- logical MAVLink port, endpoint IDs, and baud rate;
|
||||
- vertical-sensor capability and telemetry schedule; and
|
||||
- maximum optional MAVLink log severity.
|
||||
|
||||
```sh
|
||||
make configurator
|
||||
```
|
||||
The platform compares that metadata with the selected `F722BoardRoutes` before
|
||||
claiming pins, UARTs, DMA, or IRQs. A board declaration therefore cannot claim
|
||||
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.
|
||||
|
||||
Open `http://127.0.0.1:4861`, enter the device path in the Connection section,
|
||||
and select **Connect serial**. On Linux the application device normally appears
|
||||
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.
|
||||
F722’s log threshold is immutable board policy. It is deliberately separate
|
||||
from the SITL profile’s `observability.maximum_log_level`: the F722 image does
|
||||
not construct a runtime `ConfigManager` or profile store.
|
||||
|
||||
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.
|
||||
## One CBOR profile 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.
|
||||
There is one unreleased raw profile blob format:
|
||||
|
||||
A stale baseline returns `REQUEST_CONFLICT` without writing flash. The tool
|
||||
discards that draft. Disconnect and reconnect to reload before editing again.
|
||||
If saved state is unknown, ordinary saves are blocked. Reconnect to inspect it,
|
||||
then use factory reset or reboot recovery as described below.
|
||||
- it is a CBOR map keyed by the discovered numeric `FieldId`s;
|
||||
- key `0` is `profile_format = 1` and reserved key `65535` is the 16-byte
|
||||
`schema_id = "KAPTANPROFILE-01"`;
|
||||
- the complete blob is at most 4 KiB; and
|
||||
- malformed, trailing, wrong-format, non-finite, out-of-range, cross-field,
|
||||
and capability-invalid values are rejected.
|
||||
|
||||
A changed save or explicit factory reset synchronously erases a 128-KiB journal
|
||||
sector. Flash activity can interrupt telemetry and sensor servicing. The host
|
||||
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.
|
||||
The map is exact: it has those 26 keys once each. Unknown, duplicate, missing,
|
||||
or indefinite-map keys are rejected. That keeps the unreleased file contract
|
||||
unambiguous instead of silently accepting undeclared extensions.
|
||||
|
||||
See [MAVLink](mavlink.md) and [Logs](logs.md) for the packet and runtime-log views.
|
||||
There is no migration path because no version has shipped. The raw blob is the
|
||||
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.
|
||||
|
||||
## Board ownership
|
||||
## Future GCS upload contract
|
||||
|
||||
Each F722 board selects `F722UserConfig<Catalog, N>`. The shared factory values
|
||||
leave every configurable UART disabled. USB always carries Kaptan MAVLink and
|
||||
has no configurable role. Board catalogs determine which UART fields exist.
|
||||
The custom dialect is prepared for a first-party GCS without borrowing
|
||||
`PARAM_*` semantics. A client will:
|
||||
|
||||
Both current boards expose MAVLink identity, publication rates, log filtering,
|
||||
and `ports` fields from `kaptan-ports`. Range and optical-flow rates are present
|
||||
on both boards because either can assign an inventoried UART to MTF01. Their
|
||||
streams are enabled only when the selected sensor service supplies them.
|
||||
1. send `KAPTAN_CONFIG_INFO_REQUEST` and require the exact protocol, profile
|
||||
format, and schema ID in the correlated `KAPTAN_CONFIG_INFO` reply;
|
||||
2. request descriptors and values as needed;
|
||||
3. open a transaction with `KAPTAN_CONFIG_BEGIN`;
|
||||
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.
|
||||
|
||||
The adjacent `IMMUTABLE_CONFIG` manifest holds:
|
||||
An accepted import begin returns a server-issued transfer ID. Every chunk and
|
||||
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.
|
||||
|
||||
- the IMU route, buses and interrupt wiring
|
||||
- sensor mount and driver setup
|
||||
- the available serial inventory, status LED and optional I²C range route
|
||||
- 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.
|
||||
This is protocol groundwork, not a claim of active aircraft configuration:
|
||||
F722 needs authenticated ingress, source binding and replay protection,
|
||||
journalled target storage, a disarmed/inhibited activation permit, and atomic
|
||||
consumer rebuild/reset before it may accept any configuration command.
|
||||
+32
-119
@@ -1,133 +1,46 @@
|
||||
# Development and verification
|
||||
|
||||
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:
|
||||
## Fast host checks
|
||||
|
||||
```sh
|
||||
nix develop -c sh -c 'cd firmware && cargo fetch --locked'
|
||||
nix develop -c rustup toolchain install nightly-2025-08-01 \
|
||||
--profile minimal --component llvm-tools-preview
|
||||
nix develop -c make verification-tools
|
||||
nix develop -c make verification
|
||||
cd firmware
|
||||
cargo test --workspace --offline
|
||||
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
|
||||
cargo clippy -p platform-stm32f7 --all-targets --offline -- -D warnings
|
||||
cargo fmt --check
|
||||
git diff --check
|
||||
```
|
||||
|
||||
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:
|
||||
## Target checks
|
||||
|
||||
```sh
|
||||
nix develop -c python3 firmware/scripts/verify.py all --output /tmp/kaptan-review
|
||||
cd firmware
|
||||
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
|
||||
```
|
||||
|
||||
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:
|
||||
## Strict core coverage
|
||||
|
||||
```sh
|
||||
nix develop .#ground-suite
|
||||
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
|
||||
cd firmware
|
||||
scripts/report-test-coverage.sh
|
||||
```
|
||||
|
||||
The app currently has no widget-test suite. Windows and macOS need their native
|
||||
build and runtime checks.
|
||||
The strict gate requires 100% line and branch coverage for `kaptan-math`,
|
||||
`kaptan-navigation`, `kaptan-control`, and `kaptan-safety`. It isolates each
|
||||
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.
|
||||
+34
-89
@@ -1,107 +1,52 @@
|
||||
# Errors
|
||||
|
||||
Public reportable Kaptan error enums derive `KaptanError`:
|
||||
Public Kaptan error enums derive the pure `KaptanError` implementation:
|
||||
|
||||
```rust,ignore
|
||||
#[derive(Debug, kaptan_err_reportable::KaptanError)]
|
||||
```rust
|
||||
#[derive(Debug, kaptan_log::KaptanError)]
|
||||
enum SensorError {
|
||||
#[kaptan_error(id = 12345)]
|
||||
TransferFault,
|
||||
#[kaptan_error(id = 12346)]
|
||||
UnexpectedIdentity(u8),
|
||||
}
|
||||
|
||||
#[kaptan_err_reportable::propagate]
|
||||
fn service_sensor() -> Result<(), SensorError> {
|
||||
read_sensor()?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
The derive implements `core::error::Error`, `Display`, and `ReportableError`. It
|
||||
requires a nonzero explicit ID on every variant and retains no type, variant,
|
||||
or module string. `Display` renders the decimal ID. Renaming, moving, or
|
||||
reordering a Rust variant therefore does not change its receiver contract.
|
||||
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.
|
||||
The derive implements `core::error::Error`, `Display`, and `ErrorReport`. It
|
||||
creates a static `module::Type::Variant` error path without allocating,
|
||||
publishing telemetry, exposing generic source values, or changing safety
|
||||
state. Renaming a module, type, or variant changes that path; it is not a
|
||||
stable machine protocol identity.
|
||||
|
||||
The [final-link fixture](../../firmware/libs/kaptan-err-reportable-macros/tests/final_link.md)
|
||||
checks Rust 1.90.0 release fat-LTO and native Linux static-library links.
|
||||
Run it with `make -C firmware native-test`. Each run retains its results and
|
||||
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.
|
||||
Normal-task code may use `log::error!("{error}")`; it is routed as a bounded
|
||||
`KAPTAN_ERROR`. ISR-adjacent flight paths may enqueue the derived static
|
||||
message through their existing bounded mailbox. Neither route is a replacement
|
||||
for safety state, actuator inhibit, or typed configuration results.
|
||||
|
||||
## What the receiver sees
|
||||
|
||||
- A rejected KCFG upload receives an authoritative `FILE_CONTROL`
|
||||
status and bounded readable detail. Invalid user content is not a firmware
|
||||
error. A durable-storage failure additionally emits one stable
|
||||
`PersistConfigurationError` ID that distinguishes journal geometry, model,
|
||||
sequence, flash-controller, bounds, unlock, and verification
|
||||
failures. Replaying the cached close result does not repeat the report or
|
||||
flash operation. Any persistence error makes committed state unknown and
|
||||
blocks ordinary saves until reboot recovery or a successful explicit factory
|
||||
reset. Boot-active values remain unchanged.
|
||||
- A stale KCFG expected base returns `REQUEST_CONFLICT` before persistence.
|
||||
It is a reload-required editing conflict, not a reportable firmware error.
|
||||
Invalid boot configuration uses the UART-disabled USB setup path and retains
|
||||
readable saved values for repair. See [Configuration](configuration.md).
|
||||
- SPI completion errors never copy partial data into the caller's receive
|
||||
buffer. If bounded shutdown cannot establish quiescence, the SPI route
|
||||
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
|
||||
- Configuration writes receive authoritative structured failures through
|
||||
`KAPTAN_CONFIG_RESULT`: operation, lifecycle state, field IDs, validation
|
||||
rule, and protocol error code.
|
||||
- Runtime driver and link faults reach the F722 MAVLink worker as a
|
||||
`KAPTAN_ERROR` path, subject to bounded transport admission. The error
|
||||
payload is `{ flags, path_length, additional_information_length, path[96],
|
||||
additional_information[64] }`; each length identifies the valid UTF-8
|
||||
prefix and the optional information field may be empty. A `KaptanError`
|
||||
derive supplies the type/variant path and normally leaves additional
|
||||
information empty. `log::error!` uses the Rust log target as the path and
|
||||
the formatted message as additional information.
|
||||
- A rejected configuration operation may enqueue a companion `KAPTAN_ERROR`
|
||||
whose path is the high-level `ProtocolError` identity. It is intentionally
|
||||
not correlated and carries no field/rule detail; the matching
|
||||
`KAPTAN_CONFIG_RESULT` remains the sole precise authority.
|
||||
- Control, navigation, safety, and simulator error types are derive-ready but
|
||||
their live owners are not yet attached to a MAVLink worker.
|
||||
|
||||
F722 uses `panic-halt`, without live panic reporting or retained-record replay.
|
||||
- `KAPTAN_SHUTDOWN`, defined in `definitions/kaptan/shutdown.xml`, represents
|
||||
only a future completed orderly,
|
||||
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
|
||||
fault, ISR, DMA callback, or motor-inhibit path. A later target integration
|
||||
must inhibit outputs first, persist a validated crash record, then report it
|
||||
only after MAVLink is available on the next boot.
|
||||
only after the next boot’s static board telemetry is available.
|
||||
@@ -4,51 +4,27 @@
|
||||
|
||||
Use the firmware toolchain, Rust 1.90. The firmware workspace declares the
|
||||
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:
|
||||
|
||||
```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:
|
||||
For host development, run:
|
||||
|
||||
```sh
|
||||
cd firmware
|
||||
cargo fetch --locked
|
||||
```
|
||||
|
||||
From the repository root, run the checks with:
|
||||
|
||||
```sh
|
||||
make pre-commit
|
||||
make coverage
|
||||
cargo test --workspace --offline
|
||||
cargo clippy --workspace --all-targets --offline --exclude kaptan-geprc-f722-v2 --exclude kaptan-kakute-f7-mini-v3 -- -D warnings
|
||||
```
|
||||
|
||||
Bare-metal board packages are not host-test targets. Check them with the
|
||||
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
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `firmware/boards/` | Hardware declarations, UART catalogs and image roots. |
|
||||
| `firmware/platforms/stm32f7/` | F722 RTIC platform, user schema, factory values, peripheral ownership and linker layout. |
|
||||
| `firmware/boards/` | Immutable board declarations and linker layouts. |
|
||||
| `firmware/platforms/stm32f7/` | Reviewed F722 RTIC platform and peripheral ownership. |
|
||||
| `firmware/libs/` | Typed math, estimation, control, safety, protocol, and driver crates. |
|
||||
| `firmware/tools/configurator/` | Board-agnostic loopback web UI and serial bridge. |
|
||||
| `firmware/tools/sitl/` | Host-only closed-loop Gazebo simulation. |
|
||||
| `ground-suite/` | Desktop ground-control application. |
|
||||
| `docs/` | This Markdown documentation site. |
|
||||
|
||||
Do not add board-local READMEs. Add a page here instead, then link it from
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Ground Suite
|
||||
|
||||
`ground-suite/` is Kaptan's web-based mission-control workspace. It is a
|
||||
map-first React and TypeScript application for working from mission intent to
|
||||
tasks, groups, roles, vehicles, and explicit engineering detail.
|
||||
|
||||
> **Simulation boundary:** the first release always displays deterministic
|
||||
> simulated data. It has no live Kaptan or Cerebrum connection and cannot
|
||||
> command a physical vehicle.
|
||||
|
||||
## Application model
|
||||
|
||||
The core model keeps these categories independent:
|
||||
|
||||
- **Mission truth** — source-owned missions, tasks, groups, vehicles,
|
||||
operators, authority, alerts, and command receipts.
|
||||
- **Derived projections** — fleet health, task coverage, selection summaries,
|
||||
and deterministic recommendations.
|
||||
- **Local UI state and preferences** — panels, viewport, layers, density,
|
||||
reduced motion, and configurable hotkeys.
|
||||
- **Audit journal** — ordered source and command envelopes.
|
||||
|
||||
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 central map uses an owned offline demonstration raster and local SVG/DOM
|
||||
overlays. Four semantic levels progressively disclose group, formation,
|
||||
vehicle telemetry, and engineering diagnostics. Click, Shift selection,
|
||||
rectangle selection, structured smart filters, configurable group keys, and
|
||||
accessible tables all operate on the same explicit selection model. Selection
|
||||
never grants command authority.
|
||||
|
||||
Consequential actions go through an exact-target preview covering authority,
|
||||
energy, link, staleness, constraints, connectivity, next state, and failure
|
||||
effect. The deterministic source then emits an acknowledgement or rejection
|
||||
into the same journal used for live mission projection.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
DataSource -> validation -> ordering/deduplication -> DataPlane journal
|
||||
-> mission projection/selectors -> useSyncExternalStore -> React
|
||||
|
||||
UI intent -> preview/authority/safety -> confirmation -> DataSource receipt
|
||||
-> DataPlane journal -> mission projection
|
||||
```
|
||||
|
||||
The fake and future Kaptan sources implement the same transport-neutral
|
||||
contract. React never imports the fake adapter. See `ground-suite/docs/` for
|
||||
the complete boundary and integration unknowns.
|
||||
|
||||
## Follow-up work
|
||||
|
||||
- TODO(gcs-source-binding): authenticated explicit source-to-vehicle 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 a command transport only after its authority,
|
||||
authentication, replay protection, target binding, and acknowledgement
|
||||
contracts are reviewed.
|
||||
- TODO(gcs-catalog): generate installed definitions and category metadata from
|
||||
reviewed firmware build manifests.
|
||||
+24
-10
@@ -1,22 +1,36 @@
|
||||
# Overview
|
||||
|
||||
Kaptan is an embedded Rust flight-controller project.
|
||||
Kaptan is an embedded Rust flight-controller project. This manual is the
|
||||
single source of project documentation: board support, safety boundaries,
|
||||
simulation, development workflow, and design references live here as
|
||||
Markdown.
|
||||
|
||||
> **Status:** 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.
|
||||
> **Status:** the checked-in firmware is telemetry-only. It does not expose
|
||||
> physical motor authority, and this site does not make a physical-flight
|
||||
> release claim.
|
||||
|
||||
## Index
|
||||
|
||||
- New contributors should read [Getting started](getting-started.md).
|
||||
- Firmware source and its Rust workspace begin in `firmware/`.
|
||||
- Firmware source and its Rust workspace begin in `firmware/`; the desktop
|
||||
application is a sibling project in `ground-suite/`.
|
||||
- Board work belongs under [Boards](boards/index.md).
|
||||
- [Configuration](configuration.md) explains target-described user settings
|
||||
and immutable F722 hardware policy.
|
||||
- [Logs](logs.md) and [Errors](errors.md) describe MAVLink diagnostics.
|
||||
- The currently supported closed-loop simulation is [Host SITL](sitl.md).
|
||||
- [Configuration](configuration.md) distinguishes the current SITL profile
|
||||
from immutable F722 board policy.
|
||||
- [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).
|
||||
|
||||
## Documentation conventions
|
||||
|
||||
Keep documentation in `docs/src/`, add pages to `SUMMARY.md`, and place commands
|
||||
beside their prerequisites.
|
||||
Documentation is centralized in `docs/src/`; individual crates and board
|
||||
directories do not carry README files. The top-level `firmware/README.md`
|
||||
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.
|
||||
+48
-69
@@ -1,89 +1,68 @@
|
||||
# Logs
|
||||
|
||||
Logging macros work on host and flight targets:
|
||||
Normal host/SITL task code can use the standard facade:
|
||||
|
||||
```rust,ignore
|
||||
kaptan_log::debug!("estimator waiting for IMU");
|
||||
kaptan_log::info!("configuration loaded");
|
||||
kaptan_log::warn!("range aid stale");
|
||||
kaptan_log::error!("sensor transfer failed");
|
||||
kaptan_log::trace!("prediction step complete");
|
||||
```rust
|
||||
log::debug!("estimator waiting for IMU");
|
||||
log::info!("configuration loaded");
|
||||
log::warn!("range aid stale");
|
||||
log::error!("sensor transfer failed"); // emitted as KAPTAN_ERROR
|
||||
```
|
||||
|
||||
All five macros emit `LOG`; their level never selects a different wire
|
||||
message. When a reportable firmware error escapes a
|
||||
`#[kaptan_err_reportable::propagate]` function, it makes one bounded admission attempt
|
||||
to the separate `ERROR` ingress.
|
||||
|
||||
`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.
|
||||
`kaptan-log` formats once into a fixed bounded buffer, queues locally, and
|
||||
never sends MAVLink, allocates, blocks, changes flight state, or touches DMA.
|
||||
The sole MAVLink worker drains admitted records. F722 deliberately does not
|
||||
link this facade in ISR, DMA, control, actuator, panic, hard-fault, or shutdown
|
||||
contexts; those paths use static fault mailboxes that the worker turns into
|
||||
messages later.
|
||||
|
||||
## Receiver contract
|
||||
|
||||
`definitions/publish/log.xml` and `definitions/publish/error.xml` define the
|
||||
separate ordinary-log and reportable-error publications:
|
||||
`definitions/kaptan/log.xml`, `definitions/kaptan/error.xml`, and
|
||||
`definitions/kaptan/shutdown.xml` separate ordinary logs, errors, and
|
||||
orderly-shutdown observations:
|
||||
|
||||
```text
|
||||
LOG { level, flags, message_length, message[160] }
|
||||
ERROR {
|
||||
error_id, raised_at[96]
|
||||
KAPTAN_LOG { level, flags, message_length, message[160] }
|
||||
KAPTAN_ERROR {
|
||||
flags, path_length, additional_information_length,
|
||||
path[96], additional_information[64]
|
||||
}
|
||||
KAPTAN_SHUTDOWN { reason }
|
||||
```
|
||||
|
||||
A GCS decoder generated from the same `kaptan.xml` revision must validate
|
||||
`message_length <= 160` for `LOG`. `ERROR.raised_at` is a
|
||||
zero-padded UTF-8 `file:line` prefix; receivers use bytes before the first zero,
|
||||
or the complete field when no zero is present.
|
||||
`LOG.level` is the typed severity-ordered Kaptan wire enum Trace (1), Debug
|
||||
(2), Info (3), Warn (4), or Error (5). Off (0) is a filter-only sentinel and
|
||||
is never emitted as a `LOG` event. `LOG.flags` currently owns only the
|
||||
Truncated bit (1).
|
||||
`message_length <= 160` for `KAPTAN_LOG`, and `path_length <= 96` plus
|
||||
`additional_information_length <= 64` for `KAPTAN_ERROR`, before slicing the
|
||||
fixed arrays into UTF-8 prefixes. Generated MAVLink framing alone does not
|
||||
enforce these semantic length fields. `KAPTAN_LOG` displays its text at the
|
||||
MAVLink severity in `level`; `TRACE` is encoded as `DEBUG` plus the `TRACE`
|
||||
flag.
|
||||
`KAPTAN_ERROR` is error-only and accepts only `TRUNCATED` and `COALESCED`; a
|
||||
`TRACE` bit is never emitted there. Unknown future flag bits must be ignored.
|
||||
|
||||
`error_id` is the stable nonzero catalog number explicitly assigned to the
|
||||
Rust error variant. Renaming or reordering a variant does not change it.
|
||||
`raised_at` identifies the reporting point where the `Err` entered
|
||||
diagnostics. Protocol failures remain in their owning response; for example, a
|
||||
rejected KCFG upload returns a `FILE_CONTROL` failure and readable
|
||||
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.
|
||||
The derived error path is intentionally not a numeric error catalog; renaming
|
||||
its Rust module/type/variant changes that identity. Error details requiring
|
||||
machine action stay in their owning protocol, notably `KAPTAN_CONFIG_RESULT`.
|
||||
`KAPTAN_SHUTDOWN` has only its typed reason and is valid only after an orderly
|
||||
output-inhibited completion. Delivery is bounded and lossy under link pressure;
|
||||
heartbeat and safety telemetry use separate paths.
|
||||
|
||||
## Filtering
|
||||
|
||||
The single restart-only `mavlink.logging.level_filter` field uses Off (0),
|
||||
Trace (1), Debug (2), Info (3), Warn (4), and Error (5). `Info`, for example,
|
||||
accepts Info, Warn, and Error log events. `Off` suppresses `LOG` on MAVLink
|
||||
without suppressing reportable `ERROR` events or logs sent to another
|
||||
sink. There is no separate enable flag and no MAVLink-specific set of extra
|
||||
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).
|
||||
The SITL profile’s restart-only `observability.maximum_log_level` accepts
|
||||
MAVLink severities Error (3) through Debug (7). For example, selecting Warning
|
||||
permits warning records while suppressing info and debug records. Error-level
|
||||
records always use `KAPTAN_ERROR` and remain non-filterable. F722 uses its
|
||||
immutable board log threshold instead, because it has no active runtime
|
||||
profile.
|
||||
|
||||
Events retain the compact typed `Level` and sink configuration retains the
|
||||
compact typed `LevelFilter`. Schema metadata supplies the filter choices to the
|
||||
configurator.
|
||||
Only the MAVLink adapter converts the five event levels to the typed Kaptan
|
||||
wire enum.
|
||||
Cargo `log/max_level_*` features compile calls away before the facade sees
|
||||
them; they are not a supported filtering mechanism. The build policy keeps
|
||||
that decision out of workspace dependencies.
|
||||
|
||||
The log ingress is transport-neutral and unfiltered.
|
||||
The tested `SitlConfigRuntime` composition proves `log!` reaches a generated
|
||||
`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.
|
||||
+19
-93
@@ -1,104 +1,30 @@
|
||||
# MAVLink and telemetry
|
||||
|
||||
Kaptan uses MAVLink 2 with its private dialect. It does not support `common`,
|
||||
`minimal` or other dialects. Reception follows these rules:
|
||||
Kaptan emits telemetry using MAVLink 2 framing. The production receiver does
|
||||
not promote inbound MAVLink frames to authority records:
|
||||
|
||||
- MAVLink 1 is discarded.
|
||||
- CRC-invalid MAVLink 2 frames are discarded.
|
||||
- Signed frames are discarded because no signature verifier or persistent
|
||||
replay state exists.
|
||||
- CRC-valid unsigned MAVLink 2 frames are retained only when their message ID
|
||||
and extra CRC belong to the Kaptan dialect. The board firmware consumes
|
||||
exact-target Kaptan file requests.
|
||||
- Unsigned MAVLink 2 frames are also discarded; a V2 CRC detects corruption,
|
||||
not authentication.
|
||||
|
||||
Shared or remote inbound authority is unsupported. Enabling it
|
||||
requires verified MAVLink 2 signing, configured source/target binding,
|
||||
persistent anti-replay state, and a provisioning/key-rotation policy as one
|
||||
reviewed feature.
|
||||
`TODO(signing)`: any future inbound authority requires verified MAVLink 2
|
||||
signing, configured source/target binding, persistent anti-replay state, and a
|
||||
provisioning/key-rotation policy as one reviewed feature.
|
||||
|
||||
Each F722 board image initializes USB CDC-ACM as a MAVLink link independently
|
||||
of UART roles. Linux normally names it `/dev/ttyACM*`. It is separate from the
|
||||
STM32 ROM DFU device used for flashing. Every UART assigned `KaptanMavlink` at
|
||||
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.
|
||||
Telemetry transmission uses bounded frame classes. A critical frame may
|
||||
replace pending best-effort traffic, but never the DMA-owned active buffer.
|
||||
Completion retries preserve ordering and retain a transiently busy frame for a
|
||||
later service turn.
|
||||
|
||||
The custom Kaptan definitions are aggregated by `definitions/kaptan.xml` from
|
||||
one-message leaves grouped by interaction contract:
|
||||
|
||||
| Directory | Contract |
|
||||
| --- | --- |
|
||||
| `publish/` | Zero-reply records for any interested receiver. |
|
||||
| `transfer/` | Exact-endpoint, bounded multi-message sessions. |
|
||||
|
||||
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.
|
||||
`kaptan/config.xml`, `kaptan/log.xml`, `kaptan/error.xml`, and
|
||||
`kaptan/shutdown.xml`.
|
||||
`KAPTAN_LOG` is an outbound human-readable four-field record. `KAPTAN_ERROR`
|
||||
is its separate bounded error channel, while `KAPTAN_SHUTDOWN` represents only
|
||||
a completed orderly output-inhibited shutdown. Configuration messages are GCS
|
||||
groundwork only: the current F722 receiver rejects every inbound frame before
|
||||
configuration dispatch. See [Configuration](configuration.md), [Logs](logs.md),
|
||||
and [Errors](errors.md) for the exact contracts.
|
||||
+23
-16
@@ -1,24 +1,31 @@
|
||||
# Reference
|
||||
|
||||
This page collects stable limits and points to the detailed contracts.
|
||||
|
||||
| Surface | Current contract |
|
||||
| --- | --- |
|
||||
| F722 persistence | One bounded native-width scalar snapshot inside each slot of a power-loss-safe two-slot flash journal. |
|
||||
| Public config artifact | Canonical KCFG v3 with generated metadata, finite/nonzero flags, factory values, selected-view values and exact expected-base values. |
|
||||
| Flight profile | Generated schema and required compiled installation for the portable runtime. See [Configuration](configuration.md#flight-configuration). |
|
||||
| Navigation service | Fusion, prediction and initialization completion run on due 100-Hz control service. Early ticks supervise cached evidence; admission alone cannot renew capabilities. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Flight profile | One 4 KiB-or-smaller raw CBOR blob, `profile_format = 1`, schema ID `KAPTANPROFILE-01`. |
|
||||
| SITL file | Private `[generation, raw-profile-blob]` envelope; not a MAVLink upload blob. |
|
||||
| Config transaction | One owner-bound RAM transaction; typed writes or 192-byte idempotent CBOR chunks; commit is restart-required. |
|
||||
| Config discovery | Correlated `KAPTAN_CONFIG_INFO_REQUEST` / `KAPTAN_CONFIG_INFO`, then descriptor/value requests. |
|
||||
| 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. |
|
||||
| F722 configuration | Immutable compiled board policy only; inbound MAVLink configuration remains disabled. |
|
||||
|
||||
Read [Configuration](configuration.md) for configuration ownership, artifact,
|
||||
and file contracts, [Architecture](architecture.md#monotonic-time) for clock
|
||||
assumptions, [Logs](logs.md) for receiver decoding and filtering, and
|
||||
[Errors](errors.md) for reportable errors and panic scope.
|
||||
Read [Configuration](configuration.md) for the field/consumer matrix and GCS
|
||||
upload sequence, [Logs](logs.md) for receiver decoding and filtering, and
|
||||
[Errors](errors.md) for error propagation and panic scope.
|
||||
|
||||
## Verification
|
||||
|
||||
Use [Development and verification](development.md) for the required gates,
|
||||
tool setup and evidence limits.
|
||||
Before review, run:
|
||||
|
||||
```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.
|
||||
+14
-77
@@ -1,88 +1,25 @@
|
||||
# Safety boundary
|
||||
|
||||
F722 startup drives PB0, PB1, PB4 and PB5 low, plus PB6 and PB7 on Kakute.
|
||||
It configures them as push-pull GPIO outputs before clock-tree setup, board
|
||||
routing, UART setup or RTIC tasks.
|
||||
Every checked-in F722 image inhibits PB0, PB1, PB4, and PB5 before normal
|
||||
board initialization. The interlock configures each pin as a low, push-pull
|
||||
GPIO output; it runs before clocks, board routing, UART setup, or RTIC tasks.
|
||||
|
||||
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.
|
||||
There is no checked-in platform actuator route:
|
||||
|
||||
The board images have no DShot, PWM or other actuator driver. The portable
|
||||
flight runtime is not yet connected to physical motors. File requests grant
|
||||
no actuator access.
|
||||
- no DShot or PWM peripheral implementation is linked;
|
||||
- no TIM3/TIM5 motor task or DMA output path exists;
|
||||
- MAVLink input has no production authority path;
|
||||
- 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:
|
||||
|
||||
```sh
|
||||
cd firmware
|
||||
cargo test -p platform-stm32f7 \
|
||||
--test platform_safety_boundary --locked --offline
|
||||
cargo test -p platform-stm32f7 --test platform_safety_boundary --offline
|
||||
```
|
||||
|
||||
## Shared runtime commit contract
|
||||
|
||||
`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.
|
||||
This boundary is deliberately stronger than an application-level disarm flag:
|
||||
it removes the physical actuator implementation from the platform image.
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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,2 +1,3 @@
|
||||
target
|
||||
.git
|
||||
tools/hitl
|
||||
Generated
+144
-182
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -52,7 +52,6 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"bitfield",
|
||||
"critical-section",
|
||||
"embedded-hal",
|
||||
"volatile-register",
|
||||
]
|
||||
@@ -99,12 +98,6 @@ dependencies = [
|
||||
"void",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.7.1"
|
||||
@@ -117,7 +110,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
|
||||
dependencies = [
|
||||
"embedded-io 0.7.1",
|
||||
"embedded-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -132,31 +125,12 @@ version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -177,12 +151,18 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
name = "kaptan-actuator"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-board"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-config-derive",
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-log",
|
||||
]
|
||||
|
||||
@@ -198,24 +178,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-configurator"
|
||||
name = "kaptan-config-mavlink"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-config",
|
||||
"kaptan-file-mavlink",
|
||||
"kaptan-log",
|
||||
"kaptan-mavlink",
|
||||
"libc",
|
||||
"mavlink-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-control"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-log",
|
||||
"kaptan-math",
|
||||
]
|
||||
|
||||
@@ -223,44 +198,77 @@ dependencies = [
|
||||
name = "kaptan-data-channel"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-board",
|
||||
"kaptan-dma",
|
||||
"kaptan-log",
|
||||
"kaptan-mavlink",
|
||||
"kaptan-sensor",
|
||||
"kaptan-time",
|
||||
"kaptan-tx",
|
||||
"mavlink-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-diagnostics-mavlink"
|
||||
name = "kaptan-dma"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-log",
|
||||
"kaptan-mavlink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-drivers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-i2c",
|
||||
"kaptan-log",
|
||||
"kaptan-sensor",
|
||||
"kaptan-spi",
|
||||
"kaptan-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-err-reportable"
|
||||
name = "kaptan-flight-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-err-reportable-macros",
|
||||
"kaptan-config",
|
||||
"kaptan-log",
|
||||
"minicbor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-err-reportable-macros"
|
||||
name = "kaptan-flight-runtime"
|
||||
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"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
@@ -270,70 +278,19 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-file-mavlink"
|
||||
name = "kaptan-log-mavlink"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-log",
|
||||
"kaptan-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",
|
||||
"mavlink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-math"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -341,8 +298,12 @@ name = "kaptan-mavlink"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"kaptan-dma",
|
||||
"kaptan-log",
|
||||
"kaptan-sensor",
|
||||
"kaptan-time",
|
||||
"kaptan-tx",
|
||||
"mavlink",
|
||||
"mavlink-bindgen",
|
||||
"mavlink-core",
|
||||
"num-derive",
|
||||
@@ -353,23 +314,17 @@ dependencies = [
|
||||
name = "kaptan-navigation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-log",
|
||||
"kaptan-math",
|
||||
"kaptan-sensor",
|
||||
"kaptan-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-ports"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kaptan-safety"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-log",
|
||||
"kaptan-time",
|
||||
]
|
||||
|
||||
@@ -380,6 +335,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "kaptan-spi"
|
||||
version = "0.1.0"
|
||||
@@ -393,10 +369,23 @@ name = "kaptan-tx"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[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]]
|
||||
name = "mavlink-bindgen"
|
||||
@@ -420,7 +409,7 @@ checksum = "b31cc9f930c7edce0c1933659d405a330a28346cdf087bd283a56a6d72eb3d85"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc-any",
|
||||
"embedded-io 0.7.1",
|
||||
"embedded-io",
|
||||
"embedded-io-async",
|
||||
]
|
||||
|
||||
@@ -430,6 +419,26 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "nb"
|
||||
version = "0.1.3"
|
||||
@@ -477,27 +486,20 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"kaptan-config",
|
||||
"kaptan-board",
|
||||
"kaptan-data-channel",
|
||||
"kaptan-diagnostics-mavlink",
|
||||
"kaptan-dma",
|
||||
"kaptan-drivers",
|
||||
"kaptan-err-reportable",
|
||||
"kaptan-file-mavlink",
|
||||
"kaptan-i2c",
|
||||
"kaptan-log",
|
||||
"kaptan-log-mavlink",
|
||||
"kaptan-mavlink",
|
||||
"kaptan-ports",
|
||||
"kaptan-sensor",
|
||||
"kaptan-spi",
|
||||
"kaptan-time",
|
||||
"kaptan-tx",
|
||||
"panic-halt",
|
||||
"rtic",
|
||||
"stm32f7",
|
||||
"synopsys-usb-otg",
|
||||
"usb-device",
|
||||
"usbd-serial",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -508,9 +510,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -526,9 +528,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -547,9 +549,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -624,7 +626,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -644,7 +645,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -669,12 +670,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stm32f7"
|
||||
version = "0.16.0"
|
||||
@@ -699,26 +694,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synopsys-usb-otg"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"critical-section",
|
||||
"embedded-hal",
|
||||
"usb-device",
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-triple"
|
||||
version = "1.0.1"
|
||||
@@ -736,22 +720,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.20"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -814,28 +798,6 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "vcell"
|
||||
version = "0.1.3"
|
||||
|
||||
+27
-32
@@ -1,34 +1,33 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"boards/geprc-f722-v2",
|
||||
"boards/kakute-f7-mini-v3",
|
||||
"libs/kaptan-actuator",
|
||||
"libs/kaptan-control",
|
||||
"libs/kaptan-config",
|
||||
"libs/kaptan-config-derive",
|
||||
"libs/kaptan-file-mavlink",
|
||||
"libs/kaptan-data-channel",
|
||||
"libs/kaptan-drivers",
|
||||
"libs/kaptan-err-reportable",
|
||||
"libs/kaptan-err-reportable-macros",
|
||||
"libs/kaptan-flight-config",
|
||||
"libs/kaptan-flight-runtime",
|
||||
"libs/kaptan-i2c",
|
||||
"libs/kaptan-math",
|
||||
"libs/kaptan-mavlink",
|
||||
"libs/kaptan-log",
|
||||
"libs/kaptan-diagnostics-mavlink",
|
||||
"libs/kaptan-navigation",
|
||||
"libs/kaptan-ports",
|
||||
"libs/kaptan-safety",
|
||||
"libs/kaptan-sensor",
|
||||
"libs/kaptan-spi",
|
||||
"libs/kaptan-time",
|
||||
"libs/kaptan-tx",
|
||||
"platforms/stm32f7",
|
||||
"tools/configurator",
|
||||
"boards/geprc-f722-v2",
|
||||
"boards/kakute-f7-mini-v3",
|
||||
"libs/kaptan-actuator",
|
||||
"libs/kaptan-board",
|
||||
"libs/kaptan-control",
|
||||
"libs/kaptan-config",
|
||||
"libs/kaptan-config-derive",
|
||||
"libs/kaptan-config-mavlink",
|
||||
"libs/kaptan-data-channel",
|
||||
"libs/kaptan-dma",
|
||||
"libs/kaptan-drivers",
|
||||
"libs/kaptan-flight-config",
|
||||
"libs/kaptan-flight-runtime",
|
||||
"libs/kaptan-i2c",
|
||||
"libs/kaptan-math",
|
||||
"libs/kaptan-mavlink",
|
||||
"libs/kaptan-log",
|
||||
"libs/kaptan-log-derive",
|
||||
"libs/kaptan-log-mavlink",
|
||||
"libs/kaptan-navigation",
|
||||
"libs/kaptan-safety",
|
||||
"libs/kaptan-sensor",
|
||||
"libs/kaptan-spi",
|
||||
"libs/kaptan-time",
|
||||
"libs/kaptan-tx",
|
||||
"platforms/stm32f7",
|
||||
"tools/sitl",
|
||||
]
|
||||
exclude = ["vendor/synopsys-usb-otg"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
@@ -39,7 +38,6 @@ rust-version = "1.90"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_op_in_unsafe_fn = "deny"
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(kani)'] }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
@@ -50,6 +48,3 @@ codegen-units = 1
|
||||
lto = "fat"
|
||||
panic = "abort"
|
||||
strip = "symbols"
|
||||
|
||||
[patch.crates-io]
|
||||
synopsys-usb-otg = { path = "vendor/synopsys-usb-otg" }
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
+14
-13
@@ -1,25 +1,26 @@
|
||||
# Firmware
|
||||
|
||||
Kaptan flight-controller firmware workspace.
|
||||
This directory is the Kaptan flight-controller firmware workspace. It owns the
|
||||
Rust workspace manifest, board declarations, platform implementation, reusable
|
||||
firmware crates, simulator, firmware scripts, and firmware test fixtures.
|
||||
|
||||
```text
|
||||
firmware/
|
||||
├── boards/ board hardware declarations and entry points
|
||||
├── boards/ immutable board declarations and linker layouts
|
||||
├── libs/ reusable no_std firmware crates
|
||||
├── platforms/ hardware composition, configuration and linker layouts
|
||||
├── tools/ local configuration and telemetry tools
|
||||
└── scripts/ packaging, analysis, and coverage helpers
|
||||
├── platforms/ hardware platform composition
|
||||
├── tools/ SITL and firmware development tooling
|
||||
├── scripts/ packaging, analysis, and coverage helpers
|
||||
└── tests/ firmware integration-test fixtures
|
||||
```
|
||||
|
||||
List commands from this directory:
|
||||
Run Cargo commands from this directory:
|
||||
|
||||
```sh
|
||||
make help
|
||||
cd firmware
|
||||
cargo test --workspace --locked --offline
|
||||
```
|
||||
|
||||
Run Cargo directly from this directory. Build and package every supported board
|
||||
with `make -C boards build`, or one with `make -C boards/<board> build`.
|
||||
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.
|
||||
The sibling `ground-suite/` directory is the desktop ground-control
|
||||
application. It is intentionally a separate project and does not participate
|
||||
in the firmware Cargo workspace.
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
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,11 +10,8 @@ build = "build.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-board = { path = "../../libs/kaptan-board" }
|
||||
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]]
|
||||
name = "kaptan-geprc-f722-v2"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
override BOARD := geprc-f722-v2
|
||||
|
||||
include ../board.mk
|
||||
@@ -1,11 +1,9 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.join("../../platforms/stm32f7/link/f722");
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
layout.join("memory.x").display()
|
||||
);
|
||||
println!("cargo:rerun-if-changed=memory.x");
|
||||
println!("cargo:rustc-link-arg=-Tlink.x");
|
||||
println!("cargo:rustc-link-search={}", layout.display());
|
||||
println!(
|
||||
"cargo:rustc-link-search={}",
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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,35 +1,45 @@
|
||||
//! Declarative support for the GEPRC GEP-F722-HD V2.
|
||||
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use platform_stm32f7::kaptan_ports::{PortCatalog, PortDescriptor};
|
||||
use kaptan_board::{
|
||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
||||
Severity, TelemetryProfile, VerticalSensingKind,
|
||||
};
|
||||
use platform_stm32f7::{
|
||||
F7Board, F722Hardware, F722ImmutableConfig, F722UserConfig, Icm42688Spi3, ImuMount, MotorPins,
|
||||
SerialInventory, StatusLed, UART1, UART2, UART3, UART4, UART6, VerticalSensor,
|
||||
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
|
||||
};
|
||||
|
||||
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
|
||||
pub struct GeprcF722V2;
|
||||
|
||||
impl PortCatalog<5> for GeprcF722V2 {
|
||||
const DESCRIPTORS: [PortDescriptor; 5] = [UART1, UART2, UART3, UART4, UART6];
|
||||
}
|
||||
|
||||
impl F7Board for GeprcF722V2 {
|
||||
type UserConfig = F722UserConfig<Self, 5>;
|
||||
type ImmutableConfig = F722ImmutableConfig<Icm42688Spi3>;
|
||||
|
||||
const FACTORY_USER_CONFIG: Self::UserConfig = Self::UserConfig::FACTORY;
|
||||
const IMMUTABLE_CONFIG: Self::ImmutableConfig = F722ImmutableConfig {
|
||||
const CONFIG: BoardConfig = BoardConfig {
|
||||
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
|
||||
service_hz: 100,
|
||||
hardware: F722Hardware::new(
|
||||
Icm42688Spi3 {
|
||||
mount: ImuMount::Yaw180,
|
||||
mavlink: MavlinkEndpoint {
|
||||
system_id: 1,
|
||||
component_id: 1,
|
||||
port: MavlinkPort::Serial2,
|
||||
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,
|
||||
},
|
||||
SerialInventory::new(&Self::DESCRIPTORS),
|
||||
StatusLed::ActiveHighPa13,
|
||||
VerticalSensor::None,
|
||||
MotorPins::Four,
|
||||
),
|
||||
maximum_log_level: Severity::Info,
|
||||
}),
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
platform_stm32f7::flight_firmware!(crate::board::GeprcF722V2);
|
||||
platform_stm32f7::flight_firmware!(board::GeprcF722V2);
|
||||
@@ -8,12 +8,12 @@ version.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
lidar-lite-v3 = []
|
||||
|
||||
[dependencies]
|
||||
kaptan-board = { path = "../../libs/kaptan-board" }
|
||||
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]]
|
||||
name = "kaptan-kakute-f7-mini-v3"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
override BOARD := kakute-f7-mini-v3
|
||||
|
||||
include ../board.mk
|
||||
@@ -1,11 +1,9 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||
.join("../../platforms/stm32f7/link/f722");
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
layout.join("memory.x").display()
|
||||
);
|
||||
println!("cargo:rerun-if-changed=memory.x");
|
||||
println!("cargo:rustc-link-arg=-Tlink.x");
|
||||
println!("cargo:rustc-link-search={}", layout.display());
|
||||
println!(
|
||||
"cargo:rustc-link-search={}",
|
||||
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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,42 +1,51 @@
|
||||
//! 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.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use platform_stm32f7::kaptan_ports::{PortCatalog, PortDescriptor};
|
||||
use kaptan_board::{
|
||||
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
||||
Severity, TelemetryProfile, VerticalSensingKind,
|
||||
};
|
||||
use platform_stm32f7::{
|
||||
AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware, F722ImmutableConfig,
|
||||
F722UserConfig, GyroscopeRange, ImuMount, MotorPins, Mpu6000Config, Mpu6000Spi1,
|
||||
SerialInventory, StatusLed, UART1, UART2, UART3, UART4, UART6, VerticalSensor,
|
||||
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor,
|
||||
};
|
||||
|
||||
/// Holybro Kakute F7 Mini V3.
|
||||
pub struct KakuteF7MiniV3;
|
||||
|
||||
impl PortCatalog<5> for KakuteF7MiniV3 {
|
||||
const DESCRIPTORS: [PortDescriptor; 5] = [UART1, UART2, UART3, UART4, UART6];
|
||||
}
|
||||
const CONFIG: BoardConfig = BoardConfig {
|
||||
name: "Kakute F7 Mini V3",
|
||||
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 {
|
||||
type UserConfig = F722UserConfig<Self, 5>;
|
||||
type ImmutableConfig = F722ImmutableConfig<Mpu6000Spi1>;
|
||||
const CONFIG: BoardConfig = CONFIG;
|
||||
|
||||
const FACTORY_USER_CONFIG: Self::UserConfig = Self::UserConfig::FACTORY;
|
||||
const IMMUTABLE_CONFIG: Self::ImmutableConfig = F722ImmutableConfig {
|
||||
name: "Kakute F7 Mini V3",
|
||||
service_hz: 100,
|
||||
hardware: F722Hardware::new(
|
||||
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,
|
||||
),
|
||||
};
|
||||
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
|
||||
platform
|
||||
.imu(Mpu6000::on_spi1().with_drdy(Pa4))
|
||||
.mavlink(Mavlink::on_usart1())
|
||||
.vertical_sensor(VerticalSensor::mtf01_on_uart4())
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@
|
||||
#![no_main]
|
||||
|
||||
mod board;
|
||||
platform_stm32f7::flight_firmware!(crate::board::KakuteF7MiniV3);
|
||||
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
|
||||
@@ -18,12 +18,15 @@ impl Config {
|
||||
rate: Rate,
|
||||
armed_idle_throttle: Throttle,
|
||||
arming_duration: ArmingDuration,
|
||||
) -> Self {
|
||||
Self {
|
||||
) -> Option<Self> {
|
||||
if rate.frames_for_millis(arming_duration.milliseconds()) == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
rate,
|
||||
armed_idle_throttle,
|
||||
arming_duration,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn rate(self) -> Rate {
|
||||
@@ -59,6 +62,17 @@ impl Rate {
|
||||
pub const fn timer_base_hz(self) -> u32 {
|
||||
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)]
|
||||
@@ -69,12 +83,10 @@ pub struct 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> {
|
||||
let symbol_hz = rate.hertz() as u64;
|
||||
let ticks = (timer_hz as u64 + symbol_hz / 2) / symbol_hz;
|
||||
if ticks < 3 || ticks > u16::MAX as u64 {
|
||||
let symbol_hz = rate.hertz();
|
||||
let ticks = (timer_hz + symbol_hz / 2) / symbol_hz;
|
||||
if ticks < 3 || ticks > u16::MAX as u32 {
|
||||
return None;
|
||||
}
|
||||
let bit_ticks = ticks as u16;
|
||||
@@ -202,7 +214,8 @@ pub const fn symbols(packet: u16, zero_high: u16, one_high: u16) -> [u16; FRAME_
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ArmSequence, FrameValue, Rate, Throttle, Timing, packet, symbols};
|
||||
use super::{ArmSequence, Config, FrameValue, Rate, Throttle, Timing, packet, symbols};
|
||||
use crate::ArmingDuration;
|
||||
|
||||
#[test]
|
||||
fn packet_matches_standard_dshot_vectors() {
|
||||
@@ -237,33 +250,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_clock_boundaries_do_not_overflow_or_clamp() {
|
||||
for (rate, maximum_profile) in [
|
||||
(Rate::Dshot150, (28_633, 10_022, 20_043)),
|
||||
(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
|
||||
);
|
||||
}
|
||||
fn requires_an_arming_interval_with_frames() {
|
||||
let duration = ArmingDuration::from_millis(2_000).unwrap();
|
||||
let throttle = Throttle::new(300).unwrap();
|
||||
assert!(Config::new(Rate::Dshot600, throttle, duration).is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//! 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 @@
|
||||
//! DShot/PWM framing and local arming sequences.
|
||||
//! Protocol-neutral physical-motor protocol reference.
|
||||
//!
|
||||
//! This reference crate grants no peripheral access or flight authority and is
|
||||
//! not linked by the current board images.
|
||||
//! This crate deliberately remains in the workspace for future, separately
|
||||
//! reviewed physical-flight work. No checked-in board platform links it, and
|
||||
//! its types model DShot/PWM framing and
|
||||
//! local arming sequences only; they grant neither peripheral access nor
|
||||
//! flight authority.
|
||||
|
||||
#![no_std]
|
||||
|
||||
pub mod dshot;
|
||||
pub mod pwm;
|
||||
|
||||
#[cfg(kani)]
|
||||
mod kani_proofs;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ArmingDuration(u16);
|
||||
|
||||
@@ -26,18 +26,6 @@ impl ArmingDuration {
|
||||
pub const fn milliseconds(self) -> u16 {
|
||||
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)]
|
||||
@@ -45,23 +33,3 @@ pub enum MotorProtocol {
|
||||
Pwm(pwm::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,22 +66,16 @@ impl Rate {
|
||||
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> {
|
||||
let period = 1_000_000 / self.0 as u32;
|
||||
if 1_000_000 % self.0 as u32 == 0 && period <= u16::MAX as u32 {
|
||||
Some(period as u16)
|
||||
if 1_000_000 % self.0 as u32 == 0 {
|
||||
Some((1_000_000 / self.0 as u32) as u16)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounds up so the frames last at least the requested duration.
|
||||
pub const fn frames_for_millis(self, milliseconds: u16) -> 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
|
||||
((self.0 as u32 * milliseconds as u32) / 1_000) as u16
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,59 +184,4 @@ mod tests {
|
||||
assert!(Config::new(Rate::new(333).unwrap(), minimum, idle, 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,5 +1,5 @@
|
||||
[package]
|
||||
name = "kaptan-ports"
|
||||
name = "kaptan-board"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -9,4 +9,4 @@ version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-config = { path = "../kaptan-config" }
|
||||
kaptan-log = { path = "../kaptan-log" }
|
||||
@@ -0,0 +1,339 @@
|
||||
//! 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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;
|
||||
match kind {
|
||||
ModelKind::Scalars => expand_scalars(ident, generics, crate_path, post_validate, fields),
|
||||
ModelKind::Sections => expand_children(ident, generics, crate_path, post_validate, fields),
|
||||
ModelKind::Sections => expand_sections(ident, generics, crate_path, post_validate, fields),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,72 +29,46 @@ fn expand_scalars(
|
||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||
let mut checks = Vec::new();
|
||||
let mut descriptors = Vec::new();
|
||||
let mut defaults = Vec::new();
|
||||
let mut reads = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
let mut snapshot_widths = Vec::new();
|
||||
for field in &fields {
|
||||
let FieldMetadata {
|
||||
descriptor,
|
||||
checks: field_checks,
|
||||
} = expand_field(&crate_path, field)?;
|
||||
descriptors.push(descriptor);
|
||||
checks.extend(field_checks.iter().cloned());
|
||||
let id = field.id.expect("scalar configuration fields have IDs");
|
||||
let ty = &field.ty;
|
||||
if let Some(descriptor) = descriptor {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
checks.extend(field_checks);
|
||||
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 (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 },
|
||||
)
|
||||
};
|
||||
defaults.push(quote! { #field_ident: #default });
|
||||
reads.push(quote! {
|
||||
#id => Some(#read),
|
||||
#id => Some(#value(self.#field_ident)),
|
||||
});
|
||||
writes.push(quote! {
|
||||
#id => {
|
||||
let next = match #decode {
|
||||
Ok(next) => next,
|
||||
Err(#crate_path::ScalarValueError::WrongKind(actual)) => {
|
||||
let next = match value {
|
||||
#value(next) => next,
|
||||
actual => {
|
||||
return Err(#crate_path::FieldWriteError::WrongType {
|
||||
field: #crate_path::FieldId::new(#id),
|
||||
expected: #kind,
|
||||
actual,
|
||||
actual: actual.kind(),
|
||||
});
|
||||
}
|
||||
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;
|
||||
self.#field_ident = next;
|
||||
let validation = (|| -> Result<(), #crate_path::ConfigViolation> {
|
||||
#(#field_checks)*
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(violation) = validation {
|
||||
if let Err(violation) =
|
||||
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
|
||||
{
|
||||
self.#field_ident = previous;
|
||||
return Err(#crate_path::FieldWriteError::Violation(violation));
|
||||
}
|
||||
@@ -104,33 +78,30 @@ fn expand_scalars(
|
||||
}
|
||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||
Ok(quote! {
|
||||
impl #impl_generics #ident #type_generics #where_clause {
|
||||
/// Field metadata generated from the same declarations as validation.
|
||||
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
|
||||
#(#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)*
|
||||
impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause {
|
||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||
<Self as #crate_path::ConfigSection>::validate_local_fields(self)?;
|
||||
#post_validate
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
|
||||
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);
|
||||
impl #impl_generics #ident #type_generics #where_clause {
|
||||
/// Field metadata generated from the same attributes as validation.
|
||||
pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[
|
||||
#(#descriptors),*
|
||||
];
|
||||
}
|
||||
|
||||
fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
|
||||
Self::DESCRIPTORS.get(index)
|
||||
impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause {
|
||||
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
|
||||
|
||||
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
|
||||
let section = Self {
|
||||
#(#defaults),*
|
||||
};
|
||||
<Self as #crate_path::ValidateFields>::validate_fields(§ion)?;
|
||||
Ok(section)
|
||||
}
|
||||
|
||||
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||
@@ -151,16 +122,15 @@ fn expand_scalars(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||
self.__validate_local_configuration()?;
|
||||
#post_validate
|
||||
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||
#(#checks)*
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_children(
|
||||
fn expand_sections(
|
||||
ident: syn::Ident,
|
||||
generics: syn::Generics,
|
||||
crate_path: syn::Path,
|
||||
@@ -168,58 +138,71 @@ fn expand_children(
|
||||
fields: Vec<crate::parse::FieldModel>,
|
||||
) -> Result<TokenStream> {
|
||||
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||
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 validations = fields.iter().map(|field| {
|
||||
let field_ident = &field.ident;
|
||||
let ty = &field.ty;
|
||||
validations.push(quote! {
|
||||
<#ty as #crate_path::Config>::validate(&self.#field_ident)?;
|
||||
});
|
||||
reads.push(quote! {
|
||||
if let Some(value) = <#ty as #crate_path::Config>::field_value(
|
||||
quote! {
|
||||
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
|
||||
}
|
||||
});
|
||||
let defaults = fields.iter().map(|field| {
|
||||
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,
|
||||
id,
|
||||
) {
|
||||
return Some(value);
|
||||
}
|
||||
});
|
||||
writes.push(quote! {
|
||||
if <#ty as #crate_path::Config>::field_value(&self.#field_ident, id).is_some() {
|
||||
return <#ty as #crate_path::Config>::set_field(
|
||||
}
|
||||
});
|
||||
let writes = fields.iter().map(|field| {
|
||||
let field_ident = &field.ident;
|
||||
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,
|
||||
id,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT });
|
||||
snapshot_widths.push(quote! { <#ty as #crate_path::Config>::SNAPSHOT_VALUE_BYTES });
|
||||
schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID });
|
||||
descriptor_lookups.push(quote! {
|
||||
if index < <#ty as #crate_path::Config>::FIELD_COUNT {
|
||||
return <#ty as #crate_path::Config>::descriptor(index);
|
||||
}
|
||||
index -= <#ty as #crate_path::Config>::FIELD_COUNT;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
let counts = fields.iter().map(|field| {
|
||||
let ty = &field.ty;
|
||||
quote! { <#ty as #crate_path::ConfigSection>::FIELD_COUNT }
|
||||
});
|
||||
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
|
||||
|
||||
Ok(quote! {
|
||||
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
|
||||
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::ValidateFields for #ident #type_generics #where_clause {
|
||||
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
|
||||
#(#validations)*
|
||||
#post_validate
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
|
||||
#(#descriptor_lookups)*
|
||||
None
|
||||
impl #impl_generics #ident #type_generics #where_clause {
|
||||
/// Number of fields aggregated from derived nested sections.
|
||||
pub const FIELD_COUNT: usize = 0 #(+ #counts)*;
|
||||
}
|
||||
|
||||
impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause {
|
||||
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> {
|
||||
let aggregate = Self {
|
||||
#(#defaults),*
|
||||
};
|
||||
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
|
||||
Ok(aggregate)
|
||||
}
|
||||
|
||||
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
|
||||
@@ -235,39 +218,23 @@ fn expand_children(
|
||||
#(#writes)*
|
||||
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 {
|
||||
descriptor: TokenStream,
|
||||
descriptor: Option<TokenStream>,
|
||||
checks: Vec<TokenStream>,
|
||||
}
|
||||
|
||||
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
|
||||
let mut checks = Vec::new();
|
||||
let id = field.id.expect("scalar configuration fields have IDs");
|
||||
let Some(id) = field.id else {
|
||||
return Ok(FieldMetadata {
|
||||
descriptor: None,
|
||||
checks,
|
||||
});
|
||||
};
|
||||
let minimum = field.checks.iter().find_map(|check| match check {
|
||||
Check::Minimum(value) => Some(value),
|
||||
_ => None,
|
||||
@@ -276,67 +243,28 @@ fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Res
|
||||
Check::Maximum(value) => Some(value),
|
||||
_ => 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 {
|
||||
checks.push(expand_check(
|
||||
crate_path,
|
||||
&field.ident,
|
||||
id,
|
||||
check.clone(),
|
||||
float,
|
||||
));
|
||||
checks.push(expand_check(crate_path, &field.ident, id, check.clone()));
|
||||
}
|
||||
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 ty = &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 default = field.default.as_ref().expect("id fields have a default");
|
||||
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
|
||||
let minimum = option_value_tokens(value.clone(), minimum);
|
||||
let maximum = option_value_tokens(value.clone(), maximum);
|
||||
Ok(FieldMetadata {
|
||||
descriptor: quote! {
|
||||
descriptor: Some(quote! {
|
||||
#crate_path::FieldDescriptor {
|
||||
id: #crate_path::FieldId::new(#id),
|
||||
display_key: #key,
|
||||
kind: #kind,
|
||||
minimum: #minimum,
|
||||
maximum: #maximum,
|
||||
enum_options: #enum_options,
|
||||
default: #value(#default),
|
||||
unit: #unit,
|
||||
finite: #finite,
|
||||
nonzero: #nonzero,
|
||||
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
|
||||
}
|
||||
},
|
||||
}),
|
||||
checks,
|
||||
})
|
||||
}
|
||||
@@ -348,7 +276,10 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To
|
||||
}
|
||||
}
|
||||
|
||||
fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result<TokenStream> {
|
||||
fn config_value_tokens(
|
||||
crate_path: &syn::Path,
|
||||
ty: &syn::Type,
|
||||
) -> Result<(TokenStream, TokenStream)> {
|
||||
let syn::Type::Path(path) = ty else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
ty,
|
||||
@@ -361,34 +292,42 @@ fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Re
|
||||
"configuration metadata requires a scalar type",
|
||||
));
|
||||
};
|
||||
let value = match segment.ident.to_string().as_str() {
|
||||
"bool" => quote! { #crate_path::ConfigValue::Bool },
|
||||
"i32" => quote! { #crate_path::ConfigValue::I32 },
|
||||
"u8" => quote! { #crate_path::ConfigValue::U8 },
|
||||
"u16" => quote! { #crate_path::ConfigValue::U16 },
|
||||
"u32" => quote! { #crate_path::ConfigValue::U32 },
|
||||
"u64" => quote! { #crate_path::ConfigValue::U64 },
|
||||
"f32" => quote! { #crate_path::ConfigValue::F32 },
|
||||
"f64" => quote! { #crate_path::ConfigValue::F64 },
|
||||
let (kind, value) = match segment.ident.to_string().as_str() {
|
||||
"bool" => (
|
||||
quote! { #crate_path::ValueKind::Bool },
|
||||
quote! { #crate_path::ConfigValue::Bool },
|
||||
),
|
||||
"i32" => (
|
||||
quote! { #crate_path::ValueKind::I32 },
|
||||
quote! { #crate_path::ConfigValue::I32 },
|
||||
),
|
||||
"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 Ok(quote! { #crate_path::ConfigValue::U32 });
|
||||
return Err(syn::Error::new_spanned(
|
||||
ty,
|
||||
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(value)
|
||||
Ok((kind, value))
|
||||
}
|
||||
|
||||
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 }
|
||||
};
|
||||
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
|
||||
match check {
|
||||
Check::Finite => quote! {
|
||||
if !self.#field.is_finite() {
|
||||
@@ -399,7 +338,7 @@ fn expand_check(
|
||||
}
|
||||
},
|
||||
Check::Nonzero => quote! {
|
||||
if self.#field == #zero {
|
||||
if self.#field == 0 {
|
||||
return Err(#crate_path::ConfigViolation::field(
|
||||
#crate_path::FieldId::new(#id),
|
||||
#crate_path::ValidationRule::Zero,
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
//! Procedural derive for recursively composable Kaptan configuration records.
|
||||
//! Procedural validation derive for Kaptan configuration records.
|
||||
|
||||
mod config_enum;
|
||||
mod expand;
|
||||
mod parse;
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use syn::parse_macro_input;
|
||||
|
||||
/// Generates validation, metadata, routing, and schema identity.
|
||||
///
|
||||
/// 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 {
|
||||
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers.
|
||||
#[proc_macro_derive(ConfigValidate, attributes(config))]
|
||||
pub fn derive_config_validate(input: TokenStream) -> TokenStream {
|
||||
let input = parse_macro_input!(input as syn::DeriveInput);
|
||||
match parse::Model::parse(input).and_then(expand::expand) {
|
||||
Ok(tokens) => tokens.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,9 +3,6 @@ use syn::{
|
||||
spanned::Spanned,
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_KEY_BYTES: usize = 48;
|
||||
const MAX_UNIT_BYTES: usize = 16;
|
||||
|
||||
pub(crate) struct Model {
|
||||
pub(crate) ident: Ident,
|
||||
pub(crate) generics: syn::Generics,
|
||||
@@ -27,8 +24,9 @@ pub(crate) struct FieldModel {
|
||||
pub(crate) id: Option<u16>,
|
||||
pub(crate) display_key: Option<syn::LitStr>,
|
||||
pub(crate) unit: Option<syn::LitStr>,
|
||||
pub(crate) default: Option<Expr>,
|
||||
pub(crate) checks: Vec<Check>,
|
||||
pub(crate) enum_backed: bool,
|
||||
pub(crate) section: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -45,19 +43,19 @@ impl Model {
|
||||
let Data::Struct(data) = input.data else {
|
||||
return Err(syn::Error::new(
|
||||
input.ident.span(),
|
||||
"Config can only be derived for structs",
|
||||
"ConfigValidate can only be derived for structs",
|
||||
));
|
||||
};
|
||||
let Fields::Named(fields) = data.fields else {
|
||||
return Err(syn::Error::new(
|
||||
input.ident.span(),
|
||||
"Config requires named struct fields",
|
||||
"ConfigValidate requires named struct fields",
|
||||
));
|
||||
};
|
||||
if fields.named.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
input.ident.span(),
|
||||
"Config requires at least one field",
|
||||
"ConfigValidate requires at least one configured field or section",
|
||||
));
|
||||
}
|
||||
let mut models = Vec::with_capacity(fields.named.len());
|
||||
@@ -75,63 +73,38 @@ impl Model {
|
||||
}
|
||||
models.push(model);
|
||||
}
|
||||
let kind = if models.iter().all(|field| field.id.is_some()) {
|
||||
ModelKind::Scalars
|
||||
} else if models.iter().all(|field| field.id.is_none()) {
|
||||
let kind = if models.iter().all(|field| field.section) {
|
||||
ModelKind::Sections
|
||||
} else if models.iter().all(|field| field.id.is_some()) {
|
||||
ModelKind::Scalars
|
||||
} else {
|
||||
let field = models
|
||||
.iter()
|
||||
.find(|field| field.id.is_none())
|
||||
.find(|field| field.section || field.id.is_none())
|
||||
.expect("mixed configuration model has one invalid field");
|
||||
return Err(syn::Error::new(
|
||||
field.ident.span(),
|
||||
"Config records must contain either scalar fields or child configuration records exclusively",
|
||||
"ConfigValidate records must contain either scalar config fields or config(section) fields exclusively",
|
||||
));
|
||||
};
|
||||
if kind == ModelKind::Scalars {
|
||||
let mut display_keys = Vec::with_capacity(models.len());
|
||||
for field in &models {
|
||||
let Some(display_key) = &field.display_key else {
|
||||
if field.display_key.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
field.ident.span(),
|
||||
"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",
|
||||
));
|
||||
}
|
||||
display_keys.push(display_key);
|
||||
let Some(unit) = &field.unit else {
|
||||
if field.unit.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
field.ident.span(),
|
||||
"configuration fields require config(unit = \"...\")",
|
||||
));
|
||||
};
|
||||
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(
|
||||
field.ty.span(),
|
||||
"config(enum) requires a ConfigEnum-derived enum type",
|
||||
));
|
||||
}
|
||||
if !field.enum_backed && !primitive {
|
||||
if field.default.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
field.ty.span(),
|
||||
"non-primitive configuration fields require config(enum)",
|
||||
field.ident.span(),
|
||||
"configuration fields require config(default = ...)",
|
||||
));
|
||||
}
|
||||
validate_static_constraints(field)?;
|
||||
@@ -148,34 +121,6 @@ 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>)> {
|
||||
let mut crate_path = syn::parse_quote!(::kaptan_config);
|
||||
let mut has_crate_path = false;
|
||||
@@ -215,17 +160,40 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
||||
let mut id = None;
|
||||
let mut display_key = None;
|
||||
let mut unit = None;
|
||||
let mut default = None;
|
||||
let mut finite = false;
|
||||
let mut nonzero = false;
|
||||
let mut range_minimum = false;
|
||||
let mut range_maximum = false;
|
||||
let mut enum_backed = false;
|
||||
let mut section = false;
|
||||
for attribute in field
|
||||
.attrs
|
||||
.iter()
|
||||
.filter(|attribute| attribute.path().is_ident("config"))
|
||||
{
|
||||
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 id.is_some() {
|
||||
return Err(meta.error("duplicate configuration field id"));
|
||||
@@ -252,11 +220,13 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
||||
unit = Some(meta.value()?.parse()?);
|
||||
return Ok(());
|
||||
}
|
||||
if meta.path.is_ident("enum") {
|
||||
if enum_backed {
|
||||
return Err(meta.error("duplicate enum configuration marker"));
|
||||
if meta.path.is_ident("default") {
|
||||
if default.is_some() {
|
||||
return Err(meta.error("duplicate configuration default"));
|
||||
}
|
||||
enum_backed = true;
|
||||
let expression = meta.value()?.parse()?;
|
||||
validate_default_literal(&expression, &field.ty)?;
|
||||
default = Some(expression);
|
||||
return Ok(());
|
||||
}
|
||||
if meta.path.is_ident("finite") {
|
||||
@@ -303,16 +273,26 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
||||
return Ok(());
|
||||
}
|
||||
Err(meta.error(
|
||||
"supported field config rules are id, key, unit, enum, finite, nonzero, and range",
|
||||
"supported field config rules are id, key, unit, default, finite, nonzero, and range",
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if id.is_none()
|
||||
&& (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
|
||||
if !section && !checks.is_empty() && id.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
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(
|
||||
ident.span(),
|
||||
"scalar configuration fields require config(id = N)",
|
||||
"config(section) cannot be combined with scalar id, metadata, or validation rules",
|
||||
));
|
||||
}
|
||||
Ok(FieldModel {
|
||||
@@ -321,8 +301,9 @@ fn parse_field(field: Field) -> Result<FieldModel> {
|
||||
id,
|
||||
display_key,
|
||||
unit,
|
||||
default,
|
||||
checks,
|
||||
enum_backed,
|
||||
section,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,19 +353,33 @@ 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<()> {
|
||||
let Some(ty) = scalar_type_name(&field.ty) else {
|
||||
if field.checks.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(syn::Error::new(
|
||||
let ty = scalar_type_name(&field.ty).ok_or_else(|| {
|
||||
syn::Error::new(
|
||||
field.ty.span(),
|
||||
"validation rules require a supported primitive scalar type",
|
||||
));
|
||||
};
|
||||
"configuration metadata requires a supported scalar type",
|
||||
)
|
||||
})?;
|
||||
if ty == "bool" {
|
||||
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 {
|
||||
Check::Minimum(value) => Some(value),
|
||||
_ => None,
|
||||
@@ -411,6 +406,70 @@ fn validate_static_constraints(field: &FieldModel) -> Result<()> {
|
||||
"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(())
|
||||
}
|
||||
|
||||
@@ -428,6 +487,14 @@ impl ScalarLiteral {
|
||||
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> {
|
||||
@@ -452,26 +519,6 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
||||
})?;
|
||||
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" => {
|
||||
let value = unsigned_literal(expression)?;
|
||||
u64::try_from(value).map_err(|_| {
|
||||
@@ -482,15 +529,13 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
|
||||
})?;
|
||||
Ok(ScalarLiteral::Unsigned(value))
|
||||
}
|
||||
// Parse at the emitted literal's native precision. Parsing through f64
|
||||
// first can double-round an f32 bound across an ordering boundary.
|
||||
"f32" => Ok(ScalarLiteral::Float(f64::from(float_literal::<f32>(
|
||||
expression,
|
||||
)?))),
|
||||
"f64" => float_literal::<f64>(expression).map(ScalarLiteral::Float),
|
||||
"f32" => Ok(ScalarLiteral::Float(f64::from(
|
||||
float_literal(expression)? as f32
|
||||
))),
|
||||
"f64" => float_literal(expression).map(ScalarLiteral::Float),
|
||||
_ => Err(syn::Error::new(
|
||||
expression.span(),
|
||||
"configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64",
|
||||
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -544,11 +589,7 @@ fn unsigned_literal(expression: &Expr) -> Result<u128> {
|
||||
}
|
||||
}
|
||||
|
||||
fn float_literal<T>(expression: &Expr) -> Result<T>
|
||||
where
|
||||
T: std::str::FromStr + core::ops::Neg<Output = T>,
|
||||
T::Err: core::fmt::Display,
|
||||
{
|
||||
fn float_literal(expression: &Expr) -> Result<f64> {
|
||||
let (negative, literal) = match expression {
|
||||
Expr::Lit(expression) => (false, &expression.lit),
|
||||
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
|
||||
@@ -569,10 +610,10 @@ where
|
||||
};
|
||||
let value = match literal {
|
||||
Lit::Int(value) => value
|
||||
.base10_parse::<T>()
|
||||
.base10_parse::<f64>()
|
||||
.map_err(|error| syn::Error::new(value.span(), error))?,
|
||||
Lit::Float(value) => value
|
||||
.base10_parse::<T>()
|
||||
.base10_parse::<f64>()
|
||||
.map_err(|error| syn::Error::new(value.span(), error))?,
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
@@ -607,14 +648,30 @@ fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
|
||||
}
|
||||
|
||||
fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> {
|
||||
if scalar_type_name(ty)
|
||||
.is_some_and(|name| matches!(name, "f32" | "f64" | "i32" | "u8" | "u16" | "u32" | "u64"))
|
||||
{
|
||||
if scalar_type_name(ty).is_some_and(|name| {
|
||||
matches!(
|
||||
name,
|
||||
"f32"
|
||||
| "f64"
|
||||
| "i8"
|
||||
| "i16"
|
||||
| "i32"
|
||||
| "i64"
|
||||
| "i128"
|
||||
| "isize"
|
||||
| "u8"
|
||||
| "u16"
|
||||
| "u32"
|
||||
| "u64"
|
||||
| "u128"
|
||||
| "usize"
|
||||
)
|
||||
}) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(syn::Error::new(
|
||||
span.span(),
|
||||
"nonzero and range require a supported primitive numeric field",
|
||||
"nonzero and range require a primitive numeric field",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -634,11 +691,18 @@ fn scalar_type_name(ty: &Type) -> Option<&str> {
|
||||
ident if ident == "bool" => "bool",
|
||||
ident if ident == "f32" => "f32",
|
||||
ident if ident == "f64" => "f64",
|
||||
ident if ident == "i8" => "i8",
|
||||
ident if ident == "i16" => "i16",
|
||||
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 == "u16" => "u16",
|
||||
ident if ident == "u32" => "u32",
|
||||
ident if ident == "u64" => "u64",
|
||||
ident if ident == "u128" => "u128",
|
||||
ident if ident == "usize" => "usize",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
use kaptan_config::{Config, ConfigViolation};
|
||||
use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct Child {
|
||||
#[config(id = 1, key = "child.value", unit = "count", range(min = 0, max = 2))]
|
||||
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
#[config(post = Self::validate_complete)]
|
||||
struct Root {
|
||||
#[config(section)]
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
fn validate_complete(&self) -> Result<(), ConfigViolation> {
|
||||
self.child.validate()
|
||||
self.child.validate_fields()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = Root {
|
||||
child: Child { value: 1 },
|
||||
};
|
||||
let root = Root::default_aggregate().unwrap();
|
||||
assert_eq!(Root::FIELD_COUNT, 1);
|
||||
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct RootBooleanConfig {
|
||||
#[config(id = 1, key = "enabled", unit = "bool")]
|
||||
#[config(id = 1, key = "enabled", unit = "bool", default = true)]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
use kaptan_config::{
|
||||
Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
|
||||
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
|
||||
ValidateFields, ValidationRule,
|
||||
};
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct LocalSection {
|
||||
#[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
|
||||
#[config(
|
||||
id = 1,
|
||||
key = "local.first",
|
||||
unit = "count",
|
||||
default = 2,
|
||||
range(min = 1, max = 9)
|
||||
)]
|
||||
first: u32,
|
||||
#[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
|
||||
#[config(
|
||||
id = 2,
|
||||
key = "local.second",
|
||||
unit = "count",
|
||||
default = 4,
|
||||
range(min = 1, max = 9)
|
||||
)]
|
||||
second: u32,
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
#[config(post = Self::validate_order)]
|
||||
struct OrderedSection {
|
||||
#[config(
|
||||
id = 3,
|
||||
key = "ordered.minimum",
|
||||
unit = "count",
|
||||
default = 1,
|
||||
range(min = 0, max = 9)
|
||||
)]
|
||||
minimum: u32,
|
||||
@@ -24,6 +39,7 @@ struct OrderedSection {
|
||||
id = 4,
|
||||
key = "ordered.maximum",
|
||||
unit = "count",
|
||||
default = 2,
|
||||
range(min = 0, max = 9)
|
||||
)]
|
||||
maximum: u32,
|
||||
@@ -42,51 +58,17 @@ impl OrderedSection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct Aggregate {
|
||||
#[config(section)]
|
||||
local: LocalSection,
|
||||
#[config(section)]
|
||||
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]
|
||||
fn generated_section_owns_every_declared_scalar_path() {
|
||||
let mut section = LocalSection {
|
||||
first: 2,
|
||||
second: 4,
|
||||
};
|
||||
let mut section = LocalSection::default_section().unwrap();
|
||||
assert_eq!(LocalSection::FIELD_COUNT, 2);
|
||||
assert_eq!(
|
||||
section.field_value(FieldId::new(2)),
|
||||
@@ -112,67 +94,9 @@ 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]
|
||||
fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
||||
let mut aggregate = Aggregate {
|
||||
local: LocalSection {
|
||||
first: 2,
|
||||
second: 4,
|
||||
},
|
||||
ordered: OrderedSection {
|
||||
minimum: 1,
|
||||
maximum: 2,
|
||||
},
|
||||
};
|
||||
let mut aggregate = Aggregate::default_aggregate().unwrap();
|
||||
assert_eq!(Aggregate::FIELD_COUNT, 4);
|
||||
assert_eq!(
|
||||
aggregate.field_value(FieldId::new(2)),
|
||||
@@ -182,7 +106,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
||||
.set_field(FieldId::new(3), ConfigValue::U32(3))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
aggregate.validate(),
|
||||
aggregate.validate_fields(),
|
||||
Err(issue)
|
||||
if issue.primary() == Some(FieldId::new(3))
|
||||
&& issue.related_field() == Some(FieldId::new(4))
|
||||
@@ -191,190 +115,5 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
|
||||
aggregate
|
||||
.set_field(FieldId::new(4), ConfigValue::U32(3))
|
||||
.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
|
||||
}])
|
||||
);
|
||||
aggregate.validate_fields().unwrap();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
use kaptan_config_derive::ConfigEnum;
|
||||
|
||||
#[derive(ConfigEnum)]
|
||||
#[repr(u8)]
|
||||
enum ImplicitDiscriminant {
|
||||
First = 1,
|
||||
Second,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: ConfigEnum variants require explicit u8 discriminants
|
||||
--> tests/ui/config_enum_implicit_discriminant.rs:7:5
|
||||
|
|
||||
7 | Second,
|
||||
| ^^^^^^
|
||||
@@ -1,10 +0,0 @@
|
||||
use kaptan_config_derive::ConfigEnum;
|
||||
|
||||
#[derive(ConfigEnum)]
|
||||
#[repr(u8)]
|
||||
enum LabelWithNul {
|
||||
#[config_enum(label = "invalid\0label")]
|
||||
Value = 1,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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")]
|
||||
| ^^^^^^^^^^^^^^^^
|
||||
@@ -1,10 +0,0 @@
|
||||
use kaptan_config_derive::ConfigEnum;
|
||||
|
||||
#[derive(ConfigEnum)]
|
||||
#[repr(u8)]
|
||||
enum LabelTooManyBytes {
|
||||
#[config_enum(label = "şşşşşşşşşşşşşşşşş")]
|
||||
Value = 1,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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 = "şşşşşşşşşşşşşşşşş")]
|
||||
| ^^^^^^^^^^^^^^^^^^^
|
||||
@@ -1,8 +0,0 @@
|
||||
use kaptan_config_derive::ConfigEnum;
|
||||
|
||||
#[derive(ConfigEnum)]
|
||||
enum MissingRepresentation {
|
||||
First = 1,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: ConfigEnum requires exactly #[repr(u8)]
|
||||
--> tests/ui/config_enum_missing_repr.rs:4:6
|
||||
|
|
||||
4 | enum MissingRepresentation {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -1,9 +0,0 @@
|
||||
use kaptan_config_derive::ConfigEnum;
|
||||
|
||||
#[derive(ConfigEnum)]
|
||||
#[repr(u8)]
|
||||
enum Payload {
|
||||
Value(u8) = 1,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: ConfigEnum variants cannot contain fields
|
||||
--> tests/ui/config_enum_payload.rs:6:10
|
||||
|
|
||||
6 | Value(u8) = 1,
|
||||
| ^^^^
|
||||
@@ -0,0 +1,9 @@
|
||||
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() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
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))]
|
||||
| ^
|
||||
@@ -0,0 +1,9 @@
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(ConfigValidate)]
|
||||
struct NonFiniteDefault {
|
||||
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
||||
value: f32,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: configuration default violates finite
|
||||
--> tests/ui/default_violates_finite.rs:5:63
|
||||
|
|
||||
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
|
||||
| ^^^^^
|
||||
@@ -0,0 +1,9 @@
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(ConfigValidate)]
|
||||
struct ZeroDefault {
|
||||
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: configuration default violates nonzero
|
||||
--> tests/ui/default_violates_nonzero.rs:5:63
|
||||
|
|
||||
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
|
||||
| ^
|
||||
@@ -1,13 +0,0 @@
|
||||
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() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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",
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -1,11 +0,0 @@
|
||||
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() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct DuplicateId {
|
||||
#[config(id = 7, nonzero)]
|
||||
first: u32,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use kaptan_config_derive::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct DuplicateRange {
|
||||
#[config(id = 7, range(min = 1), range(min = 2))]
|
||||
value: u32,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use kaptan_config_derive::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct Empty {}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,4 +1,4 @@
|
||||
error: Config requires at least one field
|
||||
error: ConfigValidate requires at least one configured field or section
|
||||
--> tests/ui/empty.rs:4:8
|
||||
|
|
||||
4 | struct Empty {}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
use kaptan_config_derive::Config;
|
||||
|
||||
#[derive(Config)]
|
||||
struct PrimitiveEnumMarker {
|
||||
#[config(enum, id = 1, key = "mode", unit = "mode")]
|
||||
mode: u8,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: config(enum) requires a ConfigEnum-derived enum type
|
||||
--> tests/ui/enum_marker_on_primitive.rs:6:11
|
||||
|
|
||||
6 | mode: u8,
|
||||
| ^^
|
||||
@@ -1,9 +0,0 @@
|
||||
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() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: configuration range minimum exceeds maximum
|
||||
--> tests/ui/f32_double_rounded_bounds.rs:6:5
|
||||
|
|
||||
6 | value: f32,
|
||||
| ^^^^^
|
||||
@@ -1,15 +0,0 @@
|
||||
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() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct MissingId {
|
||||
#[config(nonzero)]
|
||||
value: u32,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
error: scalar configuration fields require config(id = N)
|
||||
error: validated configuration fields require config(id = N)
|
||||
--> tests/ui/missing_id.rs:6:5
|
||||
|
|
||||
6 | value: u32,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use kaptan_config_derive::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct MissingMetadata {
|
||||
#[config(id = 7, unit = "count", range(min = 0, max = 10))]
|
||||
#[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use kaptan_config_derive::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct MixedSectionAndScalar {
|
||||
#[config(section)]
|
||||
child: u32,
|
||||
#[config(id = 1, key = "scalar", unit = "count", range(min = 0, max = 2))]
|
||||
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
|
||||
scalar: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
error: Config records must contain either scalar fields or child configuration records exclusively
|
||||
--> tests/ui/mixed_section_scalar.rs:5:5
|
||||
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
|
||||
--> tests/ui/mixed_section_scalar.rs:6:5
|
||||
|
|
||||
5 | child: u32,
|
||||
6 | child: u32,
|
||||
| ^^^^^
|
||||
@@ -1,24 +0,0 @@
|
||||
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() {}
|
||||
@@ -1,17 +0,0 @@
|
||||
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)]
|
||||
| ^^^^^^
|
||||
@@ -1,31 +0,0 @@
|
||||
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() {}
|
||||
@@ -1,17 +0,0 @@
|
||||
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::Config;
|
||||
use kaptan_config_derive::ConfigValidate;
|
||||
|
||||
#[derive(Config)]
|
||||
#[derive(ConfigValidate)]
|
||||
struct InvalidRange {
|
||||
#[config(id = 1, key = "value", unit = "count", range(min = 3, max = 1))]
|
||||
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
use kaptan_config_derive::Config;
|
||||
|
||||
#[derive(Config)]
|
||||
struct UnitTooLong {
|
||||
#[config(id = 1, key = "value", unit = "seventeen_bytes___")]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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___")]
|
||||
| ^^^^^^^^^^^^^^^^^^^^
|
||||
@@ -1,9 +0,0 @@
|
||||
use kaptan_config_derive::Config;
|
||||
|
||||
#[derive(Config)]
|
||||
struct UnsupportedDefault {
|
||||
#[config(id = 1, key = "value", unit = "count", default = 1)]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
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)]
|
||||
| ^^^^^^^
|
||||
@@ -1,10 +0,0 @@
|
||||
use kaptan_config_derive::Config;
|
||||
|
||||
#[derive(Config)]
|
||||
#[config(external_defaults)]
|
||||
struct UnsupportedExternalDefaults {
|
||||
#[config(id = 1, key = "value", unit = "count")]
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user