Compare commits

...
Author SHA1 Message Date
Onur Özkan 6e6c03cb6b docs: track remaining flight-controller work
Firmware gates / Source-bound firmware verification (push) Canceled after 0s
List the remaining physical flight integration, peripheral support and
hardware verification tasks.
2026-11-07 18:12:11 +03:00
Onur Özkan 62b18d5b2e build: replace argument dispatch and retain source-bound verification
Replace argument recovery with direct build targets. Run host, target,
board, coverage, Miri and selected Kani checks while retaining source
identities, logs and artifacts, including failed attempts.

Keep the PID proof diagnostic exception explicit and match its exact
identity alongside passing contract assertions and covers.

Validation: final-source verification matrix, assembled workflow tests
and documentation build.
2026-11-07 18:12:11 +03:00
Onur Özkan 4953f0a974 fix(ground-suite): handle failed UTF-16 conversion sizes
Reject failed UTF-16 sizing queries before allocating the UTF-8 result
and preserve terminating-NUL accounting.

Validation: nine host-stub cases with 16-bit wchar_t; native Windows
execution remains unverified.
2026-11-07 18:12:11 +03:00
Onur Özkan a78754d909 build(ground-suite): add a separate pinned Flutter shell
Provide a separate Flutter development shell from the existing locked
Nix input and document the application build commands.

Validation: locked environment/version probe; no native application
qualification is claimed.
2026-11-07 18:12:11 +03:00
Onur Özkan d32b03c87f feat(drivers): validate bounded IMU FIFO acquisition
Drain bounded MPU6000 and ICM-42688 FIFO batches. Publish the latest
validated sample only after overflow checks. Reject invalid drains.

Validation: driver, sensor and platform tests plus both ARM release builds
from a fresh build directory.
2026-11-07 18:12:11 +03:00
Onur Özkan f9a61a086f feat(firmware): validate configuration and migrate sensor-driven flight APIs
Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
2026-11-07 18:12:11 +03:00
Onur Özkan fb1b8af2d1 fix(actuator): validate reference timing calculations
Reject invalid DShot and PWM timer domains and widen arming-duration
calculations before composing their results.

Validation: actuator tests, strict Clippy and five Kani component proofs.
2026-11-07 18:12:11 +03:00
Onur Özkan 84807dde4e fix(diagnostics): bound formatting and release ingress before callbacks
Bound UTF-8 formatting and release diagnostic ingress before invoking
callbacks. Check error-ID collisions at Rust/C++ release final link.

Validation: diagnostic tests, strict Clippy, Miri and final-link cases.
2026-11-07 18:12:11 +03:00
Onur Özkan 3226ef97fe refactor(firmware): remove the retired simulation workflow
Remove the retired simulator workspace member, dependencies, workflow
and documentation. Keep the shared flight and hardware crates.

Validation: retirement prefix host check, command-dispatch tests and
book build.
2026-11-07 18:12:11 +03:00
Onur Özkan 53e57c5d73 ground_suite: init template layout
Host SITL smoke / Deterministic Gazebo command path (push) Canceled after 0s
Signed-off-by: Onur Özkan <work@onurozkan.dev>
2026-09-25 11:46:29 +03:00
Onur Özkan 3ae1a8efb1 docs: document usb mavlink and configurator startup
Host SITL smoke / Deterministic Gazebo command path (push) Canceled after 0s
2026-09-06 23:14:39 +03:00
Onur Özkan 1f74983d61 fix(configurator): allow startup without arguments 2026-09-06 23:14:16 +03:00
Onur Özkan 604a7aba86 feat(f722): add usb mavlink transport 2026-09-06 23:14:00 +03:00
Onur Özkan 7dc3ccb619 build: add directory-oriented make workflows
Host SITL smoke / Deterministic Gazebo command path (push) Has been cancelled
2026-08-30 07:05:01 +03:00
Onur Özkan 4bc98adbc6 docs: document the kaptan-native protocol 2026-08-24 00:56:32 +03:00
Onur Özkan 5f91465e66 feat(mavlink): adopt the kaptan-native dialect 2026-08-24 00:56:06 +03:00
Onur Özkan 1c90870634 fix(f722): bound flash operations from sram 2026-08-24 00:55:48 +03:00
Onur Özkan 9fec2fa095 refactor(errors): adopt reportable error terminology 2026-08-23 00:49:25 +03:00
Onur Özkan 21e9497915 docs: update configuration and diagnostics 2026-08-23 00:40:50 +03:00
Onur Özkan fdf82a5114 refactor(firmware): simplify configuration and diagnostics 2026-08-23 00:40:36 +03:00
Onur Özkan ba9c97ed3b feat(errors): add stable error propagation 2026-08-23 00:40:17 +03:00
Onur Özkan 97acb2a2d7 docs: document board configuration workflow 2026-08-22 18:41:07 +03:00
Onur Özkan b7631c0bc7 feat(configurator): add web configuration console 2026-08-22 18:40:43 +03:00
Onur Özkan ceb2134392 build(f722): centralize linker layout 2026-08-22 18:38:06 +03:00
Onur Özkan 387fdaad23 refactor(firmware): centralize configuration and diagnostics 2026-08-22 18:37:18 +03:00
Onur Özkan 7048ed89d0 remove experimental ground-suite project
Signed-off-by: Onur Özkan <work@onurozkan.dev>
2026-08-17 19:04:39 +03:00
516 changed files with 67687 additions and 32523 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: Firmware gates
on:
pull_request:
push:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_BUILD_JOBS: 2
jobs:
verification:
name: Source-bound firmware verification
runs-on: ubuntu-24.04
timeout-minutes: 90
steps:
- name: Check out source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Nix
uses: cachix/install-nix-action@75c75c31ebece974e48ebc36b4ad125bd97bd97b # v31
- name: Fetch pinned dependencies
working-directory: firmware
run: nix develop .. -c cargo fetch --locked
- name: Install the coverage compiler and matching LLVM tools
run: nix develop -c rustup toolchain install nightly-2025-08-01 --profile minimal --component llvm-tools-preview
- name: Install manifest-pinned Miri and Kani tools
run: nix develop -c make verification-tools
- name: Host, target, board, coverage, Miri and Kani gates
run: nix develop -c make verification
- name: Retain verification evidence, including failed attempts
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: firmware-verification
path: |
firmware/target/verification/**
firmware/target/verification-tools/setup-*/*.log
if-no-files-found: warn
-41
View File
@@ -1,41 +0,0 @@
name: Host SITL smoke
on:
pull_request:
push:
workflow_dispatch:
permissions:
contents: read
jobs:
smoke:
name: Deterministic Gazebo command path
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- name: Check out source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check host-SITL contracts
working-directory: firmware
run: |
python3 -m unittest tools/sitl/test_kaptan_sim.py
python3 tools/sitl/world-structure-test.py
cargo check --locked -p kaptan-mavlink --no-default-features
cargo check --locked -p kaptan-log --no-default-features
cargo check --locked -p kaptan-log-mavlink --no-default-features
cargo test --locked -p kaptan-log --features facade
cargo test --locked -p kaptan-log-derive -p kaptan-log-mavlink
if rg -n '(^use log::|\blog::)' platforms/stm32f7/src/f722; then
echo 'the standard log facade is forbidden in F722 IRQ/panic paths'
exit 1
fi
if rg -n 'max_level_(off|error|warn|info|debug|trace)' --glob 'Cargo.toml' --glob '*.rs' .; then
echo 'log/max_level_* removes records before Kaptan can observe them'
exit 1
fi
cargo test --locked -p kaptan-sitl
- name: Run Docker closed-loop smoke
run: firmware/tools/sitl/e2e-smoke.sh
+19
View File
@@ -0,0 +1,19 @@
- Run the flight runtime from RTIC with physical sensor input.
- Persist vehicle settings and sensor calibration in board configuration.
- Carry IMU acquisition times, uncertainty and complete sample batches into navigation.
- Add range and optical-flow fusion for local navigation.
- Add hardware motor-output drivers using the existing DShot and PWM code.
- Connect motor outputs to arming, failsafe and command-expiry handling.
- Add private MAVLink commands for arming, disarming and onboard missions.
- Inhibit motors during configuration flash writes.
- Add onboard barometer drivers and shared I2C bus access.
- Add battery-voltage, current and analog RSSI acquisition.
- Add onboard flash storage and blackbox recording.
- Add analog OSD support.
- Add buzzer, LED-strip, camera-control and auxiliary-output drivers.
- Add receiver, GPS, VTX and ESC telemetry support to configurable ports.
- Complete board component identification and peripheral pin maps.
- Add hardware tests for sensor decoding, calibration, timing and recovery.
- Add hardware tests for motor shutdown on reset, missed deadlines and faults.
- Measure startup time, control-loop latency and peak stack under I/O load.
- Verify onboard takeoff, hover, landing and motor shutdown on hardware.
+88
View File
@@ -0,0 +1,88 @@
SHELL := /bin/sh
MAKEFLAGS += --no-print-directory
export CARGO_BUILD_JOBS ?= 2
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
.NOTPARALLEL:
.PHONY: help nix-shell build boards docs configurator fmt test app-native-test coverage verification verification-tools miri kani verification-inventory pre-commit check clean
help: ## Show the command interface (the default)
@printf '%s\n' 'Kaptan developer commands' '' \
'Usage: make <target>' '' \
' help Show this help' \
' nix-shell Enter the Nix development shell' \
' build Build all firmware and documentation' \
' boards Build and package every board' \
' docs Test and build the mdBook project' \
' configurator Start the local configurator web application' \
' fmt Check Rust, shell, Python, Nix, and TOML formatting' \
' test Run shared-library, host algorithm, and script tests' \
' app-native-test Check the app UTF conversion function with a host API stub' \
' coverage Required final strict-core line/branch coverage gate' \
' verification Run all gates and retain source-bound evidence' \
' verification-tools Install the pinned Miri and Kani tools' \
' miri Run the manifest-selected Miri checks' \
' kani Run the manifest-selected Kani proofs' \
' verification-inventory Show verification owners, claims and gaps' \
' pre-commit Run fmt, firmware clippy/test/docs, and mdBook checks' \
' check Run pre-commit plus whitespace checks' \
' clean Remove generated build artifacts' \
'' 'Component workflows' \
' make -C firmware help' \
' make -C firmware/boards/<board> build|flash' \
' make -C docs build|test|check|clean' \
'' 'Native tools (run from firmware/)' \
' make test' \
' cargo run --locked --offline -p kaptan-configurator -- --baud 115200'
nix-shell: ## Enter the reproducible Nix development shell
nix develop
build: boards ## Build all firmware boards and documentation
+@$(MAKE) -C docs build
boards:
+@$(MAKE) -C firmware/boards build
docs:
+@$(MAKE) -C docs check
configurator:
+@$(MAKE) -C firmware/tools/configurator run
fmt:
+@$(MAKE) -C firmware fmt
shfmt -d $$(rg --files -g '*.sh')
ruff format --check $$(rg --files -g '*.py')
ruff check firmware/scripts
nixfmt --check $$(rg --files -g '*.nix')
taplo format --check $$(rg --files -g '*.toml')
test:
+@$(MAKE) -C firmware test
+@$(MAKE) app-native-test
app-native-test:
python3 ground_suite/windows/runner/test_utf8_from_utf16.py \
--work-dir "$(or $(CARGO_TARGET_DIR),target)/native-tests/utf8"
coverage:
+@$(MAKE) -C firmware coverage
verification verification-tools miri kani verification-inventory:
+@$(MAKE) -C firmware $@
pre-commit: fmt test
+@$(MAKE) -C firmware clippy
+@$(MAKE) -C firmware docs
+@$(MAKE) -C docs check
@printf '%s\n' 'Pre-commit passed. Coverage was not run; make coverage is a required final gate.'
check: pre-commit ## Run pre-commit plus whitespace checks
git diff HEAD --check
clean: ## Remove firmware and documentation build artifacts
+@$(MAKE) -C firmware clean
+@$(MAKE) -C docs clean
+21 -7
View File
@@ -1,12 +1,26 @@
MDBOOK ?= mdbook
SHELL := /bin/sh
MAKEFLAGS += --no-print-directory
.PHONY: build serve test
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
.PHONY: help build test check clean
help:
@printf '%s\n' 'Usage: make -C docs build|test|check|clean' '' \
' build Build the book' \
' test Test book examples' \
' check Test and build the book' \
' clean Remove generated documentation' '' \
'For other options, run mdbook directly from docs/.'
build:
$(MDBOOK) build
serve:
$(MDBOOK) serve --open
mdbook build
test:
$(MDBOOK) test
mdbook test
check: test build
clean:
mdbook clean
+1 -1
View File
@@ -1,6 +1,6 @@
[book]
title = "Kaptan documentation"
description = "Architecture, board bring-up, simulation, and contributor guidance for Kaptan."
description = "Architecture, board bring-up, and contributor guidance for Kaptan."
authors = ["Kaptan contributors"]
language = "en"
src = "src"
-2
View File
@@ -9,9 +9,7 @@
- [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)
+166 -56
View File
@@ -10,73 +10,182 @@ board declaration
F722 platform / RTIC application ──► data channel ──► MAVLink telemetry
│
├── physical sensor drivers
└── motor-pin inhibit interlock
└── startup motor-pin inhibition
portable libraries
math ─► navigation ─► control ─► safety
Portable flight libraries (not connected to physical actuators)
FlightEndpoint ──► Navigation / control / phases / safety
│
└── caller-owned synchronous output sink
```
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.
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 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.
`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.
Configuration and runtime observability are separate, one-way planes:
Each board declares one `PortCatalog<N>::DESCRIPTORS` array. Both
`Ports<Catalog, N>` and `SerialInventory::new(&Catalog::DESCRIPTORS)` use it for
stable UART/field identities, supported roles, baud allowlists and resource
claims. Validation produces a boot-only `PortPlan<N>`. The same const `N` sizes
I/O and protocol storage: five UARTs on each current board. Absent UARTs
have no slots. A declared UART set to Disabled retains its slot but initializes
no peripheral or pins. USB is fixed normal MAVLink outside that inventory,
separate from ROM DFU.
Each MAVLink `Link` has its own scheduler, encoder and receive framing, with a
separate transport queue. A shared collector snapshots sensor mailboxes for all
links. See [MAVLink](mavlink.md) for scheduling and transport behavior.
`kaptan-flight-runtime::FlightEndpoint` runs Quad-X control and
`kaptan-navigation::Navigation` without allocation. Callers supply
observations, independently captured receipt/processing time, clock ticks and a
synchronous output sink. Transport and wire encoding stay outside the core.
The board images do not yet construct a flight endpoint.
See [Safety boundary](safety-boundary.md#shared-runtime-commit-contract) for
epochs, request sequencing and the output commit contract.
`FlightEndpoint::supervise` checks actual time, cached health and phase/stream
deadlines without advancing navigation, integrating control or consuming a
control release. It can inhibit and publish outcomes, but cannot renew enabled
output. Callers must service the endpoint with actual time even when input
stops. The core owns control release and deadline decisions.
Within `FlightEndpoint`, fusion, current prediction and initialization completion
run only when the 100-Hz control service is due. Early `tick` calls supervise
cached evidence without advancing navigation. Ingress admission and its
diagnostics remain immediate, but receiving or queuing fresh observations alone
does not renew capabilities. The actual control gap is checked before a due
navigation update can refresh evidence.
`Navigation` initializes from a stationary window of IMU, fixed-reference local
position/velocity, magnetic, pressure and range observations. It derives attitude
and a fixed launch origin from sensors, with explicit calibration/reference
identities. Its shared 16-state error covariance covers position, velocity,
attitude error, gyro bias, accelerometer bias and barometric datum. Bounded
delayed fusion and current prediction preserve observation timing and capability
evidence.
The navigation `sensor-snapshots` feature enables driver snapshot conversion.
The same flight estimator is available on host and embedded targets.
Navigation owns the 25-m/s² free-flight acceleration bound checked by
complete-profile validation. See
[Configuration](configuration.md#flight-configuration) for required installation
data and the supported vehicle model.
Configuration and diagnostics use separate paths:
```text
Kaptan GCS custom MAVLink V2 messages
│ (future: verified signing, source binding, replay state)
FILE_CONTROL / FILE_DATA
│
▼
kaptan-config-mavlink ──► state-only ConfigProtocol
│ borrows
kaptan-flight-runtime
├── one ConfigManager ──► kaptan-config ──► flight schema/CBOR
└── one kaptan-log hub
kaptan-file-mavlink ──► virtual FileProvider
│
kaptan-log-mavlink payload adapter
KCFG importer/exporter
│
platform-owned sole encoder / DMA TX link
ConfigManager ──► private store
kaptan-log / kaptan-err-reportable
│
▼
kaptan-diagnostics-mavlink ──► per-link encoder / bounded TX
```
The generated dialect is intentionally custom rather than a `PARAM_*` or QGC
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.
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.
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.
`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.
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.
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.
See [Configuration](configuration.md) for the deployment matrix and GCS upload
contract, [Logs](logs.md) for the receiver contract, and [Errors](errors.md)
## 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)
for derived-error and failure-reporting scope.
## Ownership rules
@@ -84,7 +193,8 @@ 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.
- 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.
- `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.
+58 -13
View File
@@ -1,21 +1,66 @@
# GEPRC GEP-F722-HD V2
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 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`.
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.
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.
Build and package from the firmware workspace:
## Build and package
From the repository root:
```sh
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
make -C firmware/boards/geprc-f722-v2 build
```
The resulting image is telemetry-only and has the shared physical motor-pin
inhibit boundary.
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.
+13 -3
View File
@@ -3,10 +3,20 @@
Board pages describe supported hardware topology, build products, and safe
verification. They do not declare a board flight-ready.
| Board | MCU family | Primary IMU | MAVLink UART |
| Board | IMU route | Serial inventory | Factory UART roles |
| --- | --- | --- | --- |
| [Kakute F7 Mini V3](kakute-f7-mini-v3.md) | STM32F722 | MPU-6000 | USART1 |
| [GEPRC GEP-F722-HD V2](geprc-f722-v2.md) | STM32F722 | ICM-42688-P | USART2 |
| [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.
All board images share the motor-pin inhibit boundary described in
[Safety boundary](../safety-boundary.md).
+42 -18
View File
@@ -1,43 +1,62 @@
# Kakute F7 Mini V3
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 Holybro Kakute F7 Mini V3 uses the MPU-6000 on SPI1.
The checked-in manufacturer datasheet is
`firmware/boards/kakute-f7-mini-v3/datasheet.pdf`.
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.
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.
## Build and package
From the firmware workspace:
From the repository root:
```sh
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
make -C firmware/boards/kakute-f7-mini-v3 build
```
The packaging script derives the matching binary and linker layout, then
prints the flash and RAM report.
The packaging script uses 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.
1. Disconnect USB.
2. Hold the board's `Boot` button.
3. Connect USB, then release `Boot` once the STM32 DFU device enumerates.
4. Flash the generated image:
4. Disconnect any other STM32 boards in DFU mode and verify that the intended
device is the listed `0483:df11` target.
5. Flash the generated image:
```sh
dfu-util --alt 0 --dfuse-address 0x08000000:leave --download target/thumbv7em-none-eabihf/release/kaptan-kakute-f7-mini-v3.bin
dfu-util --list
make -C firmware/boards/kakute-f7-mini-v3 flash
```
## Telemetry wiring
## MAVLink over USB
Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground:
After flashing, release `Boot` and reconnect USB so the application starts and
enumerates as `Kaptan / Kakute F7 Mini V3`, normally `/dev/ttyACM*` on Linux.
Then launch the target-described web UI:
```sh
make configurator
```
Open `http://127.0.0.1:4861` and connect `/dev/serial/by-id/<kaptan-device>` or
the discovered `/dev/ttyACM*` path from the Connection section.
## UART connection
First assign UART1 to `KaptanMavlink` over USB, save and restart. Then connect
a 3.3 V USB-UART adapter with crossed data lines and common ground:
| Adapter | Flight controller |
| --- | --- |
@@ -45,4 +64,9 @@ Use a 3.3 V USB-to-UART adapter with crossed data lines and common ground:
| `TX` | `R1` |
| `GND` | `G` |
This wiring is telemetry only. It does not enable motor control.
Enter `/dev/serial/by-id/<adapter>` in the same configurator's Connection section.
Use the UART's boot-active baud. If its role was changed or recovery disabled
UARTs, connect over normal USB. Follow [save/reset/reload](../configuration.md#web-configurator)
before expecting new settings to apply. Disconnect other transmitters from the
selected RX pad while configuring. Physical motor control is not yet implemented.
+280 -78
View File
@@ -1,102 +1,304 @@
# Configuration
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.
`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.
## Current deployment matrix
## Flight configuration
| Deployment | Configuration consumed today | Remote writes |
`kaptan-flight-config::CandidateProfile` owns the flight policy, gains, thrust
model, navigation noise densities and timing constraints. It is consumed by the
portable runtime and its tests. The board firmware uses its own board schema
and **KCFG v3** service.
Field IDs **6, 7 and 25** remain reserved.
The other field IDs retain their meanings and numbering.
Construct the candidate's raw sections and supply a `NavigationInstallation`
through `VehicleCapabilities::quad_x`. It declares sources, calibration and
reference identities, range mounting and quantization, contact geometry,
magnetic field and local pressure/temperature. Complete validation produces
the immutable `VehicleProfile`. The current runtime supports Quad-X allocation
and four equal contact spheres on a body-frame rectangle.
The current controller descriptors include fields 41..44:
| ID | Key | Meaning |
| --- | --- | --- |
| `kaptan-sitl serve --config FILE.cbor` | One validated Quad-X profile at process construction. | No live MAVLink endpoint is attached yet. |
| `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. |
| 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. |
The current SITL profile has only settings consumed by its estimator,
controller, safety supervisor, allocator envelope, or log hub:
Roll/pitch rate gains use the rate-PID fields. Derivative and contribution limits
are shared.
- 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.
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.
The fixed Quad-X mixer is intentionally not configurable. It is reviewed
capability data, not an operator-editable array.
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.
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.
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.
## Immutable F722 board policy
The 1.2-kg/18-N fixture and its installation values exist only in test support.
They do not calibrate physical sensors or identify a propulsion system.
The checked-in F722 boards consume these compiled `BoardConfig` settings before
peripheral initialization:
## Web configurator
- 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.
Build and flash the board image, boot normally, connect the board's USB port,
and run from the repository root:
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.
```sh
make configurator
```
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.
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.
## One CBOR profile format
The tool defaults to MAVLink source system `255` and baud `115200`.
`--source-system` and `--baud` are command-line overrides. Match the adapter baud
to the UART's boot-active value. For example, from `firmware/`, launch
`cargo run -p kaptan-configurator --locked --offline -- --baud 230400`.
Peers sharing a link need distinct nonzero source identities, but those
identities do not authenticate them.
There is one unreleased raw profile blob format:
1. Connect and wait for discovery to read both saved and boot-active artifacts.
Each field shows **Boot-active**, **Saved** and **Draft** values.
2. Edit the draft and press **Save**. The target checks the downloaded baseline,
validates the complete replacement and persists it. After success, the tool
reloads both artifacts to establish the next save's baseline.
3. To replace saved settings with the target's factory values, press
**Reset saved configuration**. This is a persistence operation, not a reboot.
It also works when saved state is unknown, provided active metadata was read.
4. Save and factory reset leave active roles, baud, identity, rates and logging
unchanged. When restart is required, manually reset or power-cycle the board,
then reconnect and check the reloaded values. A UART may now use another baud
or no longer carry MAVLink. Normal USB remains the configuration route.
- it is a CBOR map keyed by the discovered numeric `FieldId`s;
- 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 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.
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.
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.
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.
See [MAVLink](mavlink.md) and [Logs](logs.md) for the packet and runtime-log views.
## Future GCS upload contract
## Board ownership
The custom dialect is prepared for a first-party GCS without borrowing
`PARAM_*` semantics. A client will:
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.
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.
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.
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 adjacent `IMMUTABLE_CONFIG` manifest holds:
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.
- 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.
+119 -32
View File
@@ -1,46 +1,133 @@
# Development and verification
## Fast host checks
Use the locked Nix shell. Run Cargo from `firmware/`, which pins Rust 1.90.0.
Keep total Cargo concurrency at two jobs. `make help` lists repository commands.
## Setup and required gates
From the repository root:
```sh
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
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
```
## Target checks
Setup needs network access. Verification uses offline dependencies and runs
host, target, board, coverage, Miri and Kani gates sequentially. Kani runs one
harness at a time. Firmware CI runs
the same command and retains evidence on failure. Keep source fixed during a run.
| Command, inside `nix develop` | Scope |
| --- | --- |
| `make pre-commit` | Formatting, host tests, host/embedded Clippy, API docs and mdBook. Does not run coverage. |
| `make -C firmware target-checks` | Sensor-free consumers, the portable runtime on ARM, and linked RTIC cross-route fixtures. |
| `make boards` | Both release board images and final-image placement checks. |
| `make coverage` | Strict core line/branch gate and informational totals for other host crates. |
| `make miri` / `make kani` | Manifest-selected checks with their own evidence receipts. |
| `make verification-inventory` | Package ownership, property claims, assumptions and gaps. |
| `env CARGO_BUILD_JOBS=1 make -j1 -C docs check` | Test examples and render mdBook. Review changed links and anchors separately. |
Host gates exclude the two bare-metal boards and include platform contracts.
They enable `kaptan-navigation/sensor-snapshots`. Navigation itself is unconditional.
Host platform tests must not invoke MMIO. Rust 1.90.0 runs all doctests and
compile-pass/fail fixtures. The test gate also runs the configurator's Node tests,
Python script tests, release-LTO final-link cases and the UTF conversion helper.
Run the native helpers separately with `make -C firmware native-test` and
`make app-native-test`. The default artifact directories are
`firmware/target/native-tests/final-link/` and `target/native-tests/utf8/`.
`CARGO_TARGET_DIR` relocates them. The UTF helper uses a host
API stub and 16-bit `wchar_t`; it does not build the Windows runner. See the
[final-link fixture](../../firmware/libs/kaptan-err-reportable-macros/tests/final_link.md)
for its archive-extraction limit.
## Evidence and proof scope
[verification.toml](../../firmware/verification.toml) owns exact tool versions,
package/harness selection, required covers, claims, assumptions and gaps. Every
workspace package must have one verification area.
By default, each run creates a new directory under `firmware/target/verification/`. It retains
tracked and nonignored untracked source, hashes, Git state, commands, tool
identities, logs, Kani exports, successful coverage reports, board ELF/binary
artifacts and `receipt.json`. Source drift fails the receipt. To choose a fresh
output directory:
```sh
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
nix develop -c python3 firmware/scripts/verify.py all --output /tmp/kaptan-review
```
## Strict core coverage
Miri checks executed paths and schedules, not all interleavings or physical DMA.
Kani checks the manifest's component contracts with default checks and unwind
assertions enabled. Required covers must be satisfied. Missing results, timeouts,
unsupported operations and unexpected failures fail the gate.
The PID result has one qualification: finite operands can overflow to opposite
infinities whose sum is NaN. The implementation rejects that intermediate value
before changing state. Kani's raw verdict remains `Failure`. The runner accepts
only the exact declared diagnostic, function and location, with the contract
assertions and covers satisfied. Reviewed unreachable automatic checks are
recorded separately. No check is disabled or input domain narrowed. A passing
repository receipt therefore does not mean every raw Kani verdict passed.
These checks do not establish numerical accuracy, vehicle stability, arbitrary
safety histories, MMIO behavior or MCU timing. Kani's
[UB coverage](https://model-checking.github.io/kani/undefined-behaviour.html) and
[floating-point models](https://model-checking.github.io/kani/rust-feature-support.html#floating-point-operations)
have further limits. Source review and independent numerical evidence remain
required. No certification or tool qualification is claimed.
## Compiler contracts
| Contract | Enforcement and limit |
| --- | --- |
| Frames, rotation direction and position versus velocity | Distinct types and compile-fail examples. Raw sensor labeling still needs validation. |
| Allocator motor count | Const-generic dimensions. These do not establish physical mixer geometry. |
| Validated configuration and output authority | Private fields and distinct types, with compile-fail examples. Constructors and current admission policy remain responsible for validation. |
| Numerical-core unsafe operations | `forbid(unsafe_code)` in math, navigation, control, safety and runtime. Dependencies and generated export attributes remain separate boundaries. |
| Representation limits | Const assertions for navigation masks, field counts, runtime size and board storage. Object size does not measure peak stack. |
| Schemas and UART catalogs | Derive diagnostics and const validation. Received settings still need runtime validation. |
| Shared resources | Rust ownership and RTIC's resource graph. Device completion, barriers and MMIO validity remain hardware obligations. |
| Error IDs and flash-busy placement | Release final-link fixtures and packaged ELF checks, within their documented linker scope. |
Strict Clippy retains unsafe-operation and undocumented-unsafe-block lints.
A target or feature excluded from a build is not checked by that build.
## Coverage
`cargo-llvm-cov 0.8.7` comes from the locked Nix shell. The script selects
`nightly-2025-08-01`, its matching LLVM tools and the `dev` profile. Normal gates
keep Rust 1.90.0. Reports default to `firmware/target/test-coverage/`; an absolute
`CARGO_TARGET_DIR` relocates them. The verification runner uses a separate build directory.
Math, navigation, control and safety require 100% lines and branch outcomes.
Empty denominators fail. The summarizer unions LCOV source lines and LLVM branch
spans across generic instantiations. Other totals are informational, but any
test/report failure fails the gate. An absent instrumented host body is unavailable.
All host-testable members are included. Navigation enables `sensor-snapshots`
and MAVLink enables `sensor-telemetry`. Compiler-diagnostic fixtures remain on
Rust 1.90.0: coverage selects `--lib --test section` for `kaptan-config-derive`
and `--lib` for `kaptan-err-reportable-macros`. Their reported runtime coverage
does not establish compiler-phase macro coverage.
## Ground suite builds
The separate locked shell supplies Flutter, Dart and desktop dependencies:
```sh
cd firmware
scripts/report-test-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
```
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.
The app currently has no widget-test suite. Windows and macOS need their native
build and runtime checks.
+89 -34
View File
@@ -1,52 +1,107 @@
# Errors
Public Kaptan error enums derive the pure `KaptanError` implementation:
Public reportable Kaptan error enums derive `KaptanError`:
```rust
#[derive(Debug, kaptan_log::KaptanError)]
```rust,ignore
#[derive(Debug, kaptan_err_reportable::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 `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 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.
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.
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.
## What the receiver sees
- 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
- 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
their live owners are not yet attached to a MAVLink worker.
- `KAPTAN_SHUTDOWN`, defined in `definitions/kaptan/shutdown.xml`, represents
only a future completed orderly,
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.
F722 uses `panic-halt`, without live panic reporting 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 the next boot’s static board telemetry is available.
only after MAVLink is available on the next boot.
+31 -7
View File
@@ -4,27 +4,51 @@
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:
For host development, run:
```sh
nix develop
```
From the repository root, discover the developer commands with:
```sh
make help
```
Fetch the pinned Rust dependencies once before the offline checks:
```sh
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 fetch --locked
```
From the repository root, run the checks with:
```sh
make pre-commit
make coverage
```
Bare-metal board packages are not host-test targets. Check them with the
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/` | Immutable board declarations and linker layouts. |
| `firmware/platforms/stm32f7/` | Reviewed F722 RTIC platform and peripheral ownership. |
| `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/libs/` | Typed math, estimation, control, safety, protocol, and driver crates. |
| `firmware/tools/sitl/` | Host-only closed-loop Gazebo simulation. |
| `ground-suite/` | Desktop ground-control application. |
| `firmware/tools/configurator/` | Board-agnostic loopback web UI and serial bridge. |
| `docs/` | This Markdown documentation site. |
Do not add board-local READMEs. Add a page here instead, then link it from
-72
View File
@@ -1,72 +0,0 @@
# Ground Suite
`ground-suite/` is Kaptan's desktop Flutter application. Its primary
workspace is the Tactical Command surface: a spatial local scene, local units,
and session-local drawings and markers. The decoded diagnostic event stream is
always reachable from the tactical header.
## Application model
The core model keeps these concepts independent:
- **Vehicle definition** — immutable reviewed profile, catalog identity, and
bundled presentation media.
- **Local fleet unit** — an operator-created instance of a vehicle definition.
Local-unit IDs and in-memory planning placements belong to the workspace,
not the vehicle definition. Current placement is local presentation data and
is not yet persisted.
- **Received vehicle data** — transport input and diagnostic events. It cannot
create, position, identify, or authorize a local fleet unit.
This separation gives the fleet interface a stable path from local planning to
future live operation without coupling visual selection to incoming telemetry.
## Tactical workspace
The default route is Tactical Command. Its map-edge `UNITS`, `CURSOR`, and
`VIEW` groups are local interactions, not MAVLink controls. `Selection cursor`
is the default mode for map and rectangle selection. `Pencil cursor` creates
session-local freehand strokes; its top toolbar controls hue, saturation,
brightness, and thickness. `Marker cursor` has an independent full-color
style and creates a named local marker only after a map click and explicit
save. The shared disclosure keeps local scene data,
vehicle link state, and command authority separate. Local drawings and markers
are never reported vehicle positions.
`Add local unit` opens the horizontal local hangar. Choosing a definition only
prepares a local unit draft; the explicit setup confirmation creates and
selects a locally scattered scene unit, without asking the operator to click
the map. Local setup currently permits only the workspace label. Vehicle
configuration remains unavailable until verified binding, compatible schema,
command authority, and safe activation are available. This presentation
placement is temporary until reviewed MAVLink position data can drive a
distinct reported-position layer. The right inspector and bottom selection
console are available outside the map for keyboard and assistive-technology
access. These interactions create only session-local workspace data.
## Architecture
```text
VehicleDataPort -> typed observation updates -> observability state -> event stream
Vehicle catalog -> local-unit workspace state -> tactical scene and unit inspector
```
The transport path and local-fleet path intentionally meet only through future
explicit bindings. A future MAVLink adapter combines pinned MAVLink Common and
Kaptan dialect definitions at the decoding boundary.
## Follow-up work
- TODO(gcs-fleet-storage): versioned persistence for local units and catalog
identities.
- TODO(gcs-source-binding): explicit source-to-unit binding.
- TODO(gcs-map-pose): coordinate datum, timestamped pose, uncertainty, and
staleness handling for physical map placement.
- TODO(gcs-map-renderer): owned Linux GPU map host, pinned MapLibre assets,
and reviewed vector/DEM data source.
- TODO(gcs-platform-match): compare reported build/schema metadata to the
local definition.
- TODO(gcs-command-surface): add command interaction only with its reviewed
authority, target-binding, and safety contracts.
- TODO(gcs-catalog): generate installed definitions and category metadata from
reviewed firmware build manifests.
+10 -24
View File
@@ -1,36 +1,22 @@
# Overview
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.
Kaptan is an embedded Rust flight-controller project.
> **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.
> **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.
## Index
- New contributors should read [Getting started](getting-started.md).
- Firmware source and its Rust workspace begin in `firmware/`; the desktop
application is a sibling project in `ground-suite/`.
- Firmware source and its Rust workspace begin in `firmware/`.
- Board work belongs under [Boards](boards/index.md).
- 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.
- [Configuration](configuration.md) explains target-described user settings
and immutable F722 hardware policy.
- [Logs](logs.md) and [Errors](errors.md) describe MAVLink diagnostics.
- Before submitting changes, follow [Development and verification](development.md).
## Documentation conventions
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.
Keep documentation in `docs/src/`, add pages to `SUMMARY.md`, and place commands
beside their prerequisites.
+69 -48
View File
@@ -1,68 +1,89 @@
# Logs
Normal host/SITL task code can use the standard facade:
Logging macros work on host and flight targets:
```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
```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");
```
`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.
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.
## Receiver contract
`definitions/kaptan/log.xml`, `definitions/kaptan/error.xml`, and
`definitions/kaptan/shutdown.xml` separate ordinary logs, errors, and
orderly-shutdown observations:
`definitions/publish/log.xml` and `definitions/publish/error.xml` define the
separate ordinary-log and reportable-error publications:
```text
KAPTAN_LOG { level, flags, message_length, message[160] }
KAPTAN_ERROR {
flags, path_length, additional_information_length,
path[96], additional_information[64]
LOG { level, flags, message_length, message[160] }
ERROR {
error_id, raised_at[96]
}
KAPTAN_SHUTDOWN { reason }
```
A GCS decoder generated from the same `kaptan.xml` revision must validate
`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.
`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).
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.
`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.
## Filtering
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.
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).
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.
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.
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.
The log ingress is transport-neutral and unfiltered.
+93 -19
View File
@@ -1,30 +1,104 @@
# MAVLink and telemetry
Kaptan emits telemetry using MAVLink 2 framing. The production receiver does
not promote inbound MAVLink frames to authority records:
Kaptan uses MAVLink 2 with its private dialect. It does not support `common`,
`minimal` or other dialects. Reception follows these rules:
- MAVLink 1 is discarded.
- CRC-invalid MAVLink 2 frames are discarded.
- Signed frames are discarded because no signature verifier or persistent
replay state exists.
- Unsigned MAVLink 2 frames are also discarded; a V2 CRC detects corruption,
not authentication.
- 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.
`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.
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.
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.
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.
The custom Kaptan definitions are aggregated by `definitions/kaptan.xml` from
`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.
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.
+16 -23
View File
@@ -1,31 +1,24 @@
# Reference
This page collects stable limits and points to the detailed contracts.
| Surface | Current contract |
| --- | --- |
| 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. |
| 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. |
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.
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.
## Verification
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.
Use [Development and verification](development.md) for the required gates,
tool setup and evidence limits.
+77 -14
View File
@@ -1,25 +1,88 @@
# Safety boundary
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.
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.
There is no checked-in platform actuator route:
This is a firmware startup property, not a hardware latch. Reset temporarily
returns GPIO configuration to the MCU reset state, and the code cannot prove
external routing, pull resistors, ESC polarity or ESC power behavior. Board
support must document those electrical assumptions. Continuous inhibition
through reset requires board hardware that provides it.
- no DShot or PWM peripheral implementation is linked;
- 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.
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.
Run the structural guard after any platform or board change:
```sh
cd firmware
cargo test -p platform-stm32f7 --test platform_safety_boundary --offline
cargo test -p platform-stm32f7 \
--test platform_safety_boundary --locked --offline
```
This boundary is deliberately stronger than an application-level disarm flag:
it removes the physical actuator implementation from the platform image.
## 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.
-60
View File
@@ -1,60 +0,0 @@
# Host SITL
Host SITL is Kaptan's deterministic closed-loop simulation tier. Gazebo sends
simulated IMU and declared noisy local position/velocity observations over a
private loopback protocol. The Rust process runs the ESKF, safety supervisor,
trajectory, controller, and allocator, then returns four virtual motor
fractions to Gazebo.
It is a pre-bench software gate, not a physical-flight validation.
## Run the closed loop
```sh
cd firmware
docker compose -f tools/sitl/compose.yaml up -d --build
tools/sitl/kaptan-sim ready
tools/sitl/kaptan-sim move 5 3 1 --wait
tools/sitl/kaptan-sim land 0 0 --wait
docker compose -f tools/sitl/compose.yaml down
```
For the reproducible command-path gate, use:
```sh
cd firmware
tools/sitl/e2e-smoke.sh
```
The smoke scenario verifies ascent, translation, descent, landing, flat-plane
proximity, and zero applied motor fractions after landing. Its reference world
contains only a visual landing pad; the pad has no collision geometry and
never contributes a controller or truth observation.
Open <https://app.gazebosim.org/visualization>, connect to
`ws://localhost:9002`, and leave authorization blank to view the world.
## Quad-X profile
SITL starts through the same central `ConfigManager` composition used by the
host protocol tests. Create and validate a private host profile file with:
```sh
kaptan-sitl config write-default kaptan-sitl.cbor
kaptan-sitl config validate kaptan-sitl.cbor
kaptan-sitl serve --config kaptan-sitl.cbor
```
The file selects the restart-only profile used to build the estimator,
controller, allocator envelope, safety supervisor, and optional log filter at
process start. It is not the raw GCS upload blob. `kaptan-sitl serve` retains
the central manager for that boot but does not yet open a MAVLink config/log
endpoint; see [Configuration](configuration.md) for the exact upload groundwork
and safety boundary.
## Limits
This model does not validate terrain, vision, flow/range sensing, wind,
vibration, battery/ESC behavior, sensor latency/jitter, actuator faults, or
physical touchdown sensing. Add those only as a reviewed sensor/contact model,
not as a display-only shortcut.
-1
View File
@@ -1,3 +1,2 @@
target
.git
tools/hitl
+182 -144
View File
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
@@ -52,6 +52,7 @@ checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
dependencies = [
"bare-metal",
"bitfield",
"critical-section",
"embedded-hal",
"volatile-register",
]
@@ -98,6 +99,12 @@ 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"
@@ -110,7 +117,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
dependencies = [
"embedded-io",
"embedded-io 0.7.1",
]
[[package]]
@@ -125,12 +132,31 @@ 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"
@@ -151,18 +177,12 @@ 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",
]
@@ -178,19 +198,24 @@ dependencies = [
]
[[package]]
name = "kaptan-config-mavlink"
name = "kaptan-configurator"
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-log",
"kaptan-err-reportable",
"kaptan-math",
]
@@ -198,77 +223,44 @@ dependencies = [
name = "kaptan-data-channel"
version = "0.1.0"
dependencies = [
"kaptan-board",
"kaptan-dma",
"kaptan-log",
"critical-section",
"kaptan-err-reportable",
"kaptan-mavlink",
"kaptan-sensor",
"kaptan-time",
"kaptan-tx",
"mavlink-core",
]
[[package]]
name = "kaptan-dma"
name = "kaptan-diagnostics-mavlink"
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-flight-config"
name = "kaptan-err-reportable"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-log",
"minicbor",
"kaptan-err-reportable-macros",
]
[[package]]
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"
name = "kaptan-err-reportable-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
@@ -278,19 +270,70 @@ dependencies = [
]
[[package]]
name = "kaptan-log-mavlink"
name = "kaptan-file-mavlink"
version = "0.1.0"
dependencies = [
"kaptan-log",
"kaptan-mavlink",
"mavlink",
]
[[package]]
name = "kaptan-flight-config"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-control",
"kaptan-math",
"kaptan-navigation",
]
[[package]]
name = "kaptan-flight-runtime"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-control",
"kaptan-flight-config",
"kaptan-math",
"kaptan-navigation",
"kaptan-safety",
"kaptan-time",
]
[[package]]
name = "kaptan-geprc-f722-v2"
version = "0.1.0"
dependencies = [
"platform-stm32f7",
"rtic",
]
[[package]]
name = "kaptan-i2c"
version = "0.1.0"
dependencies = [
"kaptan-time",
]
[[package]]
name = "kaptan-kakute-f7-mini-v3"
version = "0.1.0"
dependencies = [
"platform-stm32f7",
"rtic",
]
[[package]]
name = "kaptan-log"
version = "0.1.0"
dependencies = [
"kaptan-config-derive",
]
[[package]]
name = "kaptan-math"
version = "0.1.0"
dependencies = [
"kaptan-log",
"kaptan-err-reportable",
]
[[package]]
@@ -298,12 +341,8 @@ 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",
@@ -314,17 +353,23 @@ 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-log",
"kaptan-err-reportable",
"kaptan-time",
]
@@ -335,27 +380,6 @@ 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"
@@ -369,23 +393,10 @@ name = "kaptan-tx"
version = "0.1.0"
[[package]]
name = "log"
version = "0.4.33"
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
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",
]
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "mavlink-bindgen"
@@ -409,7 +420,7 @@ checksum = "b31cc9f930c7edce0c1933659d405a330a28346cdf087bd283a56a6d72eb3d85"
dependencies = [
"byteorder",
"crc-any",
"embedded-io",
"embedded-io 0.7.1",
"embedded-io-async",
]
@@ -419,26 +430,6 @@ 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"
@@ -486,20 +477,27 @@ version = "0.1.0"
dependencies = [
"cortex-m",
"cortex-m-rt",
"kaptan-board",
"critical-section",
"kaptan-config",
"kaptan-data-channel",
"kaptan-dma",
"kaptan-diagnostics-mavlink",
"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]]
@@ -510,9 +508,9 @@ checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "proc-macro2"
version = "1.0.106"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -528,9 +526,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.46"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -549,9 +547,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.16"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
@@ -626,6 +624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@@ -645,7 +644,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -670,6 +669,12 @@ 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"
@@ -694,15 +699,26 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.0"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
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"
@@ -720,22 +736,22 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
"syn 3.0.3",
]
[[package]]
@@ -798,6 +814,28 @@ 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"
+11 -6
View File
@@ -3,31 +3,32 @@ members = [
"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-file-mavlink",
"libs/kaptan-data-channel",
"libs/kaptan-dma",
"libs/kaptan-drivers",
"libs/kaptan-err-reportable",
"libs/kaptan-err-reportable-macros",
"libs/kaptan-flight-config",
"libs/kaptan-flight-runtime",
"libs/kaptan-i2c",
"libs/kaptan-math",
"libs/kaptan-mavlink",
"libs/kaptan-log",
"libs/kaptan-log-derive",
"libs/kaptan-log-mavlink",
"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/sitl",
"tools/configurator",
]
exclude = ["vendor/synopsys-usb-otg"]
resolver = "2"
[workspace.package]
@@ -38,6 +39,7 @@ 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"
@@ -48,3 +50,6 @@ codegen-units = 1
lto = "fat"
panic = "abort"
strip = "symbols"
[patch.crates-io]
synopsys-usb-otg = { path = "vendor/synopsys-usb-otg" }
+96
View File
@@ -0,0 +1,96 @@
SHELL := /bin/sh
MAKEFLAGS += --no-print-directory
export CARGO_BUILD_JOBS ?= 2
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override BOARDS := geprc-f722-v2 kakute-f7-mini-v3
override BOARD_PACKAGES := $(addprefix kaptan-,$(BOARDS))
override BOARD_PACKAGE_ARGS := $(addprefix -p ,$(BOARD_PACKAGES))
override HOST_EXCLUDES := $(addprefix --exclude ,$(BOARD_PACKAGES))
override HOST_FEATURES := --features kaptan-navigation/sensor-snapshots
.NOTPARALLEL:
.PHONY: help fmt clippy test native-test docs coverage target-checks verification verification-tools miri kani verification-inventory pre-commit check clean
help:
@printf '%s\n' 'Usage: make -C firmware <target>' '' \
'Workspace targets:' \
' fmt Check Rust formatting' \
' clippy Run host and embedded Clippy checks' \
' test Run Rust and firmware script tests' \
' native-test Check cross-crate error IDs at Rust/C++ release final link' \
' docs Build Rust API documentation with warnings denied' \
' coverage Required final strict-core line/branch coverage gate' \
' verification Run all gates and retain source-bound evidence' \
' verification-tools Install the pinned Miri and Kani tools' \
' miri Run the manifest-selected Miri checks' \
' kani Run the manifest-selected Kani proofs' \
' verification-inventory Show verification owners, claims and gaps' \
' target-checks Check sensor-free consumers and link cross-route fixtures' \
' pre-commit Run fmt, clippy, test, and docs' \
' check Alias for pre-commit' \
' clean Remove Cargo build artifacts' '' \
'For other Cargo options, run cargo directly from firmware/.' \
'Host gates exclude bare-metal boards; navigation/sensor-snapshots is enabled.'
fmt:
cargo fmt --all --check
clippy:
cargo clippy --workspace --all-targets $(HOST_EXCLUDES) \
$(HOST_FEATURES) --locked --offline -- -D warnings
cargo clippy -p platform-stm32f7 --all-targets \
--locked --offline -- -D warnings
cargo clippy $(BOARD_PACKAGE_ARGS) $(addprefix --bin ,$(BOARD_PACKAGES)) --target thumbv7em-none-eabihf \
--locked --offline -- -D warnings
test:
cargo +1.90.0 test --workspace $(HOST_EXCLUDES) $(HOST_FEATURES) --locked --offline
cargo +1.90.0 test -p platform-stm32f7 -p synopsys-usb-otg --lib --locked --offline
node --test tools/configurator/web/view.test.cjs
python3 -m unittest scripts/test-analyze-fw.py \
scripts/test-summarize-test-coverage.py scripts/test-build-workflow.py \
scripts/test-verify.py
+@$(MAKE) native-test
native-test:
python3 libs/kaptan-err-reportable-macros/tests/final_link.py \
--work-dir "$(or $(CARGO_TARGET_DIR),target)/native-tests/final-link"
docs:
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps \
$(HOST_EXCLUDES) $(HOST_FEATURES) --locked --offline
coverage:
scripts/report-test-coverage.sh
verification:
python3 scripts/verify.py all
verification-tools:
python3 scripts/verify.py setup
miri kani:
python3 scripts/verify.py $@
verification-inventory:
python3 scripts/verify.py inventory
target-checks:
cargo check -p kaptan-configurator -p kaptan-diagnostics-mavlink --locked --offline
cargo check -p kaptan-mavlink -p kaptan-log --no-default-features --locked --offline
cargo check -p kaptan-flight-runtime --no-default-features \
--target thumbv7em-none-eabihf --locked --offline
RUSTFLAGS='-C link-arg=-Tlink.x -L platforms/stm32f7/link/f722' \
cargo build -p platform-stm32f7 --example mpu_dual_uart --example icm_dual_uart \
--release --target thumbv7em-none-eabihf --locked --offline
pre-commit: fmt clippy test docs
@printf '%s\n' 'Pre-commit passed. Coverage was not run; make coverage is a required final gate.'
check: pre-commit
clean:
cargo clean
+13 -14
View File
@@ -1,26 +1,25 @@
# Firmware
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.
Kaptan flight-controller firmware workspace.
```text
firmware/
├── boards/ immutable board declarations and linker layouts
├── boards/ board hardware declarations and entry points
├── libs/ reusable no_std firmware crates
├── platforms/ hardware platform composition
├── tools/ SITL and firmware development tooling
├── scripts/ packaging, analysis, and coverage helpers
└── tests/ firmware integration-test fixtures
├── platforms/ hardware composition, configuration and linker layouts
├── tools/ local configuration and telemetry tools
└── scripts/ packaging, analysis, and coverage helpers
```
Run Cargo commands from this directory:
List commands from this directory:
```sh
cd firmware
cargo test --workspace --locked --offline
make help
```
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.
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.
+31
View File
@@ -0,0 +1,31 @@
SHELL := /bin/sh
MAKEFLAGS += --no-print-directory
export CARGO_BUILD_JOBS ?= 2
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override EMBEDDED_TARGET := thumbv7em-none-eabihf
CARGO_TARGET_DIR ?= ../target
override TARGET_DIR := $(CARGO_TARGET_DIR)/$(EMBEDDED_TARGET)/release
override BOARDS := geprc-f722-v2 kakute-f7-mini-v3
override BOARD_PACKAGES := $(addprefix kaptan-,$(BOARDS))
override BOARD_PACKAGE_ARGS := $(addprefix -p ,$(BOARD_PACKAGES))
override BOARD_BIN_ARGS := $(addprefix --bin ,$(BOARD_PACKAGES))
.NOTPARALLEL:
.PHONY: help build
help:
@printf '%s\n' 'Usage: make -C firmware/boards build' '' \
' build Build and package every board in release mode' \
'CARGO_TARGET_DIR defaults to firmware/target; relative overrides are relative to firmware/boards/.' \
'For one board: make -C firmware/boards/<board> build|flash'
build:
cargo build --release --locked --offline $(BOARD_PACKAGE_ARGS) $(BOARD_BIN_ARGS) \
--target $(EMBEDDED_TARGET) --target-dir "$(CARGO_TARGET_DIR)"
@set -e; for board in $(BOARD_PACKAGES); do \
../scripts/create-dfu-image.sh "$(TARGET_DIR)/$$board"; \
done
+30
View File
@@ -0,0 +1,30 @@
SHELL := /bin/sh
MAKEFLAGS += --no-print-directory
export CARGO_BUILD_JOBS ?= 2
.DEFAULT_GOAL := help
.DELETE_ON_ERROR:
override EMBEDDED_TARGET := thumbv7em-none-eabihf
override PACKAGE := kaptan-$(BOARD)
CARGO_TARGET_DIR ?= ../../target
override ELF := $(CARGO_TARGET_DIR)/$(EMBEDDED_TARGET)/release/$(PACKAGE)
.NOTPARALLEL:
.PHONY: help build flash
help:
@printf '%s\n' 'Usage: make -C firmware/boards/$(BOARD) build|flash' '' \
' build Build and package $(PACKAGE) in release mode' \
' flash Build, package, and write $(PACKAGE) over DFU' '' \
'CARGO_TARGET_DIR defaults to firmware/target; relative overrides are relative to this board directory.' \
'For custom Cargo or DFU options, invoke cargo or dfu-util directly.'
build:
cargo build --release --locked --offline -p $(PACKAGE) --bin $(PACKAGE) \
--target $(EMBEDDED_TARGET) --target-dir "$(CARGO_TARGET_DIR)"
../../scripts/create-dfu-image.sh "$(ELF)"
flash: build
dfu-util --device 0483:df11 --alt 0 --dfuse-address 0x08000000:leave \
--download "$(ELF).bin"
+4 -1
View File
@@ -10,8 +10,11 @@ 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"
+3
View File
@@ -0,0 +1,3 @@
override BOARD := geprc-f722-v2
include ../board.mk
+6 -4
View File
@@ -1,9 +1,11 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rustc-link-arg=-Tlink.x");
let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("../../platforms/stm32f7/link/f722");
println!(
"cargo:rustc-link-search={}",
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
"cargo:rerun-if-changed={}",
layout.join("memory.x").display()
);
println!("cargo:rustc-link-arg=-Tlink.x");
println!("cargo:rustc-link-search={}", layout.display());
}
-19
View File
@@ -1,19 +0,0 @@
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
/*
* STM32F722 RAM totals 256 KiB:
* - DTCM: 64 KiB at 0x2000_0000
* - SRAM1: 176 KiB at 0x2001_0000
* - SRAM2: 16 KiB at 0x2003_C000
*
* SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2.
* They form one 192 KiB default RAM region from 0x2001_0000 through
* 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit
* placement policy rather than mixing CPU-critical and general allocations.
*
* TODO: Utilize DTCM.
*
*/
RAM : ORIGIN = 0x20010000, LENGTH = 192K
}
+23 -33
View File
@@ -1,45 +1,35 @@
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
//! Declarative support for the GEPRC GEP-F722-HD V2.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
#![forbid(unsafe_code)]
use platform_stm32f7::kaptan_ports::{PortCatalog, PortDescriptor};
use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
F7Board, F722Hardware, F722ImmutableConfig, F722UserConfig, Icm42688Spi3, ImuMount, MotorPins,
SerialInventory, StatusLed, UART1, UART2, UART3, UART4, UART6, VerticalSensor,
};
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
pub struct GeprcF722V2;
impl PortCatalog<5> for GeprcF722V2 {
const DESCRIPTORS: [PortDescriptor; 5] = [UART1, UART2, UART3, UART4, UART6];
}
impl F7Board for GeprcF722V2 {
const CONFIG: BoardConfig = BoardConfig {
type UserConfig = F722UserConfig<Self, 5>;
type ImmutableConfig = F722ImmutableConfig<Icm42688Spi3>;
const FACTORY_USER_CONFIG: Self::UserConfig = Self::UserConfig::FACTORY;
const IMMUTABLE_CONFIG: Self::ImmutableConfig = F722ImmutableConfig {
name: "GEPRC TAKER F722 V2 (ICM-42688-P)",
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,
hardware: F722Hardware::new(
Icm42688Spi3 {
mount: ImuMount::Yaw180,
},
maximum_log_level: Severity::Info,
}),
SerialInventory::new(&Self::DESCRIPTORS),
StatusLed::ActiveHighPa13,
VerticalSensor::None,
MotorPins::Four,
),
};
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
platform
.imu(Icm42688::on_spi3().with_drdy(Pc3))
.mavlink(Mavlink::on_usart2())
.vertical_sensor(VerticalSensor::none())
.build()
}
}
+1 -1
View File
@@ -3,4 +3,4 @@
mod board;
platform_stm32f7::flight_firmware!(board::GeprcF722V2);
platform_stm32f7::flight_firmware!(crate::board::GeprcF722V2);
+4 -4
View File
@@ -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"
@@ -0,0 +1,3 @@
override BOARD := kakute-f7-mini-v3
include ../board.mk
+6 -4
View File
@@ -1,9 +1,11 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rustc-link-arg=-Tlink.x");
let layout = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("../../platforms/stm32f7/link/f722");
println!(
"cargo:rustc-link-search={}",
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
"cargo:rerun-if-changed={}",
layout.join("memory.x").display()
);
println!("cargo:rustc-link-arg=-Tlink.x");
println!("cargo:rustc-link-search={}", layout.display());
}
@@ -1,19 +0,0 @@
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
/*
* STM32F722 RAM totals 256 KiB:
* - DTCM: 64 KiB at 0x2000_0000
* - SRAM1: 176 KiB at 0x2001_0000
* - SRAM2: 16 KiB at 0x2003_C000
*
* SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2.
* They form one 192 KiB default RAM region from 0x2001_0000 through
* 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit
* placement policy rather than mixing CPU-critical and general allocations.
*
* TODO: Utilize DTCM.
*
*/
RAM : ORIGIN = 0x20010000, LENGTH = 192K
}
+31 -40
View File
@@ -1,51 +1,42 @@
//! Declarative Kakute F7 Mini V3 board description.
//!
//! This module deliberately contains no MCU registers, RTIC concepts, unsafe
//! code, interrupt names, DMA handling, or task scheduling. Those concerns
//! belong to the STM32F7 platform crate.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
#![forbid(unsafe_code)]
use platform_stm32f7::kaptan_ports::{PortCatalog, PortDescriptor};
use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor,
AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware, F722ImmutableConfig,
F722UserConfig, GyroscopeRange, ImuMount, MotorPins, Mpu6000Config, Mpu6000Spi1,
SerialInventory, StatusLed, UART1, UART2, UART3, UART4, UART6, VerticalSensor,
};
/// Holybro Kakute F7 Mini V3.
pub struct KakuteF7MiniV3;
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 PortCatalog<5> for KakuteF7MiniV3 {
const DESCRIPTORS: [PortDescriptor; 5] = [UART1, UART2, UART3, UART4, UART6];
}
impl F7Board for KakuteF7MiniV3 {
const CONFIG: BoardConfig = CONFIG;
type UserConfig = F722UserConfig<Self, 5>;
type ImmutableConfig = F722ImmutableConfig<Mpu6000Spi1>;
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()
}
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,
),
};
}
@@ -2,4 +2,4 @@
#![no_main]
mod board;
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
platform_stm32f7::flight_firmware!(crate::board::KakuteF7MiniV3);
+37 -26
View File
@@ -18,15 +18,12 @@ impl Config {
rate: Rate,
armed_idle_throttle: Throttle,
arming_duration: ArmingDuration,
) -> Option<Self> {
if rate.frames_for_millis(arming_duration.milliseconds()) == 0 {
return None;
}
Some(Self {
) -> Self {
Self {
rate,
armed_idle_throttle,
arming_duration,
})
}
}
pub const fn rate(self) -> Rate {
@@ -62,17 +59,6 @@ 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)]
@@ -83,10 +69,12 @@ 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();
let ticks = (timer_hz + symbol_hz / 2) / symbol_hz;
if ticks < 3 || ticks > u16::MAX as u32 {
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 {
return None;
}
let bit_ticks = ticks as u16;
@@ -214,8 +202,7 @@ pub const fn symbols(packet: u16, zero_high: u16, one_high: u16) -> [u16; FRAME_
#[cfg(test)]
mod tests {
use super::{ArmSequence, Config, FrameValue, Rate, Throttle, Timing, packet, symbols};
use crate::ArmingDuration;
use super::{ArmSequence, FrameValue, Rate, Throttle, Timing, packet, symbols};
#[test]
fn packet_matches_standard_dshot_vectors() {
@@ -250,9 +237,33 @@ mod tests {
}
#[test]
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());
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
);
}
}
}
@@ -0,0 +1,121 @@
//! Integer wire and timing contracts.
use super::dshot::{FrameValue, Rate, Throttle, Timing, symbols};
fn any_rate() -> Rate {
match kani::any::<u8>() {
0 => Rate::Dshot150,
1 => Rate::Dshot300,
_ => Rate::Dshot600,
}
}
#[kani::proof]
fn dshot_frame_preserves_payload_and_checksum() {
let raw: u16 = kani::any();
let stop: bool = kani::any();
let frame = if stop {
FrameValue::Stop
} else {
let Some(throttle) = Throttle::new(raw) else {
assert!(!(48..=2_047).contains(&raw));
return;
};
assert!((48..=2_047).contains(&raw));
FrameValue::Throttle(throttle)
};
let packet = frame.packet();
assert_eq!(packet >> 5, if stop { 0 } else { raw });
assert_eq!(packet & 0x10, 0);
assert_eq!(
(packet ^ (packet >> 4) ^ (packet >> 8) ^ (packet >> 12)) & 0xf,
0
);
kani::cover!(stop);
kani::cover!(!stop && raw == 48);
kani::cover!(!stop && raw == 2_047);
}
#[kani::proof]
#[kani::unwind(17)]
fn dshot_symbols_round_trip_and_end_low() {
let packet: u16 = kani::any();
let zero: u16 = kani::any();
let one: u16 = kani::any();
let encoded = symbols(packet, zero, one);
let mut decoded = 0_u16;
for symbol in &encoded[..16] {
assert!(*symbol == zero || *symbol == one);
decoded = (decoded << 1) | u16::from(*symbol == one);
if zero == one {
assert_eq!(*symbol, zero);
}
}
if zero != one {
assert_eq!(decoded, packet);
}
assert_eq!(encoded[16], 0);
assert_eq!(encoded[17], 0);
// Equal levels are legal arguments. The framing function does not claim
// that every caller-supplied pair is physically distinguishable.
kani::cover!(zero == one);
kani::cover!(zero != one && packet != 0 && packet != u16::MAX);
}
#[kani::proof]
fn dshot_timer_rounding_keeps_compares_inside_period() {
let rate = any_rate();
let timer_hz: u32 = kani::any();
let timing = Timing::from_timer_clock(timer_hz, rate);
let requested = u64::from(timer_hz);
let symbol_hz = u64::from(rate.hertz());
if let Some(timing) = timing {
let represented = u64::from(timing.bit_ticks()) * symbol_hz;
// Nearest period, with half-symbol ties rounded upward.
assert!(2 * represented <= 2 * requested + symbol_hz);
assert!(2 * represented + symbol_hz > 2 * requested);
assert!(timing.zero_high_ticks() > 0);
assert!(timing.zero_high_ticks() < timing.one_high_ticks());
assert!(timing.one_high_ticks() < timing.bit_ticks());
} else {
// At all supported DShot rates u32::MAX still fits a u16 period.
// Therefore refusal is exactly below the minimum three-tick period.
assert!(2 * requested < 5 * symbol_hz);
}
kani::cover!(timing.is_none());
kani::cover!(timing.is_some() && timer_hz == u32::MAX);
}
#[kani::proof]
fn arming_duration_product() {
let milliseconds: u16 = kani::any();
let cadence_hz: u32 = kani::any();
let Some(duration) = super::ArmingDuration::from_millis(milliseconds) else {
assert!(milliseconds == 0 || milliseconds > 8_000);
return;
};
assert_eq!(duration.milliseconds(), milliseconds);
assert!((1..=8_000).contains(&milliseconds));
let requested = u64::from(duration.milliseconds()) * u64::from(cadence_hz);
assert!(requested <= 8_000 * u64::from(u32::MAX));
assert_eq!(requested == 0, cadence_hz == 0);
kani::cover!(milliseconds == 1 && cadence_hz == 0);
kani::cover!(milliseconds == 8_000 && cadence_hz == u32::MAX);
}
#[kani::proof]
fn arming_frame_count() {
let requested: u64 = kani::any();
let frames = super::frame_count(requested);
if let Some(frames) = frames {
let frames = u64::from(frames);
assert!(requested != 0 && frames != 0);
assert!(frames * 1_000 >= requested);
assert!((frames - 1) * 1_000 < requested);
} else {
assert!(requested == 0 || requested > u64::from(u16::MAX) * 1_000);
}
kani::cover!(requested == 0 && frames.is_none());
kani::cover!(frames.is_some() && requested % 1_000 != 0);
kani::cover!(requested == u64::MAX && frames.is_none());
}
+38 -6
View File
@@ -1,16 +1,16 @@
//! Protocol-neutral physical-motor protocol reference.
//! DShot/PWM framing and local arming sequences.
//!
//! This crate deliberately remains in the workspace for future, separately
//! 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.
//! This reference crate grants no peripheral access or flight authority and is
//! not linked by the current board images.
#![no_std]
pub mod dshot;
pub mod pwm;
#[cfg(kani)]
mod kani_proofs;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ArmingDuration(u16);
@@ -26,6 +26,18 @@ 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)]
@@ -33,3 +45,23 @@ 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)
);
}
}
+64 -3
View File
@@ -66,16 +66,22 @@ 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> {
if 1_000_000 % self.0 as u32 == 0 {
Some((1_000_000 / self.0 as u32) as 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)
} else {
None
}
}
/// Rounds up so the frames last at least the requested duration.
pub const fn frames_for_millis(self, milliseconds: u16) -> u16 {
((self.0 as u32 * milliseconds as u32) / 1_000) as u16
// Rate is at most 500 Hz. The product is at most 32,767,500 and
// the rounded-up result at most 32,768 for every u16 duration.
(self.0 as u32 * milliseconds as u32).div_ceil(1_000) as u16
}
}
@@ -184,4 +190,59 @@ mod tests {
assert!(Config::new(Rate::new(333).unwrap(), minimum, idle, duration).is_none());
assert!(Config::new(rate, 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);
}
}
}
}
-339
View File
@@ -1,339 +0,0 @@
//! Portable board-support contract for Kaptan.
//!
//! This crate describes logical board identity, sensing capability and
//! outbound data-channel policy. It intentionally has no dependency on an MCU
//! family, peripheral access crate, RTIC, or a runtime framework.
#![no_std]
use kaptan_log::KaptanError;
pub use kaptan_log::Severity;
/// Physical IMU device selected by a board declaration.
///
/// This is immutable board topology, not a runtime-selectable sensor driver.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImuDevice {
/// InvenSense MPU-6000 on the board's selected SPI route.
Mpu6000,
/// TDK ICM-42688-P on the board's selected SPI route.
Icm42688,
}
/// Fixed board-to-body IMU mounting transform.
///
/// Calibration remains a separate, safety-gated workflow. This declaration
/// only selects the compiled transform for the physical board route.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImuMount {
/// Sensor axes already match the controller body axes.
BodyAligned,
/// Sensor is yawed 180° relative to the controller body axes.
Yaw180,
}
/// Logical outbound MAVLink serial endpoint selected by a board declaration.
///
/// The platform maps these logical names to its checked pin/DMA routes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MavlinkPort {
/// First supported board serial endpoint.
Serial1,
/// Second supported board serial endpoint.
Serial2,
}
/// The vertical-sensing data a board support package makes available.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum VerticalSensingKind {
/// No range or optical-flow data is supported.
None,
/// Range data is supported.
Range,
/// Coherent range and optical-flow data are supported.
RangeAndOpticalFlow,
}
/// MAVLink endpoint identity selected by one board support package.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MavlinkEndpoint {
/// MAVLink system identifier.
pub system_id: u8,
/// MAVLink component identifier.
pub component_id: u8,
/// Immutable physical serial endpoint selected for this board image.
pub port: MavlinkPort,
/// UART line rate used by that physical endpoint.
pub baud: u32,
}
/// Fixed outbound data-channel rates driven by a platform service timer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataChannelRates {
/// Frequency of the timer that calls the data-channel scheduler.
pub service_hz: usize,
/// Raw-IMU publication rate.
pub raw_imu_hz: usize,
/// Range publication rate. `None` means range is not published.
pub range_hz: Option<usize>,
/// Optical-flow publication rate. `None` means flow is not published.
pub optical_flow_hz: Option<usize>,
/// Link heartbeat publication rate.
pub heartbeat_hz: usize,
}
/// Telemetry-only physical-sensor and outbound-data policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TelemetryProfile {
/// Immutable physical IMU driver selected by this board image.
pub imu: ImuDevice,
/// Immutable board-to-body transform applied to that IMU's samples.
pub imu_mount: ImuMount,
/// Supported physical vertical-sensing capability.
pub vertical_sensing: VerticalSensingKind,
/// Data-channel timer and publication rates.
pub data_channel: DataChannelRates,
/// Highest optional diagnostic severity forwarded to MAVLink.
///
/// Errors remain observable even when optional debug/info diagnostics are
/// filtered. This setting is compile-time board policy until the target
/// has the reviewed configuration activation path.
pub maximum_log_level: Severity,
}
/// Exclusive build-time image selection.
///
/// `ExternalControlOnly` remains representable for an explicit validation
/// rejection rather than silently falling back to telemetry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BoardProfile {
/// Physical-sensor telemetry image.
TelemetryOnly(TelemetryProfile),
/// Reserved and deliberately unimplemented production authority profile.
ExternalControlOnly,
}
/// Static board identity and exclusive build-time image configuration.
///
/// The selected platform trait determines the MCU family. Keeping a free-form
/// MCU string here would duplicate that type-level fact without validating it.
///
/// Board policy is compiled and non-deserializable. The current F722 platform
/// validates it against typestate-selected electrical routes before it touches
/// hardware. Target-side runtime configuration remains disabled until its
/// separate authenticated, journalled, disarmed activation contract exists.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BoardConfig {
/// Stable board name used in build and diagnostic tooling.
pub name: &'static str,
/// MAVLink endpoint identity.
pub mavlink: MavlinkEndpoint,
/// Exactly one immutable image profile.
pub profile: BoardProfile,
}
impl BoardConfig {
/// Returns physical telemetry configuration only for a telemetry image.
pub const fn telemetry(self) -> Option<TelemetryProfile> {
match self.profile {
BoardProfile::TelemetryOnly(profile) => Some(profile),
BoardProfile::ExternalControlOnly => None,
}
}
/// Validates the selected exclusive profile.
pub const fn validate(self) -> Result<(), BoardConfigError> {
if self.mavlink.system_id == 0 || self.mavlink.component_id == 0 {
return Err(BoardConfigError::InvalidMavlinkEndpoint);
}
if self.mavlink.baud == 0 {
return Err(BoardConfigError::ZeroMavlinkBaud);
}
match self.profile {
BoardProfile::TelemetryOnly(profile) => Self::validate_telemetry(profile),
BoardProfile::ExternalControlOnly => {
// TODO(flight-config): replace this hard rejection only with a
// complete runtime and atomic configuration activation. Required
// inputs are vehicle geometry, calibrated sensors, actuator
// limits, authenticated MAVLink-v2 authority, persistent replay
// state, estimator/controller/allocator readiness, and a
// verified output watchdog.
Err(BoardConfigError::UnsupportedFlightProfile)
}
}
}
const fn validate_telemetry(profile: TelemetryProfile) -> Result<(), BoardConfigError> {
let rates = profile.data_channel;
if rates.service_hz == 0 {
return Err(BoardConfigError::ZeroServiceFrequency);
}
if rates.raw_imu_hz == 0 || rates.heartbeat_hz == 0 {
return Err(BoardConfigError::MissingRequiredDataChannel);
}
if rates.raw_imu_hz > rates.service_hz || rates.heartbeat_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(mut scheduled_hz) = rates.raw_imu_hz.checked_add(rates.heartbeat_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
if let Some(range_hz) = rates.range_hz {
if range_hz == 0 || range_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(total) = scheduled_hz.checked_add(range_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
scheduled_hz = total;
}
if let Some(flow_hz) = rates.optical_flow_hz {
if flow_hz == 0 || flow_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(total) = scheduled_hz.checked_add(flow_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
scheduled_hz = total;
}
if scheduled_hz > rates.service_hz {
return Err(BoardConfigError::DataChannelOvercommitted);
}
let vertical_schedule_is_valid = match profile.vertical_sensing {
VerticalSensingKind::None
if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() =>
{
true
}
VerticalSensingKind::Range
if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() =>
{
true
}
VerticalSensingKind::RangeAndOpticalFlow
if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() =>
{
true
}
_ => false,
};
if !vertical_schedule_is_valid {
return Err(BoardConfigError::CapabilityDataChannelMismatch);
}
Ok(())
}
}
/// Invalid board-support configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BoardConfigError {
/// MAVLink system/component identifiers must be exact nonzero values.
InvalidMavlinkEndpoint,
/// A physical MAVLink endpoint needs a nonzero UART rate.
ZeroMavlinkBaud,
/// A data-channel schedule needs a nonzero service frequency.
ZeroServiceFrequency,
/// Raw IMU and heartbeat are mandatory on every flight board.
MissingRequiredDataChannel,
/// A configured stream is faster than its service timer.
RateExceedsServiceFrequency,
/// The schedule requests more than one frame per service tick.
DataChannelOvercommitted,
/// Declared vertical sensing and published streams disagree.
CapabilityDataChannelMismatch,
/// The selected operation profile has no complete runtime implementation.
UnsupportedFlightProfile,
}
#[cfg(test)]
mod tests {
use super::{
BoardConfig, BoardConfigError, BoardProfile, DataChannelRates, ImuDevice, ImuMount,
MavlinkEndpoint, MavlinkPort, TelemetryProfile, VerticalSensingKind,
};
use kaptan_log::Severity;
const BASE: BoardConfig = BoardConfig {
name: "test",
mavlink: MavlinkEndpoint {
system_id: 1,
component_id: 1,
port: MavlinkPort::Serial1,
baud: 115_200,
},
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
imu: ImuDevice::Mpu6000,
imu_mount: ImuMount::BodyAligned,
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
data_channel: DataChannelRates {
service_hz: 100,
raw_imu_hz: 20,
range_hz: Some(20),
optical_flow_hz: Some(20),
heartbeat_hz: 2,
},
maximum_log_level: Severity::Info,
}),
};
#[test]
fn accepts_a_schedulable_range_and_flow_board() {
assert_eq!(BASE.validate(), Ok(()));
}
#[test]
fn rejects_streams_that_exceed_the_service_budget() {
let mut config = BASE;
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
unreachable!();
};
profile.data_channel.optical_flow_hz = Some(80);
assert_eq!(
config.validate(),
Err(BoardConfigError::DataChannelOvercommitted)
);
}
#[test]
fn rejects_capability_and_data_channel_mismatch() {
let mut config = BASE;
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
unreachable!();
};
profile.vertical_sensing = VerticalSensingKind::Range;
assert_eq!(
config.validate(),
Err(BoardConfigError::CapabilityDataChannelMismatch)
);
}
#[test]
fn rejects_external_control_until_its_runtime_exists() {
let mut config = BASE;
config.profile = BoardProfile::ExternalControlOnly;
assert_eq!(
config.validate(),
Err(BoardConfigError::UnsupportedFlightProfile)
);
}
#[test]
fn rejects_invalid_immutable_link_policy() {
let mut config = BASE;
config.mavlink.baud = 0;
assert_eq!(config.validate(), Err(BoardConfigError::ZeroMavlinkBaud));
let mut config = BASE;
config.mavlink.component_id = 0;
assert_eq!(
config.validate(),
Err(BoardConfigError::InvalidMavlinkEndpoint)
);
}
}
@@ -0,0 +1,186 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::{
Attribute, Data, DeriveInput, Expr, Fields, Ident, Lit, LitStr, Result, Token,
punctuated::Punctuated, spanned::Spanned,
};
const MAX_CONFIG_ENUM_LABEL_BYTES: usize = 32;
pub(crate) fn expand(input: DeriveInput) -> Result<TokenStream> {
if !input.generics.params.is_empty() {
return Err(syn::Error::new(
input.generics.span(),
"ConfigEnum does not support generic enums",
));
}
require_u8_representation(&input.attrs, input.ident.span())?;
if input
.attrs
.iter()
.any(|attribute| attribute.path().is_ident("config_enum"))
{
return Err(syn::Error::new(
input.ident.span(),
"config_enum options belong on enum variants",
));
}
let Data::Enum(data) = input.data else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigEnum can only be derived for enums",
));
};
if data.variants.is_empty() {
return Err(syn::Error::new(
input.ident.span(),
"ConfigEnum requires at least one variant",
));
}
if data.variants.len() > usize::from(u8::MAX) {
return Err(syn::Error::new(
input.ident.span(),
"ConfigEnum supports at most 255 variants",
));
}
let mut variants = Vec::with_capacity(data.variants.len());
let mut values = Vec::with_capacity(data.variants.len());
let mut labels = Vec::with_capacity(data.variants.len());
for variant in data.variants {
if !matches!(variant.fields, Fields::Unit) {
return Err(syn::Error::new(
variant.fields.span(),
"ConfigEnum variants cannot contain fields",
));
}
let Some((_, discriminant)) = variant.discriminant else {
return Err(syn::Error::new(
variant.ident.span(),
"ConfigEnum variants require explicit u8 discriminants",
));
};
let value = discriminant_value(&discriminant)?;
if values.contains(&value) {
return Err(syn::Error::new(
discriminant.span(),
"duplicate ConfigEnum discriminant",
));
}
let label = variant_label(&variant.attrs, &variant.ident)?;
if labels
.iter()
.any(|previous: &LitStr| previous.value() == label.value())
{
return Err(syn::Error::new(
label.span(),
"duplicate ConfigEnum display label",
));
}
variants.push(variant.ident);
values.push(value);
labels.push(label);
}
let ident = input.ident;
Ok(quote! {
impl #ident {
/// Canonical stable values and display labels for generic
/// configuration discovery.
pub const CONFIG_ENUM_OPTIONS: &'static [(u8, &'static str)] = &[
#((#values, #labels)),*
];
/// Returns this variant's stable compact representation.
pub const fn wire_value(self) -> u8 {
match self {
#(Self::#variants => #values),*
}
}
/// Decodes one stable compact representation.
pub const fn try_from_wire(value: u8) -> Option<Self> {
match value {
#(#values => Some(Self::#variants),)*
_ => None,
}
}
}
})
}
fn require_u8_representation(attributes: &[Attribute], span: proc_macro2::Span) -> Result<()> {
let representations = attributes
.iter()
.filter(|attribute| attribute.path().is_ident("repr"))
.collect::<Vec<_>>();
if representations.len() != 1 {
return Err(syn::Error::new(
span,
"ConfigEnum requires exactly #[repr(u8)]",
));
}
let arguments =
representations[0].parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
if arguments.len() != 1 || !arguments[0].eq("u8") {
return Err(syn::Error::new(
representations[0].span(),
"ConfigEnum requires exactly #[repr(u8)]",
));
}
Ok(())
}
fn discriminant_value(expression: &Expr) -> Result<u8> {
let Expr::Lit(expression) = expression else {
return Err(syn::Error::new(
expression.span(),
"ConfigEnum discriminants must be u8 integer literals",
));
};
let Lit::Int(literal) = &expression.lit else {
return Err(syn::Error::new(
expression.span(),
"ConfigEnum discriminants must be u8 integer literals",
));
};
literal.base10_parse::<u8>().map_err(|_| {
syn::Error::new(
literal.span(),
"ConfigEnum discriminant is outside the u8 domain",
)
})
}
fn variant_label(attributes: &[Attribute], ident: &Ident) -> Result<LitStr> {
let mut label = None;
for attribute in attributes
.iter()
.filter(|attribute| attribute.path().is_ident("config_enum"))
{
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("label") {
if label.is_some() {
return Err(meta.error("duplicate ConfigEnum display label"));
}
label = Some(meta.value()?.parse::<LitStr>()?);
return Ok(());
}
Err(meta.error("the only variant config_enum option is label = \"...\""))
})?;
}
let label = label.unwrap_or_else(|| LitStr::new(&ident.to_string(), ident.span()));
if label.value().is_empty() || label.value().contains('\0') {
return Err(syn::Error::new(
label.span(),
"ConfigEnum display labels must be non-empty text without NUL bytes",
));
}
if label.value().len() > MAX_CONFIG_ENUM_LABEL_BYTES {
return Err(syn::Error::new(
label.span(),
"ConfigEnum display label exceeds the 32-byte wire limit",
));
}
Ok(label)
}
+198 -137
View File
@@ -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_sections(ident, generics, crate_path, post_validate, fields),
ModelKind::Sections => expand_children(ident, generics, crate_path, post_validate, fields),
}
}
@@ -29,46 +29,72 @@ fn expand_scalars(
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let 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)?;
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)?;
checks.extend(field_checks.iter().cloned());
let id = field.id.expect("scalar configuration fields have IDs");
let ty = &field.ty;
let field_ident = &field.ident;
defaults.push(quote! { #field_ident: #default });
let (kind, read, decode, out_of_domain_rule) = if !field.enum_backed {
snapshot_widths.push(quote! { <#ty as #crate_path::ConfigScalar>::KIND.width() });
(
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
quote! { #crate_path::ConfigScalar::into_config_value(self.#field_ident) },
quote! { <#ty as #crate_path::ConfigScalar>::try_from_config_value(value) },
quote! { #crate_path::ValidationRule::AboveMaximum },
)
} else {
snapshot_widths.push(quote! { 1usize });
(
quote! { #crate_path::ValueKind::U8 },
quote! { #crate_path::ConfigValue::U8(self.#field_ident.wire_value()) },
quote! {
match value {
#crate_path::ConfigValue::U8(value) => <#ty>::try_from_wire(value)
.ok_or(#crate_path::ScalarValueError::OutOfDomain),
value => Err(#crate_path::ScalarValueError::WrongKind(value.kind())),
}
},
quote! { #crate_path::ValidationRule::InvalidEnumValue },
)
};
reads.push(quote! {
#id => Some(#value(self.#field_ident)),
#id => Some(#read),
});
writes.push(quote! {
#id => {
let next = match value {
#value(next) => next,
actual => {
let next = match #decode {
Ok(next) => next,
Err(#crate_path::ScalarValueError::WrongKind(actual)) => {
return Err(#crate_path::FieldWriteError::WrongType {
field: #crate_path::FieldId::new(#id),
expected: #kind,
actual: actual.kind(),
actual,
});
}
Err(#crate_path::ScalarValueError::OutOfDomain) => {
return Err(#crate_path::FieldWriteError::Violation(
#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#out_of_domain_rule,
),
));
}
};
let previous = self.#field_ident;
self.#field_ident = next;
if let Err(violation) =
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
{
let validation = (|| -> Result<(), #crate_path::ConfigViolation> {
#(#field_checks)*
Ok(())
})();
if let Err(violation) = validation {
self.#field_ident = previous;
return Err(#crate_path::FieldWriteError::Violation(violation));
}
@@ -78,30 +104,33 @@ fn expand_scalars(
}
let post_validate = post_validate.map(|hook| quote! { #hook(self)?; });
Ok(quote! {
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
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)*
Ok(())
}
}
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),*
];
}
impl #impl_generics #crate_path::ConfigSection for #ident #type_generics #where_clause {
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);
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
let section = Self {
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&section)?;
Ok(section)
fn descriptor(index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
Self::DESCRIPTORS.get(index)
}
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
@@ -122,15 +151,16 @@ fn expand_scalars(
}
}
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#checks)*
fn validate(&self) -> Result<(), #crate_path::ConfigViolation> {
self.__validate_local_configuration()?;
#post_validate
Ok(())
}
}
})
}
fn expand_sections(
fn expand_children(
ident: syn::Ident,
generics: syn::Generics,
crate_path: syn::Path,
@@ -138,71 +168,58 @@ fn expand_sections(
fields: Vec<crate::parse::FieldModel>,
) -> Result<TokenStream> {
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
let validations = fields.iter().map(|field| {
let mut validations = Vec::new();
let mut reads = Vec::new();
let mut writes = Vec::new();
let mut counts = Vec::new();
let mut snapshot_widths = Vec::new();
let mut schema_ids = Vec::new();
let mut descriptor_lookups = Vec::new();
for field in &fields {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
}
validations.push(quote! {
<#ty as #crate_path::Config>::validate(&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(
reads.push(quote! {
if let Some(value) = <#ty as #crate_path::Config>::field_value(
&self.#field_ident,
id,
) {
return Some(value);
}
}
});
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(
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(
&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::ValidateFields for #ident #type_generics #where_clause {
fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#validations)*
#post_validate
Ok(())
}
}
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 #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 descriptor(mut index: usize) -> Option<&'static #crate_path::FieldDescriptor> {
#(#descriptor_lookups)*
None
}
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
@@ -218,23 +235,39 @@ fn expand_sections(
#(#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: Option<TokenStream>,
descriptor: TokenStream,
checks: Vec<TokenStream>,
}
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
let mut checks = Vec::new();
let Some(id) = field.id else {
return Ok(FieldMetadata {
descriptor: None,
checks,
});
};
let id = field.id.expect("scalar configuration fields have IDs");
let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value),
_ => None,
@@ -243,28 +276,67 @@ 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()));
checks.push(expand_check(
crate_path,
&field.ident,
id,
check.clone(),
float,
));
}
let key = field.display_key.as_ref().expect("id fields have a key");
let unit = field.unit.as_ref().expect("id fields have a unit");
let default = field.default.as_ref().expect("id fields have a default");
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
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 minimum = option_value_tokens(value.clone(), minimum);
let maximum = option_value_tokens(value.clone(), maximum);
Ok(FieldMetadata {
descriptor: Some(quote! {
descriptor: quote! {
#crate_path::FieldDescriptor {
id: #crate_path::FieldId::new(#id),
display_key: #key,
kind: #kind,
minimum: #minimum,
maximum: #maximum,
default: #value(#default),
enum_options: #enum_options,
unit: #unit,
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
finite: #finite,
nonzero: #nonzero,
}
}),
},
checks,
})
}
@@ -276,10 +348,7 @@ fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> To
}
}
fn config_value_tokens(
crate_path: &syn::Path,
ty: &syn::Type,
) -> Result<(TokenStream, TokenStream)> {
fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Result<TokenStream> {
let syn::Type::Path(path) = ty else {
return Err(syn::Error::new_spanned(
ty,
@@ -292,42 +361,34 @@ fn config_value_tokens(
"configuration metadata requires a scalar type",
));
};
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 },
),
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 },
_ => {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
));
return Ok(quote! { #crate_path::ConfigValue::U32 });
}
};
Ok((kind, value))
Ok(value)
}
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
fn expand_check(
crate_path: &syn::Path,
field: &syn::Ident,
id: u16,
check: Check,
float: bool,
) -> TokenStream {
let zero = if float {
quote! { 0.0 }
} else {
quote! { 0 }
};
match check {
Check::Finite => quote! {
if !self.#field.is_finite() {
@@ -338,7 +399,7 @@ fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Chec
}
},
Check::Nonzero => quote! {
if self.#field == 0 {
if self.#field == #zero {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::Zero,
+23 -4
View File
@@ -1,17 +1,36 @@
//! Procedural validation derive for Kaptan configuration records.
//! Procedural derive for recursively composable Kaptan configuration records.
mod config_enum;
mod expand;
mod parse;
use proc_macro::TokenStream;
use syn::parse_macro_input;
/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers.
#[proc_macro_derive(ConfigValidate, attributes(config))]
pub fn derive_config_validate(input: TokenStream) -> TokenStream {
/// 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 {
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(),
}
}
+127 -191
View File
@@ -3,6 +3,9 @@ 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,
@@ -24,9 +27,8 @@ 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) section: bool,
pub(crate) enum_backed: bool,
}
#[derive(Clone)]
@@ -43,19 +45,19 @@ impl Model {
let Data::Struct(data) = input.data else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate can only be derived for structs",
"Config can only be derived for structs",
));
};
let Fields::Named(fields) = data.fields else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate requires named struct fields",
"Config requires named struct fields",
));
};
if fields.named.is_empty() {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate requires at least one configured field or section",
"Config requires at least one field",
));
}
let mut models = Vec::with_capacity(fields.named.len());
@@ -73,38 +75,63 @@ impl Model {
}
models.push(model);
}
let kind = if models.iter().all(|field| field.section) {
ModelKind::Sections
} else if models.iter().all(|field| field.id.is_some()) {
let kind = if models.iter().all(|field| field.id.is_some()) {
ModelKind::Scalars
} else if models.iter().all(|field| field.id.is_none()) {
ModelKind::Sections
} else {
let field = models
.iter()
.find(|field| field.section || field.id.is_none())
.find(|field| field.id.is_none())
.expect("mixed configuration model has one invalid field");
return Err(syn::Error::new(
field.ident.span(),
"ConfigValidate records must contain either scalar config fields or config(section) fields exclusively",
"Config records must contain either scalar fields or child configuration records exclusively",
));
};
if kind == ModelKind::Scalars {
let mut display_keys = Vec::with_capacity(models.len());
for field in &models {
if field.display_key.is_none() {
let Some(display_key) = &field.display_key else {
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",
));
}
if field.unit.is_none() {
display_keys.push(display_key);
let Some(unit) = &field.unit else {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(unit = \"...\")",
));
}
if field.default.is_none() {
};
validate_metadata_text(unit, MAX_UNIT_BYTES, "configuration unit", true)?;
let primitive = scalar_type_name(&field.ty).is_some();
if field.enum_backed && primitive {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(default = ...)",
field.ty.span(),
"config(enum) requires a ConfigEnum-derived enum type",
));
}
if !field.enum_backed && !primitive {
return Err(syn::Error::new(
field.ty.span(),
"non-primitive configuration fields require config(enum)",
));
}
validate_static_constraints(field)?;
@@ -121,6 +148,34 @@ impl Model {
}
}
fn validate_metadata_text(
literal: &syn::LitStr,
maximum_bytes: usize,
name: &str,
allow_empty: bool,
) -> Result<()> {
let value = literal.value();
if !allow_empty && value.is_empty() {
return Err(syn::Error::new(
literal.span(),
format!("{name} must not be empty"),
));
}
if value.contains('\0') {
return Err(syn::Error::new(
literal.span(),
format!("{name} cannot contain NUL bytes"),
));
}
if value.len() > maximum_bytes {
return Err(syn::Error::new(
literal.span(),
format!("{name} exceeds the {maximum_bytes}-byte wire limit"),
));
}
Ok(())
}
fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option<Path>)> {
let mut crate_path = syn::parse_quote!(::kaptan_config);
let mut has_crate_path = false;
@@ -160,40 +215,17 @@ 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 section = false;
let mut enum_backed = 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"));
@@ -220,13 +252,11 @@ fn parse_field(field: Field) -> Result<FieldModel> {
unit = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("default") {
if default.is_some() {
return Err(meta.error("duplicate configuration default"));
if meta.path.is_ident("enum") {
if enum_backed {
return Err(meta.error("duplicate enum configuration marker"));
}
let expression = meta.value()?.parse()?;
validate_default_literal(&expression, &field.ty)?;
default = Some(expression);
enum_backed = true;
return Ok(());
}
if meta.path.is_ident("finite") {
@@ -273,26 +303,16 @@ fn parse_field(field: Field) -> Result<FieldModel> {
return Ok(());
}
Err(meta.error(
"supported field config rules are id, key, unit, default, finite, nonzero, and range",
"supported field config rules are id, key, unit, enum, finite, nonzero, and range",
))
})?;
}
if !section && !checks.is_empty() && id.is_none() {
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())
if id.is_none()
&& (display_key.is_some() || unit.is_some() || enum_backed || !checks.is_empty())
{
return Err(syn::Error::new(
ident.span(),
"config(section) cannot be combined with scalar id, metadata, or validation rules",
"scalar configuration fields require config(id = N)",
));
}
Ok(FieldModel {
@@ -301,9 +321,8 @@ fn parse_field(field: Field) -> Result<FieldModel> {
id,
display_key,
unit,
default,
checks,
section,
enum_backed,
})
}
@@ -353,33 +372,19 @@ fn validate_numeric_literal(expression: &Expr) -> Result<()> {
}
}
fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> {
if scalar_type_name(ty) == Some("bool") {
return match expression {
Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()),
_ => Err(syn::Error::new(
expression.span(),
"bool configuration defaults must be bool literals",
)),
};
}
validate_numeric_literal(expression)
}
fn validate_static_constraints(field: &FieldModel) -> Result<()> {
let ty = scalar_type_name(&field.ty).ok_or_else(|| {
syn::Error::new(
let Some(ty) = scalar_type_name(&field.ty) else {
if field.checks.is_empty() {
return Ok(());
}
return Err(syn::Error::new(
field.ty.span(),
"configuration metadata requires a supported scalar type",
)
})?;
"validation rules require a supported primitive 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,
@@ -406,70 +411,6 @@ 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(())
}
@@ -487,14 +428,6 @@ 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> {
@@ -519,6 +452,26 @@ 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(|_| {
@@ -529,13 +482,15 @@ fn scalar_literal(expression: &Expr, ty: &str) -> Result<ScalarLiteral> {
})?;
Ok(ScalarLiteral::Unsigned(value))
}
"f32" => Ok(ScalarLiteral::Float(f64::from(
float_literal(expression)? as f32
))),
"f64" => float_literal(expression).map(ScalarLiteral::Float),
// 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),
_ => Err(syn::Error::new(
expression.span(),
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
"configuration metadata supports bool, i32, u8, u16, u32, u64, f32, and f64",
)),
}
}
@@ -589,7 +544,11 @@ fn unsigned_literal(expression: &Expr) -> Result<u128> {
}
}
fn float_literal(expression: &Expr) -> Result<f64> {
fn float_literal<T>(expression: &Expr) -> Result<T>
where
T: std::str::FromStr + core::ops::Neg<Output = T>,
T::Err: core::fmt::Display,
{
let (negative, literal) = match expression {
Expr::Lit(expression) => (false, &expression.lit),
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
@@ -610,10 +569,10 @@ fn float_literal(expression: &Expr) -> Result<f64> {
};
let value = match literal {
Lit::Int(value) => value
.base10_parse::<f64>()
.base10_parse::<T>()
.map_err(|error| syn::Error::new(value.span(), error))?,
Lit::Float(value) => value
.base10_parse::<f64>()
.base10_parse::<T>()
.map_err(|error| syn::Error::new(value.span(), error))?,
_ => {
return Err(syn::Error::new(
@@ -648,30 +607,14 @@ fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
}
fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> {
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"
)
}) {
if scalar_type_name(ty)
.is_some_and(|name| matches!(name, "f32" | "f64" | "i32" | "u8" | "u16" | "u32" | "u64"))
{
Ok(())
} else {
Err(syn::Error::new(
span.span(),
"nonzero and range require a primitive numeric field",
"nonzero and range require a supported primitive numeric field",
))
}
}
@@ -691,18 +634,11 @@ fn scalar_type_name(ty: &Type) -> Option<&str> {
ident if ident == "bool" => "bool",
ident if ident == "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::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
use kaptan_config_derive::ConfigValidate;
use kaptan_config::{Config, ConfigViolation};
#[derive(ConfigValidate)]
#[derive(Config)]
struct Child {
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
#[config(id = 1, key = "child.value", unit = "count", range(min = 0, max = 2))]
value: u32,
}
#[derive(ConfigValidate)]
#[derive(Config)]
#[config(post = Self::validate_complete)]
struct Root {
#[config(section)]
child: Child,
}
impl Root {
fn validate_complete(&self) -> Result<(), ConfigViolation> {
self.child.validate_fields()
self.child.validate()
}
}
fn main() {
let root = Root::default_aggregate().unwrap();
let root = Root {
child: Child { value: 1 },
};
assert_eq!(Root::FIELD_COUNT, 1);
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
}
@@ -0,0 +1,25 @@
use kaptan_config_derive::{Config, ConfigEnum};
#[derive(Clone, Copy, ConfigEnum)]
#[repr(u8)]
enum Mode {
Safe = 1,
#[config_enum(label = "Full authority")]
FullAuthority = 7,
}
#[derive(Config)]
struct EnumConfig {
#[config(enum, id = 1, key = "mode", unit = "mode")]
mode: Mode,
}
fn main() {
assert_eq!(Mode::Safe.wire_value(), 1);
assert!(matches!(Mode::try_from_wire(7), Some(Mode::FullAuthority)));
assert_eq!(Mode::CONFIG_ENUM_OPTIONS[1], (7, "Full authority"));
assert_eq!(
EnumConfig::DESCRIPTORS[0].enum_options,
Mode::CONFIG_ENUM_OPTIONS
);
}
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct RootBooleanConfig {
#[config(id = 1, key = "enabled", unit = "bool", default = true)]
#[config(id = 1, key = "enabled", unit = "bool")]
enabled: bool,
}
@@ -1,37 +1,22 @@
use kaptan_config::{
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
ValidateFields, ValidationRule,
Config, ConfigEnum, ConfigValue, ConfigViolation, FieldId, FieldWriteError, ValidationRule,
};
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
#[derive(Config)]
struct LocalSection {
#[config(
id = 1,
key = "local.first",
unit = "count",
default = 2,
range(min = 1, max = 9)
)]
#[config(id = 1, key = "local.first", unit = "count", range(min = 1, max = 9))]
first: u32,
#[config(
id = 2,
key = "local.second",
unit = "count",
default = 4,
range(min = 1, max = 9)
)]
#[config(id = 2, key = "local.second", unit = "count", range(min = 1, max = 9))]
second: u32,
}
#[derive(ConfigValidate)]
#[derive(Config)]
#[config(post = Self::validate_order)]
struct OrderedSection {
#[config(
id = 3,
key = "ordered.minimum",
unit = "count",
default = 1,
range(min = 0, max = 9)
)]
minimum: u32,
@@ -39,7 +24,6 @@ struct OrderedSection {
id = 4,
key = "ordered.maximum",
unit = "count",
default = 2,
range(min = 0, max = 9)
)]
maximum: u32,
@@ -58,17 +42,51 @@ impl OrderedSection {
}
}
#[derive(ConfigValidate)]
#[derive(Config)]
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::default_section().unwrap();
let mut section = LocalSection {
first: 2,
second: 4,
};
assert_eq!(LocalSection::FIELD_COUNT, 2);
assert_eq!(
section.field_value(FieldId::new(2)),
@@ -94,9 +112,67 @@ fn generated_section_owns_every_declared_scalar_path() {
);
}
#[test]
fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
let mut section = CompactSection {
endpoint: 7,
mode: TestMode::Quiet,
port: 4861,
};
assert_eq!(CompactSection::SNAPSHOT_VALUE_BYTES, 1 + 1 + 2);
assert_eq!(
section.field_value(FieldId::new(20)),
Some(ConfigValue::U8(7))
);
assert_eq!(
CompactSection::DESCRIPTORS[0].minimum,
Some(ConfigValue::U8(1))
);
assert_eq!(CompactSection::DESCRIPTORS[0].maximum, None);
assert_eq!(
CompactSection::DESCRIPTORS[1].enum_options,
TestMode::CONFIG_ENUM_OPTIONS
);
assert_eq!(TestMode::Quiet.wire_value(), 1);
assert_eq!(TestMode::try_from_wire(3), Some(TestMode::Loud));
assert_eq!(TestMode::try_from_wire(2), None);
section
.set_field(FieldId::new(21), ConfigValue::U8(3))
.unwrap();
assert_eq!(section.mode, TestMode::Loud);
assert!(matches!(
section.set_field(FieldId::new(21), ConfigValue::U8(2)),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::InvalidEnumValue
));
assert!(matches!(
section.set_field(FieldId::new(20), ConfigValue::U8(0)),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::BelowMinimum
));
assert_eq!(
section.field_value(FieldId::new(22)),
Some(ConfigValue::U16(4861))
);
assert_eq!(
CompactSection::DESCRIPTORS[2].kind,
kaptan_config::ValueKind::U16
);
}
#[test]
fn aggregate_routes_writes_and_defers_declared_post_validation() {
let mut aggregate = Aggregate::default_aggregate().unwrap();
let mut aggregate = Aggregate {
local: LocalSection {
first: 2,
second: 4,
},
ordered: OrderedSection {
minimum: 1,
maximum: 2,
},
};
assert_eq!(Aggregate::FIELD_COUNT, 4);
assert_eq!(
aggregate.field_value(FieldId::new(2)),
@@ -106,7 +182,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
.set_field(FieldId::new(3), ConfigValue::U32(3))
.unwrap();
assert!(matches!(
aggregate.validate_fields(),
aggregate.validate(),
Err(issue)
if issue.primary() == Some(FieldId::new(3))
&& issue.related_field() == Some(FieldId::new(4))
@@ -115,5 +191,190 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
aggregate
.set_field(FieldId::new(4), ConfigValue::U32(3))
.unwrap();
aggregate.validate_fields().unwrap();
aggregate.validate().unwrap();
}
#[test]
fn aggregate_derives_its_flattened_descriptor_order() {
assert_eq!(Aggregate::SNAPSHOT_VALUE_BYTES, 4 * 4);
let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT)
.map(|index| Aggregate::descriptor(index).unwrap())
.collect();
assert_eq!(
descriptors
.iter()
.map(|descriptor| descriptor.id.get())
.collect::<Vec<_>>(),
[1, 2, 3, 4]
);
assert!(Aggregate::descriptor(Aggregate::FIELD_COUNT).is_none());
}
#[test]
fn factory_values_are_owned_outside_the_generated_schema() {
let mut aggregate = BoardOwnedAggregate {
endpoint: BoardOwnedSection { endpoint: 42 },
};
assert_eq!(BoardOwnedAggregate::FIELD_COUNT, 1);
assert_eq!(
aggregate.field_value(FieldId::new(10)),
Some(ConfigValue::U32(42))
);
aggregate
.set_field(FieldId::new(10), ConfigValue::U32(43))
.unwrap();
aggregate.validate().unwrap();
}
#[derive(Config)]
struct DuplicateChildren {
first: LocalSection,
second: LocalSection,
}
#[derive(Config)]
struct TransparentRoot {
local: LocalSection,
}
#[derive(Config)]
struct FirstKeyOwner {
#[config(id = 30, key = "shared.key", unit = "count")]
value: u32,
}
#[derive(Config)]
struct SecondKeyOwner {
#[config(id = 31, key = "shared.key", unit = "count")]
value: u32,
}
#[derive(Config)]
struct DuplicateKeysAcrossChildren {
first: FirstKeyOwner,
second: SecondKeyOwner,
}
#[test]
fn schema_fingerprint_is_generated_and_one_child_wrappers_are_transparent() {
assert_eq!(TransparentRoot::SCHEMA_ID, LocalSection::SCHEMA_ID);
assert_ne!(Aggregate::SCHEMA_ID, LocalSection::SCHEMA_ID);
}
#[test]
fn recursive_tree_rejects_duplicate_field_ids() {
let duplicate = DuplicateChildren {
first: LocalSection {
first: 2,
second: 4,
},
second: LocalSection {
first: 2,
second: 4,
},
};
assert!(matches!(
duplicate.validate(),
Err(issue) if issue.rule() == ValidationRule::Inconsistent
));
}
#[test]
fn recursive_tree_rejects_duplicate_display_keys() {
let duplicate = DuplicateKeysAcrossChildren {
first: FirstKeyOwner { value: 1 },
second: SecondKeyOwner { value: 2 },
};
assert!(matches!(
duplicate.validate(),
Err(issue) if issue.rule() == ValidationRule::Inconsistent
));
}
#[derive(Config)]
struct FloatRules {
#[config(id = 1, key = "finite", unit = "", finite)]
finite: f32,
#[config(id = 2, key = "nonzero", unit = "", nonzero)]
nonzero: f64,
#[config(id = 3, key = "lower", unit = "", range(min = 0.0))]
lower: f32,
#[config(id = 4, key = "upper", unit = "", range(max = 1.0))]
upper: f64,
#[config(id = 5, key = "free", unit = "")]
free: f64,
#[config(id = 6, key = "nonzero32", unit = "", nonzero)]
nonzero32: f32,
}
#[test]
fn scalar_rules_preserve_local_repair_and_float_domains() {
let mut values = FloatRules {
finite: f32::NAN,
nonzero: 0.0,
lower: f32::NAN,
upper: f64::NAN,
free: f64::NAN,
nonzero32: 0.0,
};
// Every other field is invalid. A local repair must still succeed.
values
.set_field(FieldId::new(1), ConfigValue::F32(1.0))
.unwrap();
for (id, value, rule) in [
(
1,
ConfigValue::F32(f32::INFINITY),
ValidationRule::NonFinite,
),
(2, ConfigValue::F64(0.0), ValidationRule::Zero),
(2, ConfigValue::F64(-0.0), ValidationRule::Zero),
(3, ConfigValue::F32(f32::NAN), ValidationRule::Unordered),
(4, ConfigValue::F64(f64::NAN), ValidationRule::Unordered),
(6, ConfigValue::F32(0.0), ValidationRule::Zero),
(6, ConfigValue::F32(-0.0), ValidationRule::Zero),
] {
let id = FieldId::new(id);
let before = values.field_value(id);
assert_eq!(
values.set_field(id, value),
Err(FieldWriteError::Violation(ConfigViolation::field(id, rule)))
);
assert_eq!(values.field_value(id), before);
}
// Neither nonzero nor a one-sided range implies finite.
for (id, value) in [
(2, ConfigValue::F64(f64::from_bits(0xfff0_0000_0000_0042))),
(3, ConfigValue::F32(f32::INFINITY)),
(4, ConfigValue::F64(f64::NEG_INFINITY)),
(5, ConfigValue::F64(f64::from_bits(0x7ff8_0000_0000_1234))),
(6, ConfigValue::F32(f32::INFINITY)),
] {
values.set_field(FieldId::new(id), value).unwrap();
assert_eq!(values.field_value(FieldId::new(id)), Some(value));
}
values.validate().unwrap();
}
#[test]
fn rule_metadata_changes_fingerprints() {
let descriptor = FloatRules::DESCRIPTORS[0];
assert!(descriptor.finite);
assert!(!descriptor.nonzero);
assert!(FloatRules::DESCRIPTORS[1].nonzero);
let original = kaptan_config::validation::__schema_id(&[descriptor]);
assert_ne!(
original,
kaptan_config::validation::__schema_id(&[kaptan_config::FieldDescriptor {
finite: false,
..descriptor
}])
);
assert_ne!(
original,
kaptan_config::validation::__schema_id(&[kaptan_config::FieldDescriptor {
nonzero: true,
..descriptor
}])
);
}
@@ -0,0 +1,10 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum ImplicitDiscriminant {
First = 1,
Second,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum variants require explicit u8 discriminants
--> tests/ui/config_enum_implicit_discriminant.rs:7:5
|
7 | Second,
| ^^^^^^
@@ -0,0 +1,10 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum LabelWithNul {
#[config_enum(label = "invalid\0label")]
Value = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum display labels must be non-empty text without NUL bytes
--> tests/ui/config_enum_label_nul.rs:6:27
|
6 | #[config_enum(label = "invalid\0label")]
| ^^^^^^^^^^^^^^^^
@@ -0,0 +1,10 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum LabelTooManyBytes {
#[config_enum(label = "şşşşşşşşşşşşşşşşş")]
Value = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum display label exceeds the 32-byte wire limit
--> tests/ui/config_enum_label_too_many_bytes.rs:6:27
|
6 | #[config_enum(label = "şşşşşşşşşşşşşşşşş")]
| ^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,8 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
enum MissingRepresentation {
First = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum requires exactly #[repr(u8)]
--> tests/ui/config_enum_missing_repr.rs:4:6
|
4 | enum MissingRepresentation {
| ^^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigEnum;
#[derive(ConfigEnum)]
#[repr(u8)]
enum Payload {
Value(u8) = 1,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigEnum variants cannot contain fields
--> tests/ui/config_enum_payload.rs:6:10
|
6 | Value(u8) = 1,
| ^^^^
@@ -1,9 +0,0 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct DefaultBelowMinimum {
#[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
value: u32,
}
fn main() {}
@@ -1,5 +0,0 @@
error: configuration default is below its declared minimum
--> tests/ui/default_below_minimum.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
| ^
@@ -1,9 +0,0 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct NonFiniteDefault {
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
value: f32,
}
fn main() {}
@@ -1,5 +0,0 @@
error: configuration default violates finite
--> tests/ui/default_violates_finite.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
| ^^^^^
@@ -1,9 +0,0 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct ZeroDefault {
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
value: u32,
}
fn main() {}
@@ -1,5 +0,0 @@
error: configuration default violates nonzero
--> tests/ui/default_violates_nonzero.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
| ^
@@ -0,0 +1,13 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct DisplayKeyTooLong {
#[config(
id = 1,
key = "configuration.display.key.with.more.than.forty.eight.bytes",
unit = "count"
)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration display key exceeds the 48-byte wire limit
--> tests/ui/display_key_too_long.rs:7:15
|
7 | key = "configuration.display.key.with.more.than.forty.eight.bytes",
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,11 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct DuplicateDisplayKey {
#[config(id = 1, key = "duplicate.key", unit = "count")]
first: u32,
#[config(id = 2, key = "duplicate.key", unit = "count")]
second: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate configuration display key
--> tests/ui/duplicate_display_key.rs:7:28
|
7 | #[config(id = 2, key = "duplicate.key", unit = "count")]
| ^^^^^^^^^^^^^^^
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct DuplicateId {
#[config(id = 7, nonzero)]
first: u32,
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct DuplicateRange {
#[config(id = 7, range(min = 1), range(min = 2))]
value: u32,
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct Empty {}
fn main() {}
@@ -1,4 +1,4 @@
error: ConfigValidate requires at least one configured field or section
error: Config requires at least one field
--> tests/ui/empty.rs:4:8
|
4 | struct Empty {}
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct PrimitiveEnumMarker {
#[config(enum, id = 1, key = "mode", unit = "mode")]
mode: u8,
}
fn main() {}
@@ -0,0 +1,5 @@
error: config(enum) requires a ConfigEnum-derived enum type
--> tests/ui/enum_marker_on_primitive.rs:6:11
|
6 | mode: u8,
| ^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct InvertedNativeBounds {
#[config(id = 1, key = "value", unit = "", range(min = 1.0000000596046448, max = 1.0))]
value: f32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration range minimum exceeds maximum
--> tests/ui/f32_double_rounded_bounds.rs:6:5
|
6 | value: f32,
| ^^^^^
@@ -0,0 +1,15 @@
use kaptan_config_derive::{Config, ConfigEnum};
#[derive(Clone, Copy, ConfigEnum)]
#[repr(u8)]
enum Mode {
Safe = 1,
}
#[derive(Config)]
struct MissingEnumMarker {
#[config(id = 1, key = "mode", unit = "mode")]
mode: Mode,
}
fn main() {}
@@ -0,0 +1,5 @@
error: non-primitive configuration fields require config(enum)
--> tests/ui/missing_enum_marker.rs:12:11
|
12 | mode: Mode,
| ^^^^
@@ -1,6 +1,6 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct MissingId {
#[config(nonzero)]
value: u32,
@@ -1,4 +1,4 @@
error: validated configuration fields require config(id = N)
error: scalar configuration fields require config(id = N)
--> tests/ui/missing_id.rs:6:5
|
6 | value: u32,
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct MissingMetadata {
#[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))]
#[config(id = 7, unit = "count", range(min = 0, max = 10))]
value: u32,
}
@@ -1,10 +1,9 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct MixedSectionAndScalar {
#[config(section)]
child: u32,
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
#[config(id = 1, key = "scalar", unit = "count", range(min = 0, max = 2))]
scalar: u32,
}
@@ -1,5 +1,5 @@
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
--> tests/ui/mixed_section_scalar.rs:6:5
error: Config records must contain either scalar fields or child configuration records exclusively
--> tests/ui/mixed_section_scalar.rs:5:5
|
6 | child: u32,
5 | child: u32,
| ^^^^^
@@ -0,0 +1,24 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct NonzeroBool {
#[config(id = 1, key = "value", unit = "")]
#[config(nonzero)]
value: bool,
}
#[derive(Config)]
struct RangedBool {
#[config(id = 1, key = "value", unit = "")]
#[config(range(min = 0, max = 1))]
value: bool,
}
#[derive(Config)]
struct FiniteBool {
#[config(id = 1, key = "value", unit = "")]
#[config(finite)]
value: bool,
}
fn main() {}
@@ -0,0 +1,17 @@
error: nonzero and range require a supported primitive numeric field
--> tests/ui/numeric_rules_on_bool.rs:6:14
|
6 | #[config(nonzero)]
| ^^^^^^^
error: nonzero and range require a supported primitive numeric field
--> tests/ui/numeric_rules_on_bool.rs:13:14
|
13 | #[config(range(min = 0, max = 1))]
| ^^^^^
error: finite requires an f32 or f64 field
--> tests/ui/numeric_rules_on_bool.rs:20:14
|
20 | #[config(finite)]
| ^^^^^^
@@ -0,0 +1,31 @@
use kaptan_config_derive::{Config, ConfigEnum};
#[derive(Clone, Copy, ConfigEnum)]
#[repr(u8)]
enum Mode {
Off = 0,
On = 1,
}
#[derive(Config)]
struct NonzeroEnum {
#[config(enum, id = 1, key = "value", unit = "")]
#[config(nonzero)]
value: Mode,
}
#[derive(Config)]
struct RangedEnum {
#[config(enum, id = 1, key = "value", unit = "")]
#[config(range(min = 0, max = 1))]
value: Mode,
}
#[derive(Config)]
struct FiniteEnum {
#[config(enum, id = 1, key = "value", unit = "")]
#[config(finite)]
value: Mode,
}
fn main() {}
@@ -0,0 +1,17 @@
error: nonzero and range require a supported primitive numeric field
--> tests/ui/numeric_rules_on_enum.rs:13:14
|
13 | #[config(nonzero)]
| ^^^^^^^
error: nonzero and range require a supported primitive numeric field
--> tests/ui/numeric_rules_on_enum.rs:20:14
|
20 | #[config(range(min = 0, max = 1))]
| ^^^^^
error: finite requires an f32 or f64 field
--> tests/ui/numeric_rules_on_enum.rs:27:14
|
27 | #[config(finite)]
| ^^^^^^
@@ -1,8 +1,8 @@
use kaptan_config_derive::ConfigValidate;
use kaptan_config_derive::Config;
#[derive(ConfigValidate)]
#[derive(Config)]
struct InvalidRange {
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
#[config(id = 1, key = "value", unit = "count", range(min = 3, max = 1))]
value: u32,
}
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct UnitTooLong {
#[config(id = 1, key = "value", unit = "seventeen_bytes___")]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration unit exceeds the 16-byte wire limit
--> tests/ui/unit_too_long.rs:5:44
|
5 | #[config(id = 1, key = "value", unit = "seventeen_bytes___")]
| ^^^^^^^^^^^^^^^^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::Config;
#[derive(Config)]
struct UnsupportedDefault {
#[config(id = 1, key = "value", unit = "count", default = 1)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: supported field config rules are id, key, unit, enum, finite, nonzero, and range
--> tests/ui/unsupported_default.rs:5:53
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1)]
| ^^^^^^^
@@ -0,0 +1,10 @@
use kaptan_config_derive::Config;
#[derive(Config)]
#[config(external_defaults)]
struct UnsupportedExternalDefaults {
#[config(id = 1, key = "value", unit = "count")]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: struct config options are crate = path and post = path
--> tests/ui/unsupported_external_defaults.rs:4:10
|
4 | #[config(external_defaults)]
| ^^^^^^^^^^^^^^^^^
Loaded 100 of 516 files, more files were not shown because too many files have changed in this diff. Show more