refactor: move firmware workspace under firmware

This commit is contained in:
Onur Özkan committed 2026-08-10 16:58:55 +03:00
1 parent 153b0bf262
commit f03fc119a4
222 files changed
+56 -13

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
target
.git
tools/hitl
+856
View File
@@ -0,0 +1,856 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bare-metal"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3"
dependencies = [
"rustc_version",
]
[[package]]
name = "bitfield"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cortex-m"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
dependencies = [
"bare-metal",
"bitfield",
"embedded-hal",
"volatile-register",
]
[[package]]
name = "cortex-m-rt"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6"
dependencies = [
"cortex-m-rt-macros",
]
[[package]]
name = "cortex-m-rt-macros"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "crc-any"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c"
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "embedded-hal"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff"
dependencies = [
"nb 0.1.3",
"void",
]
[[package]]
name = "embedded-io"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7"
[[package]]
name = "embedded-io-async"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
dependencies = [
"embedded-io",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
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-log",
]
[[package]]
name = "kaptan-config-derive"
version = "0.1.0"
dependencies = [
"kaptan-config",
"proc-macro2",
"quote",
"syn 2.0.119",
"trybuild",
]
[[package]]
name = "kaptan-config-mavlink"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-log",
"kaptan-mavlink",
]
[[package]]
name = "kaptan-control"
version = "0.1.0"
dependencies = [
"kaptan-log",
"kaptan-math",
]
[[package]]
name = "kaptan-data-channel"
version = "0.1.0"
dependencies = [
"kaptan-board",
"kaptan-dma",
"kaptan-log",
"kaptan-mavlink",
"kaptan-sensor",
"kaptan-time",
]
[[package]]
name = "kaptan-dma"
version = "0.1.0"
[[package]]
name = "kaptan-drivers"
version = "0.1.0"
dependencies = [
"kaptan-i2c",
"kaptan-log",
"kaptan-sensor",
"kaptan-spi",
"kaptan-time",
]
[[package]]
name = "kaptan-flight-config"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-log",
"minicbor",
]
[[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"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"trybuild",
]
[[package]]
name = "kaptan-log-mavlink"
version = "0.1.0"
dependencies = [
"kaptan-log",
"kaptan-mavlink",
"mavlink",
]
[[package]]
name = "kaptan-math"
version = "0.1.0"
dependencies = [
"kaptan-log",
]
[[package]]
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",
"num-traits",
]
[[package]]
name = "kaptan-navigation"
version = "0.1.0"
dependencies = [
"kaptan-log",
"kaptan-math",
"kaptan-sensor",
"kaptan-time",
]
[[package]]
name = "kaptan-safety"
version = "0.1.0"
dependencies = [
"kaptan-log",
"kaptan-time",
]
[[package]]
name = "kaptan-sensor"
version = "0.1.0"
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"
[[package]]
name = "kaptan-time"
version = "0.1.0"
[[package]]
name = "kaptan-tx"
version = "0.1.0"
[[package]]
name = "log"
version = "0.4.33"
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",
]
[[package]]
name = "mavlink-bindgen"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "055c957f9fe541226e5b6f0a8aef09b81b9ac865bfc2d083bd6440ed1692602f"
dependencies = [
"crc-any",
"proc-macro2",
"quick-xml",
"quote",
"regex",
"thiserror",
]
[[package]]
name = "mavlink-core"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b31cc9f930c7edce0c1933659d405a330a28346cdf087bd283a56a6d72eb3d85"
dependencies = [
"byteorder",
"crc-any",
"embedded-io",
"embedded-io-async",
]
[[package]]
name = "memchr"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f"
dependencies = [
"nb 1.1.0",
]
[[package]]
name = "nb"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "panic-halt"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a513e167849a384b7f9b746e517604398518590a9142f4846a32e3c2a4de7b11"
[[package]]
name = "platform-stm32f7"
version = "0.1.0"
dependencies = [
"cortex-m",
"cortex-m-rt",
"kaptan-board",
"kaptan-data-channel",
"kaptan-dma",
"kaptan-drivers",
"kaptan-i2c",
"kaptan-log",
"kaptan-log-mavlink",
"kaptan-mavlink",
"kaptan-sensor",
"kaptan-spi",
"kaptan-time",
"panic-halt",
"rtic",
"stm32f7",
]
[[package]]
name = "portable-atomic"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rtic"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16dff5e56cf22c25d0223e8abc0f60b428c577d4dd4a2459e55eeb289165106c"
dependencies = [
"cortex-m",
"critical-section",
"portable-atomic",
"rtic-core",
"rtic-macros",
]
[[package]]
name = "rtic-core"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9369355b04d06a3780ec0f51ea2d225624db777acbc60abd8ca4832da5c1a42"
[[package]]
name = "rtic-macros"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14db06b1d38d3591975d0971b2a5cde129767a93f5815fb01c99efa8d1898c16"
dependencies = [
"indexmap",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "rustc_version"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
dependencies = [
"semver",
]
[[package]]
name = "semver"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
dependencies = [
"semver-parser",
]
[[package]]
name = "semver-parser"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]]
name = "stm32f7"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a5df42747be8091db3cf560a4e1409b9fffc92fbba0ef5f9740b9dfdb54b76c"
dependencies = [
"cortex-m",
"cortex-m-rt",
"vcell",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-triple"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.0",
]
[[package]]
name = "toml"
version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "trybuild"
version = "1.0.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4"
dependencies = [
"glob",
"serde",
"serde_derive",
"serde_json",
"target-triple",
"termcolor",
"toml",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcell"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002"
[[package]]
name = "void"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
[[package]]
name = "volatile-register"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc"
dependencies = [
"vcell",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+50
View File
@@ -0,0 +1,50 @@
[workspace]
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-data-channel",
"libs/kaptan-dma",
"libs/kaptan-drivers",
"libs/kaptan-flight-config",
"libs/kaptan-flight-runtime",
"libs/kaptan-i2c",
"libs/kaptan-math",
"libs/kaptan-mavlink",
"libs/kaptan-log",
"libs/kaptan-log-derive",
"libs/kaptan-log-mavlink",
"libs/kaptan-navigation",
"libs/kaptan-safety",
"libs/kaptan-sensor",
"libs/kaptan-spi",
"libs/kaptan-time",
"libs/kaptan-tx",
"platforms/stm32f7",
"tools/sitl",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "AGPL-3.0-only"
rust-version = "1.90"
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
[profile.release]
opt-level = 3
codegen-units = 1
lto = "fat"
panic = "abort"
strip = "symbols"
+26
View File
@@ -0,0 +1,26 @@
# 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.
```text
firmware/
├── boards/ immutable board declarations and linker layouts
├── 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
```
Run Cargo commands from this directory:
```sh
cd firmware
cargo test --workspace --locked --offline
```
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.
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "kaptan-geprc-f722-v2"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
build = "build.rs"
[lints]
workspace = true
[dependencies]
kaptan-board = { path = "../../libs/kaptan-board" }
platform-stm32f7 = { path = "../../platforms/stm32f7" }
[[bin]]
name = "kaptan-geprc-f722-v2"
path = "src/main.rs"
test = false
bench = false
+9
View File
@@ -0,0 +1,9 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rustc-link-arg=-Tlink.x");
println!(
"cargo:rustc-link-search={}",
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
);
}
+19
View File
@@ -0,0 +1,19 @@
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
/*
* STM32F722 RAM totals 256 KiB:
* - DTCM: 64 KiB at 0x2000_0000
* - SRAM1: 176 KiB at 0x2001_0000
* - SRAM2: 16 KiB at 0x2003_C000
*
* SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2.
* They form one 192 KiB default RAM region from 0x2001_0000 through
* 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit
* placement policy rather than mixing CPU-critical and general allocations.
*
* TODO: Utilize DTCM.
*
*/
RAM : ORIGIN = 0x20010000, LENGTH = 192K
}
@@ -0,0 +1,45 @@
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
};
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack.
pub struct GeprcF722V2;
impl F7Board for GeprcF722V2 {
const CONFIG: BoardConfig = BoardConfig {
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,
},
maximum_log_level: Severity::Info,
}),
};
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
platform
.imu(Icm42688::on_spi3().with_drdy(Pc3))
.mavlink(Mavlink::on_usart2())
.vertical_sensor(VerticalSensor::none())
.build()
}
}
@@ -0,0 +1,6 @@
#![no_std]
#![no_main]
mod board;
platform_stm32f7::flight_firmware!(board::GeprcF722V2);
@@ -0,0 +1,22 @@
[package]
name = "kaptan-kakute-f7-mini-v3"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[features]
lidar-lite-v3 = []
[dependencies]
kaptan-board = { path = "../../libs/kaptan-board" }
platform-stm32f7 = { path = "../../platforms/stm32f7" }
[[bin]]
name = "kaptan-kakute-f7-mini-v3"
path = "src/main.rs"
test = false
bench = false
@@ -0,0 +1,9 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rustc-link-arg=-Tlink.x");
println!(
"cargo:rustc-link-search={}",
std::env::var("CARGO_MANIFEST_DIR").expect("Cargo must set CARGO_MANIFEST_DIR")
);
}
Binary file not shown.
@@ -0,0 +1,19 @@
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
/*
* STM32F722 RAM totals 256 KiB:
* - DTCM: 64 KiB at 0x2000_0000
* - SRAM1: 176 KiB at 0x2001_0000
* - SRAM2: 16 KiB at 0x2003_C000
*
* SRAM1 and SRAM2 are contiguous and are both accessible by DMA1 and DMA2.
* They form one 192 KiB default RAM region from 0x2001_0000 through
* 0x2003_FFFF. DTCM is intentionally kept separate for a future explicit
* placement policy rather than mixing CPU-critical and general allocations.
*
* TODO: Utilize DTCM.
*
*/
RAM : ORIGIN = 0x20010000, LENGTH = 192K
}
@@ -0,0 +1,51 @@
//! Declarative Kakute F7 Mini V3 board description.
//!
//! This module deliberately contains no MCU registers, RTIC concepts, unsafe
//! code, interrupt names, DMA handling, or task scheduling. Those concerns
//! belong to the STM32F7 platform crate.
use kaptan_board::{
BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
Severity, TelemetryProfile, VerticalSensingKind,
};
use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, 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 F7Board for KakuteF7MiniV3 {
const CONFIG: BoardConfig = CONFIG;
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()
}
}
@@ -0,0 +1,5 @@
#![no_std]
#![no_main]
mod board;
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "kaptan-actuator"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
+258
View File
@@ -0,0 +1,258 @@
//! DShot framing, timing, and arming policy.
use crate::ArmingDuration;
pub const MIN_THROTTLE: u16 = 48;
pub const MOTOR_STOP: u16 = 0;
pub const FRAME_SYMBOLS: usize = 18;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Config {
rate: Rate,
armed_idle_throttle: Throttle,
arming_duration: ArmingDuration,
}
impl Config {
pub const fn new(
rate: Rate,
armed_idle_throttle: Throttle,
arming_duration: ArmingDuration,
) -> Option<Self> {
if rate.frames_for_millis(arming_duration.milliseconds()) == 0 {
return None;
}
Some(Self {
rate,
armed_idle_throttle,
arming_duration,
})
}
pub const fn rate(self) -> Rate {
self.rate
}
pub const fn armed_idle_throttle(self) -> Throttle {
self.armed_idle_throttle
}
pub const fn arming_duration(self) -> ArmingDuration {
self.arming_duration
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Rate {
Dshot150,
Dshot300,
Dshot600,
}
impl Rate {
pub const fn hertz(self) -> u32 {
match self {
Self::Dshot150 => 150_000,
Self::Dshot300 => 300_000,
Self::Dshot600 => 600_000,
}
}
/// Twenty timer ticks per symbol produce exact 7/14 compare values.
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)]
pub struct Timing {
bit_ticks: u16,
zero_high_ticks: u16,
one_high_ticks: u16,
}
impl Timing {
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 {
return None;
}
let bit_ticks = ticks as u16;
let zero_high_ticks = ((ticks * 7 + 10) / 20) as u16;
let one_high_ticks = ((ticks * 14 + 10) / 20) as u16;
if zero_high_ticks == 0 || one_high_ticks >= bit_ticks {
return None;
}
Some(Self {
bit_ticks,
zero_high_ticks,
one_high_ticks,
})
}
pub const fn bit_ticks(self) -> u16 {
self.bit_ticks
}
pub const fn zero_high_ticks(self) -> u16 {
self.zero_high_ticks
}
pub const fn one_high_ticks(self) -> u16 {
self.one_high_ticks
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Throttle(u16);
impl Throttle {
pub const fn new(value: u16) -> Option<Self> {
if value >= MIN_THROTTLE && value <= 2_047 {
Some(Self(value))
} else {
None
}
}
pub const fn get(self) -> u16 {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrameValue {
Stop,
Throttle(Throttle),
}
impl FrameValue {
pub const fn packet(self) -> u16 {
packet(match self {
Self::Stop => MOTOR_STOP,
Self::Throttle(throttle) => throttle.get(),
})
}
}
pub struct ArmSequence {
armed_throttle: Throttle,
zero_frames: u16,
remaining: u16,
armed: bool,
}
impl ArmSequence {
pub const fn new(armed_throttle: Throttle, zero_frames: u16) -> Self {
Self {
armed_throttle,
zero_frames,
remaining: 0,
armed: false,
}
}
/// Starts the fixed configured stop-frame sequence. The driver which owns
/// this state must acquire external safety authorization before calling
/// this method and before every subsequent nonzero output commit.
pub fn arm(&mut self) {
self.remaining = self.zero_frames;
self.armed = true;
}
/// Stops the sequence; the physical output owner must emit an explicit
/// stop frame independently of this local state transition.
pub fn disarm(&mut self) {
self.armed = false;
self.remaining = 0;
}
pub const fn is_armed(&self) -> bool {
self.armed
}
pub fn next_frame(&mut self) -> Option<FrameValue> {
if !self.is_armed() {
return None;
}
if self.remaining != 0 {
self.remaining -= 1;
return Some(FrameValue::Stop);
}
Some(FrameValue::Throttle(self.armed_throttle))
}
}
pub const fn packet(throttle: u16) -> u16 {
let value = throttle << 1;
(value << 4) | ((value ^ (value >> 4) ^ (value >> 8)) & 0x0f)
}
pub const fn symbols(packet: u16, zero_high: u16, one_high: u16) -> [u16; FRAME_SYMBOLS] {
let mut symbols = [0; FRAME_SYMBOLS];
let mut bit = 0;
while bit < 16 {
symbols[bit] = if packet & (1 << (15 - bit)) != 0 {
one_high
} else {
zero_high
};
bit += 1;
}
symbols
}
#[cfg(test)]
mod tests {
use super::{ArmSequence, Config, FrameValue, Rate, Throttle, Timing, packet, symbols};
use crate::ArmingDuration;
#[test]
fn packet_matches_standard_dshot_vectors() {
assert_eq!(packet(0), 0x0000);
assert_eq!(packet(48), 0x0606);
assert_eq!(packet(158), 0x13ce);
assert_eq!(packet(300), 0x258f);
}
#[test]
fn arm_sequence_holds_stop_for_the_configured_interval() {
let throttle = Throttle::new(300).unwrap();
let mut sequence = ArmSequence::new(throttle, 2);
sequence.arm();
assert_eq!(sequence.next_frame(), Some(FrameValue::Stop));
assert_eq!(sequence.next_frame(), Some(FrameValue::Stop));
assert_eq!(sequence.next_frame(), Some(FrameValue::Throttle(throttle)));
sequence.disarm();
assert_eq!(sequence.next_frame(), None);
}
#[test]
fn symbols_end_with_the_required_low_reset_gap() {
assert_eq!(symbols(packet(48), 7, 14)[16..], [0, 0]);
}
#[test]
fn standard_rates_have_timer_profiles() {
for rate in [Rate::Dshot150, Rate::Dshot300, Rate::Dshot600] {
assert!(Timing::from_timer_clock(rate.timer_base_hz(), rate).is_some());
}
}
#[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());
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Protocol-neutral physical-motor protocol reference.
//!
//! This crate deliberately remains in the workspace for future, separately
//! reviewed physical-flight work. No checked-in board platform links it, and
//! its types model DShot/PWM framing and
//! local arming sequences only; they grant neither peripheral access nor
//! flight authority.
#![no_std]
pub mod dshot;
pub mod pwm;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ArmingDuration(u16);
impl ArmingDuration {
pub const fn from_millis(milliseconds: u16) -> Option<Self> {
if milliseconds != 0 && milliseconds <= 8_000 {
Some(Self(milliseconds))
} else {
None
}
}
pub const fn milliseconds(self) -> u16 {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MotorProtocol {
Pwm(pwm::Config),
Dshot(dshot::Config),
}
+187
View File
@@ -0,0 +1,187 @@
//! Conventional PWM framing and arming policy.
use crate::ArmingDuration;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Config {
frame_rate: Rate,
minimum_pulse: PulseWidth,
armed_idle_pulse: PulseWidth,
arming_duration: ArmingDuration,
}
impl Config {
pub const fn new(
frame_rate: Rate,
minimum_pulse: PulseWidth,
armed_idle_pulse: PulseWidth,
arming_duration: ArmingDuration,
) -> Option<Self> {
match frame_rate.period_microseconds() {
Some(period)
if minimum_pulse.microseconds() <= armed_idle_pulse.microseconds()
&& armed_idle_pulse.microseconds() <= period =>
{
Some(Self {
frame_rate,
minimum_pulse,
armed_idle_pulse,
arming_duration,
})
}
_ => None,
}
}
pub const fn frame_rate(self) -> Rate {
self.frame_rate
}
pub const fn minimum_pulse(self) -> PulseWidth {
self.minimum_pulse
}
pub const fn armed_idle_pulse(self) -> PulseWidth {
self.armed_idle_pulse
}
pub const fn arming_duration(self) -> ArmingDuration {
self.arming_duration
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Rate(u16);
impl Rate {
pub const fn new(hertz: u16) -> Option<Self> {
if hertz != 0 && hertz <= 500 {
Some(Self(hertz))
} else {
None
}
}
pub const fn hertz(self) -> u16 {
self.0
}
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)
} else {
None
}
}
pub const fn frames_for_millis(self, milliseconds: u16) -> u16 {
((self.0 as u32 * milliseconds as u32) / 1_000) as u16
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PulseWidth(u16);
impl PulseWidth {
pub const fn new(microseconds: u16) -> Option<Self> {
if microseconds >= 1_000 && microseconds <= 2_000 {
Some(Self(microseconds))
} else {
None
}
}
pub const fn microseconds(self) -> u16 {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrameValue {
Minimum,
ArmedIdle(PulseWidth),
}
pub struct ArmSequence {
armed_idle: PulseWidth,
minimum_frames: u16,
remaining: u16,
armed: bool,
}
impl ArmSequence {
pub const fn new(armed_idle: PulseWidth, minimum_frames: u16) -> Self {
Self {
armed_idle,
minimum_frames,
remaining: 0,
armed: false,
}
}
/// Starts the fixed configured minimum-frame sequence. The driver which
/// owns this state must acquire external safety authorization before
/// calling this method and before every subsequent non-minimum commit.
pub fn arm(&mut self) {
self.remaining = self.minimum_frames;
self.armed = true;
}
/// Stops the sequence; the physical output owner must drive its explicit
/// minimum pulse independently of this local state transition.
pub fn disarm(&mut self) {
self.armed = false;
self.remaining = 0;
}
pub const fn is_armed(&self) -> bool {
self.armed
}
pub fn next_frame(&mut self) -> Option<FrameValue> {
if !self.is_armed() {
return None;
}
if self.remaining != 0 {
self.remaining -= 1;
return Some(FrameValue::Minimum);
}
Some(FrameValue::ArmedIdle(self.armed_idle))
}
}
#[cfg(test)]
mod tests {
use super::{ArmSequence, Config, FrameValue, PulseWidth, Rate};
use crate::ArmingDuration;
#[test]
fn rate_describes_an_exact_400_hertz_frame() {
let rate = Rate::new(400).unwrap();
assert_eq!(rate.period_microseconds(), Some(2_500));
assert_eq!(rate.frames_for_millis(2_000), 800);
}
#[test]
fn sequence_holds_minimum_before_armed_idle() {
let pulse = PulseWidth::new(1_100).unwrap();
let mut sequence = ArmSequence::new(pulse, 2);
sequence.arm();
assert_eq!(sequence.next_frame(), Some(FrameValue::Minimum));
assert_eq!(sequence.next_frame(), Some(FrameValue::Minimum));
assert_eq!(sequence.next_frame(), Some(FrameValue::ArmedIdle(pulse)));
sequence.disarm();
assert_eq!(sequence.next_frame(), None);
}
#[test]
fn requires_an_exact_frame_and_ordered_pulses() {
let duration = ArmingDuration::from_millis(2_000).unwrap();
let rate = Rate::new(400).unwrap();
let minimum = PulseWidth::new(1_000).unwrap();
let idle = PulseWidth::new(1_100).unwrap();
assert!(Config::new(rate, minimum, idle, duration).is_some());
assert!(Config::new(Rate::new(333).unwrap(), minimum, idle, duration).is_none());
assert!(Config::new(rate, idle, minimum, duration).is_none());
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "kaptan-board"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-log = { path = "../kaptan-log" }
+339
View File
@@ -0,0 +1,339 @@
//! Portable board-support contract for Kaptan.
//!
//! This crate describes logical board identity, sensing capability and
//! outbound data-channel policy. It intentionally has no dependency on an MCU
//! family, peripheral access crate, RTIC, or a runtime framework.
#![no_std]
use kaptan_log::KaptanError;
pub use kaptan_log::Severity;
/// Physical IMU device selected by a board declaration.
///
/// This is immutable board topology, not a runtime-selectable sensor driver.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImuDevice {
/// InvenSense MPU-6000 on the board's selected SPI route.
Mpu6000,
/// TDK ICM-42688-P on the board's selected SPI route.
Icm42688,
}
/// Fixed board-to-body IMU mounting transform.
///
/// Calibration remains a separate, safety-gated workflow. This declaration
/// only selects the compiled transform for the physical board route.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ImuMount {
/// Sensor axes already match the controller body axes.
BodyAligned,
/// Sensor is yawed 180° relative to the controller body axes.
Yaw180,
}
/// Logical outbound MAVLink serial endpoint selected by a board declaration.
///
/// The platform maps these logical names to its checked pin/DMA routes.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MavlinkPort {
/// First supported board serial endpoint.
Serial1,
/// Second supported board serial endpoint.
Serial2,
}
/// The vertical-sensing data a board support package makes available.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum VerticalSensingKind {
/// No range or optical-flow data is supported.
None,
/// Range data is supported.
Range,
/// Coherent range and optical-flow data are supported.
RangeAndOpticalFlow,
}
/// MAVLink endpoint identity selected by one board support package.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MavlinkEndpoint {
/// MAVLink system identifier.
pub system_id: u8,
/// MAVLink component identifier.
pub component_id: u8,
/// Immutable physical serial endpoint selected for this board image.
pub port: MavlinkPort,
/// UART line rate used by that physical endpoint.
pub baud: u32,
}
/// Fixed outbound data-channel rates driven by a platform service timer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataChannelRates {
/// Frequency of the timer that calls the data-channel scheduler.
pub service_hz: usize,
/// Raw-IMU publication rate.
pub raw_imu_hz: usize,
/// Range publication rate. `None` means range is not published.
pub range_hz: Option<usize>,
/// Optical-flow publication rate. `None` means flow is not published.
pub optical_flow_hz: Option<usize>,
/// Link heartbeat publication rate.
pub heartbeat_hz: usize,
}
/// Telemetry-only physical-sensor and outbound-data policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TelemetryProfile {
/// Immutable physical IMU driver selected by this board image.
pub imu: ImuDevice,
/// Immutable board-to-body transform applied to that IMU's samples.
pub imu_mount: ImuMount,
/// Supported physical vertical-sensing capability.
pub vertical_sensing: VerticalSensingKind,
/// Data-channel timer and publication rates.
pub data_channel: DataChannelRates,
/// Highest optional diagnostic severity forwarded to MAVLink.
///
/// Errors remain observable even when optional debug/info diagnostics are
/// filtered. This setting is compile-time board policy until the target
/// has the reviewed configuration activation path.
pub maximum_log_level: Severity,
}
/// Exclusive build-time image selection.
///
/// `ExternalControlOnly` remains representable for an explicit validation
/// rejection rather than silently falling back to telemetry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BoardProfile {
/// Physical-sensor telemetry image.
TelemetryOnly(TelemetryProfile),
/// Reserved and deliberately unimplemented production authority profile.
ExternalControlOnly,
}
/// Static board identity and exclusive build-time image configuration.
///
/// The selected platform trait determines the MCU family. Keeping a free-form
/// MCU string here would duplicate that type-level fact without validating it.
///
/// Board policy is compiled and non-deserializable. The current F722 platform
/// validates it against typestate-selected electrical routes before it touches
/// hardware. Target-side runtime configuration remains disabled until its
/// separate authenticated, journalled, disarmed activation contract exists.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BoardConfig {
/// Stable board name used in build and diagnostic tooling.
pub name: &'static str,
/// MAVLink endpoint identity.
pub mavlink: MavlinkEndpoint,
/// Exactly one immutable image profile.
pub profile: BoardProfile,
}
impl BoardConfig {
/// Returns physical telemetry configuration only for a telemetry image.
pub const fn telemetry(self) -> Option<TelemetryProfile> {
match self.profile {
BoardProfile::TelemetryOnly(profile) => Some(profile),
BoardProfile::ExternalControlOnly => None,
}
}
/// Validates the selected exclusive profile.
pub const fn validate(self) -> Result<(), BoardConfigError> {
if self.mavlink.system_id == 0 || self.mavlink.component_id == 0 {
return Err(BoardConfigError::InvalidMavlinkEndpoint);
}
if self.mavlink.baud == 0 {
return Err(BoardConfigError::ZeroMavlinkBaud);
}
match self.profile {
BoardProfile::TelemetryOnly(profile) => Self::validate_telemetry(profile),
BoardProfile::ExternalControlOnly => {
// TODO(flight-config): replace this hard rejection only with a
// complete runtime and atomic configuration activation. Required
// inputs are vehicle geometry, calibrated sensors, actuator
// limits, authenticated MAVLink-v2 authority, persistent replay
// state, estimator/controller/allocator readiness, and a
// verified output watchdog.
Err(BoardConfigError::UnsupportedFlightProfile)
}
}
}
const fn validate_telemetry(profile: TelemetryProfile) -> Result<(), BoardConfigError> {
let rates = profile.data_channel;
if rates.service_hz == 0 {
return Err(BoardConfigError::ZeroServiceFrequency);
}
if rates.raw_imu_hz == 0 || rates.heartbeat_hz == 0 {
return Err(BoardConfigError::MissingRequiredDataChannel);
}
if rates.raw_imu_hz > rates.service_hz || rates.heartbeat_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(mut scheduled_hz) = rates.raw_imu_hz.checked_add(rates.heartbeat_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
if let Some(range_hz) = rates.range_hz {
if range_hz == 0 || range_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(total) = scheduled_hz.checked_add(range_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
scheduled_hz = total;
}
if let Some(flow_hz) = rates.optical_flow_hz {
if flow_hz == 0 || flow_hz > rates.service_hz {
return Err(BoardConfigError::RateExceedsServiceFrequency);
}
let Some(total) = scheduled_hz.checked_add(flow_hz) else {
return Err(BoardConfigError::DataChannelOvercommitted);
};
scheduled_hz = total;
}
if scheduled_hz > rates.service_hz {
return Err(BoardConfigError::DataChannelOvercommitted);
}
let vertical_schedule_is_valid = match profile.vertical_sensing {
VerticalSensingKind::None
if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() =>
{
true
}
VerticalSensingKind::Range
if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() =>
{
true
}
VerticalSensingKind::RangeAndOpticalFlow
if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() =>
{
true
}
_ => false,
};
if !vertical_schedule_is_valid {
return Err(BoardConfigError::CapabilityDataChannelMismatch);
}
Ok(())
}
}
/// Invalid board-support configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BoardConfigError {
/// MAVLink system/component identifiers must be exact nonzero values.
InvalidMavlinkEndpoint,
/// A physical MAVLink endpoint needs a nonzero UART rate.
ZeroMavlinkBaud,
/// A data-channel schedule needs a nonzero service frequency.
ZeroServiceFrequency,
/// Raw IMU and heartbeat are mandatory on every flight board.
MissingRequiredDataChannel,
/// A configured stream is faster than its service timer.
RateExceedsServiceFrequency,
/// The schedule requests more than one frame per service tick.
DataChannelOvercommitted,
/// Declared vertical sensing and published streams disagree.
CapabilityDataChannelMismatch,
/// The selected operation profile has no complete runtime implementation.
UnsupportedFlightProfile,
}
#[cfg(test)]
mod tests {
use super::{
BoardConfig, BoardConfigError, BoardProfile, DataChannelRates, ImuDevice, ImuMount,
MavlinkEndpoint, MavlinkPort, TelemetryProfile, VerticalSensingKind,
};
use kaptan_log::Severity;
const BASE: BoardConfig = BoardConfig {
name: "test",
mavlink: MavlinkEndpoint {
system_id: 1,
component_id: 1,
port: MavlinkPort::Serial1,
baud: 115_200,
},
profile: BoardProfile::TelemetryOnly(TelemetryProfile {
imu: ImuDevice::Mpu6000,
imu_mount: ImuMount::BodyAligned,
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
data_channel: DataChannelRates {
service_hz: 100,
raw_imu_hz: 20,
range_hz: Some(20),
optical_flow_hz: Some(20),
heartbeat_hz: 2,
},
maximum_log_level: Severity::Info,
}),
};
#[test]
fn accepts_a_schedulable_range_and_flow_board() {
assert_eq!(BASE.validate(), Ok(()));
}
#[test]
fn rejects_streams_that_exceed_the_service_budget() {
let mut config = BASE;
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
unreachable!();
};
profile.data_channel.optical_flow_hz = Some(80);
assert_eq!(
config.validate(),
Err(BoardConfigError::DataChannelOvercommitted)
);
}
#[test]
fn rejects_capability_and_data_channel_mismatch() {
let mut config = BASE;
let BoardProfile::TelemetryOnly(profile) = &mut config.profile else {
unreachable!();
};
profile.vertical_sensing = VerticalSensingKind::Range;
assert_eq!(
config.validate(),
Err(BoardConfigError::CapabilityDataChannelMismatch)
);
}
#[test]
fn rejects_external_control_until_its_runtime_exists() {
let mut config = BASE;
config.profile = BoardProfile::ExternalControlOnly;
assert_eq!(
config.validate(),
Err(BoardConfigError::UnsupportedFlightProfile)
);
}
#[test]
fn rejects_invalid_immutable_link_policy() {
let mut config = BASE;
config.mavlink.baud = 0;
assert_eq!(config.validate(), Err(BoardConfigError::ZeroMavlinkBaud));
let mut config = BASE;
config.mavlink.component_id = 0;
assert_eq!(
config.validate(),
Err(BoardConfigError::InvalidMavlinkEndpoint)
);
}
}
@@ -0,0 +1,21 @@
[package]
name = "kaptan-config-derive"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lib]
proc-macro = true
[lints]
workspace = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
[dev-dependencies]
kaptan-config = { path = "../kaptan-config" }
trybuild = "1"
@@ -0,0 +1,365 @@
use proc_macro2::TokenStream;
use quote::quote;
use syn::Result;
use crate::parse::{Check, Model, ModelKind};
pub(crate) fn expand(model: Model) -> Result<TokenStream> {
let Model {
ident,
generics,
crate_path,
post_validate,
kind,
fields,
} = 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),
}
}
fn expand_scalars(
ident: syn::Ident,
generics: syn::Generics,
crate_path: syn::Path,
post_validate: Option<syn::Path>,
fields: Vec<crate::parse::FieldModel>,
) -> Result<TokenStream> {
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();
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)?;
let field_ident = &field.ident;
defaults.push(quote! { #field_ident: #default });
reads.push(quote! {
#id => Some(#value(self.#field_ident)),
});
writes.push(quote! {
#id => {
let next = match value {
#value(next) => next,
actual => {
return Err(#crate_path::FieldWriteError::WrongType {
field: #crate_path::FieldId::new(#id),
expected: #kind,
actual: actual.kind(),
});
}
};
let previous = self.#field_ident;
self.#field_ident = next;
if let Err(violation) =
<Self as #crate_path::ConfigSection>::validate_local_fields(self)
{
self.#field_ident = previous;
return Err(#crate_path::FieldWriteError::Violation(violation));
}
Ok(())
}
});
}
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
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 {
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
fn default_section() -> Result<Self, #crate_path::ConfigViolation> {
let section = Self {
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&section)?;
Ok(section)
}
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
match id.get() {
#(#reads)*
_ => None,
}
}
fn set_field(
&mut self,
id: #crate_path::FieldId,
value: #crate_path::ConfigValue,
) -> Result<(), #crate_path::FieldWriteError> {
match id.get() {
#(#writes,)*
_ => Err(#crate_path::FieldWriteError::UnknownField(id)),
}
}
fn validate_local_fields(&self) -> Result<(), #crate_path::ConfigViolation> {
#(#checks)*
Ok(())
}
}
})
}
fn expand_sections(
ident: syn::Ident,
generics: syn::Generics,
crate_path: syn::Path,
post_validate: Option<syn::Path>,
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 field_ident = &field.ident;
let ty = &field.ty;
quote! {
<#ty as #crate_path::ValidateFields>::validate_fields(&self.#field_ident)?;
}
});
let defaults = fields.iter().map(|field| {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
#field_ident: <#ty as #crate_path::ConfigSection>::default_section()?
}
});
let reads = fields.iter().map(|field| {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
if let Some(value) = <#ty as #crate_path::ConfigSection>::field_value(
&self.#field_ident,
id,
) {
return Some(value);
}
}
});
let writes = fields.iter().map(|field| {
let field_ident = &field.ident;
let ty = &field.ty;
quote! {
if <#ty as #crate_path::ConfigSection>::field_value(&self.#field_ident, id).is_some() {
return <#ty as #crate_path::ConfigSection>::set_field(
&mut self.#field_ident,
id,
value,
);
}
}
});
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 #ident #type_generics #where_clause {
/// Number of fields aggregated from derived nested sections.
pub const FIELD_COUNT: usize = 0 #(+ #counts)*;
}
impl #impl_generics #crate_path::ConfigAggregate for #ident #type_generics #where_clause {
fn default_aggregate() -> Result<Self, #crate_path::ConfigViolation> {
let aggregate = Self {
#(#defaults),*
};
<Self as #crate_path::ValidateFields>::validate_fields(&aggregate)?;
Ok(aggregate)
}
fn field_value(&self, id: #crate_path::FieldId) -> Option<#crate_path::ConfigValue> {
#(#reads)*
None
}
fn set_field(
&mut self,
id: #crate_path::FieldId,
value: #crate_path::ConfigValue,
) -> Result<(), #crate_path::FieldWriteError> {
#(#writes)*
Err(#crate_path::FieldWriteError::UnknownField(id))
}
}
})
}
struct FieldMetadata {
descriptor: Option<TokenStream>,
checks: Vec<TokenStream>,
}
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
let mut checks = Vec::new();
let Some(id) = field.id else {
return Ok(FieldMetadata {
descriptor: None,
checks,
});
};
let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value),
_ => None,
});
let maximum = field.checks.iter().find_map(|check| match check {
Check::Maximum(value) => Some(value),
_ => None,
});
for check in &field.checks {
checks.push(expand_check(crate_path, &field.ident, id, check.clone()));
}
let key = field.display_key.as_ref().expect("id fields have a key");
let unit = field.unit.as_ref().expect("id fields have a unit");
let default = field.default.as_ref().expect("id fields have a default");
let (kind, value) = config_value_tokens(crate_path, &field.ty)?;
let minimum = option_value_tokens(value.clone(), minimum);
let maximum = option_value_tokens(value.clone(), maximum);
Ok(FieldMetadata {
descriptor: Some(quote! {
#crate_path::FieldDescriptor {
id: #crate_path::FieldId::new(#id),
display_key: #key,
kind: #kind,
minimum: #minimum,
maximum: #maximum,
default: #value(#default),
unit: #unit,
apply_policy: #crate_path::ApplyPolicy::RestartRequired,
}
}),
checks,
})
}
fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> TokenStream {
match expression {
Some(expression) => quote! { Some(#value(#expression)) },
None => quote! { None },
}
}
fn config_value_tokens(
crate_path: &syn::Path,
ty: &syn::Type,
) -> Result<(TokenStream, TokenStream)> {
let syn::Type::Path(path) = ty else {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata requires a scalar type",
));
};
let Some(segment) = path.path.segments.last() else {
return Err(syn::Error::new_spanned(
ty,
"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 },
),
_ => {
return Err(syn::Error::new_spanned(
ty,
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
));
}
};
Ok((kind, value))
}
fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream {
match check {
Check::Finite => quote! {
if !self.#field.is_finite() {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::NonFinite,
));
}
},
Check::Nonzero => quote! {
if self.#field == 0 {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::Zero,
));
}
},
Check::Minimum(minimum) => quote! {
if self.#field < (#minimum) {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::BelowMinimum,
));
}
},
Check::Maximum(maximum) => quote! {
if self.#field > (#maximum) {
return Err(#crate_path::ConfigViolation::field(
#crate_path::FieldId::new(#id),
#crate_path::ValidationRule::AboveMaximum,
));
}
},
}
}
@@ -0,0 +1,17 @@
//! Procedural validation derive for Kaptan configuration records.
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 {
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(),
}
}
@@ -0,0 +1,708 @@
use syn::{
Attribute, Data, DeriveInput, Expr, Field, Fields, Ident, Lit, Path, Result, Type, UnOp,
spanned::Spanned,
};
pub(crate) struct Model {
pub(crate) ident: Ident,
pub(crate) generics: syn::Generics,
pub(crate) crate_path: Path,
pub(crate) post_validate: Option<Path>,
pub(crate) kind: ModelKind,
pub(crate) fields: Vec<FieldModel>,
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub(crate) enum ModelKind {
Scalars,
Sections,
}
pub(crate) struct FieldModel {
pub(crate) ident: Ident,
pub(crate) ty: Type,
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,
}
#[derive(Clone)]
pub(crate) enum Check {
Finite,
Nonzero,
Minimum(Expr),
Maximum(Expr),
}
impl Model {
pub(crate) fn parse(input: DeriveInput) -> Result<Self> {
let (crate_path, post_validate) = parse_container_attributes(&input.attrs)?;
let Data::Struct(data) = input.data else {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate 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",
));
};
if fields.named.is_empty() {
return Err(syn::Error::new(
input.ident.span(),
"ConfigValidate requires at least one configured field or section",
));
}
let mut models = Vec::with_capacity(fields.named.len());
let mut ids = Vec::new();
for field in fields.named {
let model = parse_field(field)?;
if let Some(id) = model.id {
if ids.contains(&id) {
return Err(syn::Error::new(
model.ident.span(),
"duplicate configuration field id",
));
}
ids.push(id);
}
models.push(model);
}
let kind = if models.iter().all(|field| field.section) {
ModelKind::Sections
} else if models.iter().all(|field| field.id.is_some()) {
ModelKind::Scalars
} else {
let field = models
.iter()
.find(|field| field.section || 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",
));
};
if kind == ModelKind::Scalars {
for field in &models {
if field.display_key.is_none() {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(key = \"...\")",
));
}
if field.unit.is_none() {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(unit = \"...\")",
));
}
if field.default.is_none() {
return Err(syn::Error::new(
field.ident.span(),
"configuration fields require config(default = ...)",
));
}
validate_static_constraints(field)?;
}
}
Ok(Self {
ident: input.ident,
generics: input.generics,
crate_path,
post_validate,
kind,
fields: models,
})
}
}
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;
let mut post_validate = None;
for attribute in attributes
.iter()
.filter(|attribute| attribute.path().is_ident("config"))
{
attribute.parse_nested_meta(|meta| {
if meta.path.is_ident("crate") {
if has_crate_path {
return Err(meta.error("duplicate crate path override"));
}
crate_path = meta.value()?.parse()?;
has_crate_path = true;
return Ok(());
}
if meta.path.is_ident("post") {
if post_validate.is_some() {
return Err(meta.error("duplicate post validation hook"));
}
post_validate = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("struct config options are crate = path and post = path"))
})?;
}
Ok((crate_path, post_validate))
}
fn parse_field(field: Field) -> Result<FieldModel> {
let ident = field
.ident
.clone()
.ok_or_else(|| syn::Error::new(field.span(), "configuration fields must be named"))?;
let mut checks = Vec::new();
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;
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"));
}
let literal: syn::LitInt = meta.value()?.parse()?;
let parsed = literal.base10_parse::<u16>()?;
if parsed == 0 || parsed == u16::MAX {
return Err(meta.error("configuration field id is reserved"));
}
id = Some(parsed);
return Ok(());
}
if meta.path.is_ident("key") {
if display_key.is_some() {
return Err(meta.error("duplicate configuration display key"));
}
display_key = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("unit") {
if unit.is_some() {
return Err(meta.error("duplicate configuration unit"));
}
unit = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("default") {
if default.is_some() {
return Err(meta.error("duplicate configuration default"));
}
let expression = meta.value()?.parse()?;
validate_default_literal(&expression, &field.ty)?;
default = Some(expression);
return Ok(());
}
if meta.path.is_ident("finite") {
if finite {
return Err(meta.error("duplicate finite validation rule"));
}
require_float(&field.ty, &meta.path)?;
checks.push(Check::Finite);
finite = true;
return Ok(());
}
if meta.path.is_ident("nonzero") {
if nonzero {
return Err(meta.error("duplicate nonzero validation rule"));
}
require_numeric(&field.ty, &meta.path)?;
checks.push(Check::Nonzero);
nonzero = true;
return Ok(());
}
if meta.path.is_ident("range") {
require_numeric(&field.ty, &meta.path)?;
let (minimum, maximum) = parse_range(meta)?;
if minimum.is_none() && maximum.is_none() {
return Err(syn::Error::new(
field.span(),
"range requires min and/or max",
));
}
if let Some(minimum) = minimum {
if range_minimum {
return Err(syn::Error::new(field.span(), "duplicate range minimum"));
}
checks.push(Check::Minimum(minimum));
range_minimum = true;
}
if let Some(maximum) = maximum {
if range_maximum {
return Err(syn::Error::new(field.span(), "duplicate range maximum"));
}
checks.push(Check::Maximum(maximum));
range_maximum = true;
}
return Ok(());
}
Err(meta.error(
"supported field config rules are id, key, unit, default, 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())
{
return Err(syn::Error::new(
ident.span(),
"config(section) cannot be combined with scalar id, metadata, or validation rules",
));
}
Ok(FieldModel {
ident,
ty: field.ty,
id,
display_key,
unit,
default,
checks,
section,
})
}
fn parse_range(meta: syn::meta::ParseNestedMeta<'_>) -> Result<(Option<Expr>, Option<Expr>)> {
let mut minimum = None;
let mut maximum = None;
meta.parse_nested_meta(|nested| {
if nested.path.is_ident("min") {
if minimum.is_some() {
return Err(nested.error("duplicate range minimum"));
}
let expression = nested.value()?.parse()?;
validate_numeric_literal(&expression)?;
minimum = Some(expression);
return Ok(());
}
if nested.path.is_ident("max") {
if maximum.is_some() {
return Err(nested.error("duplicate range maximum"));
}
let expression = nested.value()?.parse()?;
validate_numeric_literal(&expression)?;
maximum = Some(expression);
return Ok(());
}
Err(nested.error("range accepts only min and max"))
})?;
Ok((minimum, maximum))
}
fn validate_numeric_literal(expression: &Expr) -> Result<()> {
match expression {
Expr::Lit(expression) if matches!(expression.lit, Lit::Int(_) | Lit::Float(_)) => Ok(()),
Expr::Unary(expression)
if matches!(expression.op, UnOp::Neg(_))
&& matches!(
expression.expr.as_ref(),
Expr::Lit(literal) if matches!(literal.lit, Lit::Int(_) | Lit::Float(_))
) =>
{
Ok(())
}
_ => Err(syn::Error::new(
expression.span(),
"range bounds must be numeric literals",
)),
}
}
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(
field.ty.span(),
"configuration metadata requires a supported scalar type",
)
})?;
if ty == "bool" {
return Ok(());
}
let default = scalar_literal(
field.default.as_ref().expect("scalar fields have defaults"),
ty,
)?;
let minimum = field.checks.iter().find_map(|check| match check {
Check::Minimum(value) => Some(value),
_ => None,
});
let maximum = field.checks.iter().find_map(|check| match check {
Check::Maximum(value) => Some(value),
_ => None,
});
let minimum = minimum.map(|value| scalar_literal(value, ty)).transpose()?;
let maximum = maximum.map(|value| scalar_literal(value, ty)).transpose()?;
if minimum.is_some_and(|value| !value.is_finite())
|| maximum.is_some_and(|value| !value.is_finite())
{
return Err(syn::Error::new(
field.ident.span(),
"configuration range bounds must be finite",
));
}
if let (Some(minimum), Some(maximum)) = (minimum, maximum)
&& compare_scalar(minimum, maximum).is_gt()
{
return Err(syn::Error::new(
field.ident.span(),
"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(())
}
#[derive(Clone, Copy)]
enum ScalarLiteral {
Signed(i128),
Unsigned(u128),
Float(f64),
}
impl ScalarLiteral {
fn is_finite(self) -> bool {
match self {
Self::Signed(_) | Self::Unsigned(_) => true,
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> {
match ty {
"i32" => {
let value = signed_literal(expression)?;
i32::try_from(value).map_err(|_| {
syn::Error::new(
expression.span(),
"configuration literal is outside i32 range",
)
})?;
Ok(ScalarLiteral::Signed(value))
}
"u32" => {
let value = unsigned_literal(expression)?;
u32::try_from(value).map_err(|_| {
syn::Error::new(
expression.span(),
"configuration literal is outside u32 range",
)
})?;
Ok(ScalarLiteral::Unsigned(value))
}
"u64" => {
let value = unsigned_literal(expression)?;
u64::try_from(value).map_err(|_| {
syn::Error::new(
expression.span(),
"configuration literal is outside u64 range",
)
})?;
Ok(ScalarLiteral::Unsigned(value))
}
"f32" => Ok(ScalarLiteral::Float(f64::from(
float_literal(expression)? as f32
))),
"f64" => float_literal(expression).map(ScalarLiteral::Float),
_ => Err(syn::Error::new(
expression.span(),
"configuration metadata supports bool, i32, u32, u64, f32, and f64",
)),
}
}
fn signed_literal(expression: &Expr) -> Result<i128> {
match expression {
Expr::Lit(expression) => match &expression.lit {
Lit::Int(value) => value.base10_parse(),
_ => Err(syn::Error::new(
expression.span(),
"expected an integer literal",
)),
},
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
let Expr::Lit(value) = expression.expr.as_ref() else {
return Err(syn::Error::new(
expression.span(),
"expected an integer literal",
));
};
let Lit::Int(value) = &value.lit else {
return Err(syn::Error::new(value.span(), "expected an integer literal"));
};
value
.base10_parse::<i128>()
.map(|value| -value)
.map_err(|error| syn::Error::new(value.span(), error))
}
_ => Err(syn::Error::new(
expression.span(),
"expected an integer literal",
)),
}
}
fn unsigned_literal(expression: &Expr) -> Result<u128> {
match expression {
Expr::Lit(expression) => match &expression.lit {
Lit::Int(value) => value
.base10_parse()
.map_err(|error| syn::Error::new(value.span(), error)),
_ => Err(syn::Error::new(
expression.span(),
"expected an unsigned integer literal",
)),
},
_ => Err(syn::Error::new(
expression.span(),
"expected an unsigned integer literal",
)),
}
}
fn float_literal(expression: &Expr) -> Result<f64> {
let (negative, literal) = match expression {
Expr::Lit(expression) => (false, &expression.lit),
Expr::Unary(expression) if matches!(expression.op, UnOp::Neg(_)) => {
let Expr::Lit(value) = expression.expr.as_ref() else {
return Err(syn::Error::new(
expression.span(),
"expected a float literal",
));
};
(true, &value.lit)
}
_ => {
return Err(syn::Error::new(
expression.span(),
"expected a float literal",
));
}
};
let value = match literal {
Lit::Int(value) => value
.base10_parse::<f64>()
.map_err(|error| syn::Error::new(value.span(), error))?,
Lit::Float(value) => value
.base10_parse::<f64>()
.map_err(|error| syn::Error::new(value.span(), error))?,
_ => {
return Err(syn::Error::new(
expression.span(),
"expected a float literal",
));
}
};
Ok(if negative { -value } else { value })
}
fn compare_scalar(left: ScalarLiteral, right: ScalarLiteral) -> core::cmp::Ordering {
match (left, right) {
(ScalarLiteral::Signed(left), ScalarLiteral::Signed(right)) => left.cmp(&right),
(ScalarLiteral::Unsigned(left), ScalarLiteral::Unsigned(right)) => left.cmp(&right),
(ScalarLiteral::Float(left), ScalarLiteral::Float(right)) => {
left.partial_cmp(&right).expect("finite literals compare")
}
_ => unreachable!("field literals share their scalar type"),
}
}
fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> {
if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") {
Ok(())
} else {
Err(syn::Error::new(
span.span(),
"finite requires an f32 or f64 field",
))
}
}
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"
)
}) {
Ok(())
} else {
Err(syn::Error::new(
span.span(),
"nonzero and range require a primitive numeric field",
))
}
}
fn scalar_type_name(ty: &Type) -> Option<&str> {
let Type::Path(path) = ty else {
return None;
};
if path.qself.is_some() || path.path.segments.len() != 1 {
return None;
}
let segment = path.path.segments.first()?;
if !matches!(segment.arguments, syn::PathArguments::None) {
return None;
}
Some(match &segment.ident {
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,
})
}
@@ -0,0 +1,27 @@
use kaptan_config::{ConfigAggregate, ConfigSection, ConfigViolation, ValidateFields};
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct Child {
#[config(id = 1, key = "child.value", unit = "count", default = 1, range(min = 0, max = 2))]
value: u32,
}
#[derive(ConfigValidate)]
#[config(post = Self::validate_complete)]
struct Root {
#[config(section)]
child: Child,
}
impl Root {
fn validate_complete(&self) -> Result<(), ConfigViolation> {
self.child.validate_fields()
}
}
fn main() {
let root = Root::default_aggregate().unwrap();
assert_eq!(Root::FIELD_COUNT, 1);
assert_eq!(root.child.field_value(kaptan_config::FieldId::new(1)), Some(kaptan_config::ConfigValue::U32(1)));
}
@@ -0,0 +1,11 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct RootBooleanConfig {
#[config(id = 1, key = "enabled", unit = "bool", default = true)]
enabled: bool,
}
fn main() {
assert_eq!(RootBooleanConfig::DESCRIPTORS.len(), 1);
}
@@ -0,0 +1,119 @@
use kaptan_config::{
ConfigAggregate, ConfigSection, ConfigValue, ConfigViolation, FieldId, FieldWriteError,
ValidateFields, ValidationRule,
};
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct LocalSection {
#[config(
id = 1,
key = "local.first",
unit = "count",
default = 2,
range(min = 1, max = 9)
)]
first: u32,
#[config(
id = 2,
key = "local.second",
unit = "count",
default = 4,
range(min = 1, max = 9)
)]
second: u32,
}
#[derive(ConfigValidate)]
#[config(post = Self::validate_order)]
struct OrderedSection {
#[config(
id = 3,
key = "ordered.minimum",
unit = "count",
default = 1,
range(min = 0, max = 9)
)]
minimum: u32,
#[config(
id = 4,
key = "ordered.maximum",
unit = "count",
default = 2,
range(min = 0, max = 9)
)]
maximum: u32,
}
impl OrderedSection {
fn validate_order(&self) -> Result<(), ConfigViolation> {
if self.minimum > self.maximum {
return Err(ConfigViolation::related(
FieldId::new(3),
FieldId::new(4),
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
#[derive(ConfigValidate)]
struct Aggregate {
#[config(section)]
local: LocalSection,
#[config(section)]
ordered: OrderedSection,
}
#[test]
fn generated_section_owns_every_declared_scalar_path() {
let mut section = LocalSection::default_section().unwrap();
assert_eq!(LocalSection::FIELD_COUNT, 2);
assert_eq!(
section.field_value(FieldId::new(2)),
Some(ConfigValue::U32(4))
);
section
.set_field(FieldId::new(2), ConfigValue::U32(8))
.unwrap();
assert_eq!(
section.field_value(FieldId::new(2)),
Some(ConfigValue::U32(8))
);
assert_eq!(
section.set_field(FieldId::new(2), ConfigValue::U32(0)),
Err(FieldWriteError::Violation(ConfigViolation::field(
FieldId::new(2),
ValidationRule::BelowMinimum,
)))
);
assert_eq!(
section.field_value(FieldId::new(2)),
Some(ConfigValue::U32(8))
);
}
#[test]
fn aggregate_routes_writes_and_defers_declared_post_validation() {
let mut aggregate = Aggregate::default_aggregate().unwrap();
assert_eq!(Aggregate::FIELD_COUNT, 4);
assert_eq!(
aggregate.field_value(FieldId::new(2)),
Some(ConfigValue::U32(4))
);
aggregate
.set_field(FieldId::new(3), ConfigValue::U32(3))
.unwrap();
assert!(matches!(
aggregate.validate_fields(),
Err(issue)
if issue.primary() == Some(FieldId::new(3))
&& issue.related_field() == Some(FieldId::new(4))
&& issue.rule() == ValidationRule::Inconsistent
));
aggregate
.set_field(FieldId::new(4), ConfigValue::U32(3))
.unwrap();
aggregate.validate_fields().unwrap();
}
@@ -0,0 +1,6 @@
#[test]
fn rejects_malformed_field_validation_attributes() {
let cases = trybuild::TestCases::new();
cases.compile_fail("tests/ui/*.rs");
cases.pass("tests/pass/*.rs");
}
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct DefaultBelowMinimum {
#[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration default is below its declared minimum
--> tests/ui/default_below_minimum.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "count", default = 1, range(min = 2, max = 3))]
| ^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct NonFiniteDefault {
#[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
value: f32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration default violates finite
--> tests/ui/default_violates_finite.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "ratio", default = 1e100, finite)]
| ^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct ZeroDefault {
#[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration default violates nonzero
--> tests/ui/default_violates_nonzero.rs:5:63
|
5 | #[config(id = 1, key = "value", unit = "count", default = 0, nonzero)]
| ^
@@ -0,0 +1,11 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct DuplicateId {
#[config(id = 7, nonzero)]
first: u32,
#[config(id = 7, nonzero)]
second: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate configuration field id
--> tests/ui/duplicate_id.rs:8:5
|
8 | second: u32,
| ^^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct DuplicateRange {
#[config(id = 7, range(min = 1), range(min = 2))]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: duplicate range minimum
--> tests/ui/duplicate_range.rs:5:5
|
5 | #[config(id = 7, range(min = 1), range(min = 2))]
| ^
@@ -0,0 +1,6 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct Empty {}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigValidate requires at least one configured field or section
--> tests/ui/empty.rs:4:8
|
4 | struct Empty {}
| ^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct MissingId {
#[config(nonzero)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: validated configuration fields require config(id = N)
--> tests/ui/missing_id.rs:6:5
|
6 | value: u32,
| ^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct MissingMetadata {
#[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration fields require config(key = "...")
--> tests/ui/missing_metadata.rs:6:5
|
6 | value: u32,
| ^^^^^
@@ -0,0 +1,11 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct MixedSectionAndScalar {
#[config(section)]
child: u32,
#[config(id = 1, key = "scalar", unit = "count", default = 1, range(min = 0, max = 2))]
scalar: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: ConfigValidate records must contain either scalar config fields or config(section) fields exclusively
--> tests/ui/mixed_section_scalar.rs:6:5
|
6 | child: u32,
| ^^^^^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct InvalidRange {
#[config(id = 1, key = "value", unit = "count", default = 2, range(min = 3, max = 1))]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: configuration range minimum exceeds maximum
--> tests/ui/range_minimum_exceeds_maximum.rs:6:5
|
6 | value: u32,
| ^^^^^
@@ -0,0 +1,19 @@
[package]
name = "kaptan-config-mavlink"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[features]
# Explicitly opt-in host/SITL test authority. Production cannot construct an
# authorization token from a CRC-valid raw MAVLink frame.
host-authority = []
[dependencies]
kaptan-config = { path = "../kaptan-config" }
kaptan-log = { path = "../kaptan-log" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
@@ -0,0 +1,271 @@
//! Authorized, transport-neutral configuration commands.
use kaptan_config::{ConfigValue, FieldId};
/// Exact vehicle endpoint selected by a configuration request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Target {
/// MAVLink system identity.
system: u8,
/// MAVLink component identity.
component: u8,
}
/// Selects the configuration snapshot read by a value request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValueSource {
/// Immutable boot-active snapshot.
Active,
/// Last durably committed candidate.
Committed,
/// The caller's open RAM transaction.
Transaction,
}
impl Target {
/// Creates an exact system/component target.
pub const fn new(system: u8, component: u8) -> Self {
assert!(
system != 0 && component != 0,
"configuration target must be exact"
);
Self { system, component }
}
/// Parses an exact non-broadcast target from an untrusted transport.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self { system, component })
}
}
}
/// One command accepted by the Kaptan configuration microservice.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ConfigCommand {
/// Reads protocol/schema metadata before any descriptor or upload request.
InfoRequest {
/// Client correlation identity.
request_id: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Enumerates a bounded page of schema descriptors.
DescriptorRequest {
/// Client correlation identity.
request_id: u32,
/// First stable descriptor index.
first: u16,
/// Requested number of descriptors, clamped to the protocol bound.
count: u16,
/// Exact addressed endpoint.
target: Target,
},
/// Reads one typed scalar from an immutable or transaction snapshot.
ValueRequest {
/// Client correlation identity.
request_id: u32,
/// Lease-bound session for transaction reads, otherwise zero.
session_id: u32,
/// Stable field identity.
field: FieldId,
/// Selected snapshot.
source: ValueSource,
/// Exact addressed endpoint.
target: Target,
},
/// Starts one bounded CBOR snapshot import into the caller's transaction.
ImportBegin {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Complete CBOR byte length.
length: u16,
/// Exact schema identifier advertised by `KAPTAN_CONFIG_INFO`.
schema_id: [u8; 16],
/// CRC-32C over exactly `length` CBOR bytes.
crc32c: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Supplies one fixed-bounded CBOR import chunk.
ImportChunk {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Server-issued ID returned by the matching import-begin result.
transfer_id: u32,
/// Byte offset into the complete import.
offset: u16,
/// Used bytes of `data`.
length: u8,
/// Fixed MAVLink payload data.
data: [u8; 192],
/// Exact addressed endpoint.
target: Target,
},
/// Checks the completed import and replaces only the RAM transaction.
ImportFinish {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Server-issued ID returned by the matching import-begin result.
transfer_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Starts an edit transaction from the expected committed generation.
Begin {
/// Client correlation identity.
request_id: u32,
/// Optimistic concurrency generation.
expected_generation: u64,
/// Exact addressed endpoint.
target: Target,
},
/// Sets one typed scalar in the open transaction.
Set {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Stable schema-local field identity.
field: FieldId,
/// Typed value.
value: ConfigValue,
/// Exact addressed endpoint.
target: Target,
},
/// Validates the complete candidate without persistence.
Validate {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Validates and atomically persists the complete candidate.
Commit {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic committed generation.
expected_generation: u64,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Discards the RAM transaction.
Abort {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Exact addressed endpoint.
target: Target,
},
}
impl ConfigCommand {
/// Returns the client request identity used for idempotency.
pub const fn request_id(self) -> u32 {
match self {
Self::InfoRequest { request_id, .. }
| Self::DescriptorRequest { request_id, .. }
| Self::ValueRequest { request_id, .. }
| Self::ImportBegin { request_id, .. }
| Self::ImportChunk { request_id, .. }
| Self::ImportFinish { request_id, .. }
| Self::Begin { request_id, .. }
| Self::Set { request_id, .. }
| Self::Validate { request_id, .. }
| Self::Commit { request_id, .. }
| Self::Abort { request_id, .. } => request_id,
}
}
/// Returns the endpoint selected by this command.
pub const fn target(self) -> Target {
match self {
Self::InfoRequest { target, .. }
| Self::DescriptorRequest { target, .. }
| Self::ValueRequest { target, .. }
| Self::ImportBegin { target, .. }
| Self::ImportChunk { target, .. }
| Self::ImportFinish { target, .. }
| Self::Begin { target, .. }
| Self::Set { target, .. }
| Self::Validate { target, .. }
| Self::Commit { target, .. }
| Self::Abort { target, .. } => target,
}
}
}
/// A capability token produced only by the future signing/replay authority.
///
/// The token carries a stable principal identity so the service can bind a
/// transaction lease to one authenticated source. It is intentionally opaque:
/// an unverified raw MAVLink frame cannot create one.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VerifiedAuthority {
principal: u64,
_private: (),
}
impl VerifiedAuthority {
/// Returns the authenticated principal identity for ownership checks.
pub const fn principal(self) -> u64 {
self.principal
}
#[cfg(any(test, feature = "host-authority"))]
/// Creates an explicit host/SITL authority token.
///
/// This exists only for host integration and protocol tests. Flight code
/// must receive the equivalent token from the reviewed signing/replay path.
pub const fn host(principal: u64) -> Self {
Self {
principal,
_private: (),
}
}
}
/// A command whose authority was verified before it reached this adapter.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Authorized<T> {
authority: VerifiedAuthority,
command: T,
}
impl<T> Authorized<T> {
#[cfg(any(test, feature = "host-authority"))]
/// Wraps a host/SITL command in an explicit test-only authority token.
pub const fn host(principal: u64, command: T) -> Self {
Self {
authority: VerifiedAuthority::host(principal),
command,
}
}
pub(crate) fn into_parts(self) -> (VerifiedAuthority, T) {
(self.authority, self.command)
}
}
@@ -0,0 +1,20 @@
//! Kaptan custom-MAVLink configuration microservice adapter.
//!
//! This crate translates generated `kaptan.xml` records to the transport-
//! neutral configuration lifecycle. It does not parse raw frames, authenticate
//! peers, deserialize CBOR, access storage directly, or mutate active flight
//! consumers.
#![no_std]
mod command;
mod protocol;
pub mod service;
pub mod wire;
pub use command::{Authorized, ConfigCommand, Target, ValueSource, VerifiedAuthority};
pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
pub use service::{
CAPABILITY_CBOR_IMPORT, ConfigInfo, ConfigProtocol, DescriptorPage, NoSnapshotImporter,
ServiceResponse, SnapshotImporter, ValueResponse,
};
@@ -0,0 +1,195 @@
//! Stable typed outcomes for the Kaptan configuration microservice.
use kaptan_config::{ConfigState, ConfigViolation, FieldId};
use kaptan_log::{EventHub, KaptanError, LogFlags, PublishOutcome};
/// Operation reported in an authoritative result record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operation {
/// Query/read operation.
Query,
/// Begin transaction.
Begin,
/// Scalar write.
Set,
/// Whole-candidate validation.
Validate,
/// Durable commit.
Commit,
/// Transaction abort.
Abort,
/// CBOR snapshot import into the isolated transaction.
Import,
}
/// Stable result classification.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultKind {
/// The operation was accepted.
Accepted,
/// A full snapshot committed but requires restart before activation.
CommittedRestartRequired,
/// The operation was rejected without an active-state transition.
Rejected,
}
/// Compact protocol error code. Detail belongs in [`ConfigViolation`] when a
/// configuration invariant was responsible.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum ProtocolError {
/// The request target did not match this component.
BadTarget,
/// Authorization was absent or insufficient.
AuthorizationDenied,
/// The optimistic durable generation did not match.
GenerationConflict,
/// The single transaction slot is occupied.
SessionBusy,
/// The session is unknown, expired, or belongs to a different principal.
InvalidSession,
/// Reusing one request ID with different content is forbidden.
RequestConflict,
/// The request was not based on the current transaction revision.
StaleRevision,
/// A command is invalid in the current lifecycle state.
BadState,
/// The field is not present in this schema.
UnknownField(FieldId),
/// The typed setter does not match the field descriptor.
TypeMismatch(FieldId),
/// A local or complete validation issue.
Violation(ConfigViolation),
/// Persistence failed after a complete validation pass.
StorageFailure,
/// A CBOR snapshot could not be decoded into this schema.
CborMalformed,
/// A CBOR snapshot exceeded the fixed import budget.
CborTooLarge,
/// The complete CBOR snapshot did not match the supplied CRC-32C.
BlobCrc,
/// The uploaded CBOR blob was prepared for a different schema.
SchemaMismatch,
/// This deployment does not expose bounded CBOR transaction import.
ImportUnavailable,
/// The adapter reached an impossible internal state.
Internal,
}
/// Authoritative completion for one request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigReply {
/// Client request identity.
pub request_id: u32,
/// The current session, or zero when none exists.
pub session_id: u32,
/// Server-assigned import transfer identity, or zero when not importing.
pub transfer_id: u32,
/// The transaction revision, or zero when no transaction exists.
pub revision: u32,
/// Latest committed generation, or zero for the factory profile.
pub generation: u64,
/// Completed operation.
pub operation: Operation,
/// High-level result.
pub result: ResultKind,
/// Exact typed failure, if rejected.
pub error: Option<ProtocolError>,
/// Current lifecycle state after handling the request.
pub state: ConfigState,
}
impl ConfigReply {
/// Stable primary field for the wire response, or the reserved sentinel.
pub const fn primary_field(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => match violation.primary() {
Some(field) => field.get(),
None => u16::MAX,
},
Some(ProtocolError::UnknownField(field)) | Some(ProtocolError::TypeMismatch(field)) => {
field.get()
}
_ => u16::MAX,
}
}
/// Stable related field for the wire response, or the reserved sentinel.
pub const fn related_field(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => match violation.related_field() {
Some(field) => field.get(),
None => u16::MAX,
},
_ => u16::MAX,
}
}
/// Stable validation-rule code, or zero when no validation rule applies.
pub const fn validation_rule(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => violation.rule().code(),
_ => 0,
}
}
/// Enqueues a critical diagnostic companion for a rejected structured
/// result. The `KAPTAN_CONFIG_RESULT` remains the correlated,
/// field/rule-precise authority; this sends only the derive-generated
/// high-level `ProtocolError` display identity through `KAPTAN_ERROR`.
///
/// The helper deliberately carries no request/session/generation fields
/// into the error channel. A composition owner may call it after it has
/// emitted or retained the authoritative result.
pub fn publish_rejection<const CRITICAL: usize, const BEST_EFFORT: usize>(
self,
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
) -> Option<PublishOutcome> {
if self.result != ResultKind::Rejected {
return None;
}
self.error
.as_ref()
.map(|error| hub.publish_error(LogFlags::NONE, error))
}
}
#[cfg(test)]
mod tests {
use kaptan_log::{EventHub, LogFilter, PublishOutcome, RecordChannel};
use super::{ConfigReply, Operation, ProtocolError, ResultKind};
use kaptan_config::ConfigState;
#[test]
fn rejected_result_enqueues_only_its_high_level_error_identity() {
let reply = ConfigReply {
request_id: 42,
session_id: 7,
transfer_id: 0,
revision: 3,
generation: 9,
operation: Operation::Set,
result: ResultKind::Rejected,
error: Some(ProtocolError::BadState),
state: ConfigState::Editing,
};
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
assert_eq!(
reply.publish_rejection(&mut hub),
Some(PublishOutcome::Queued)
);
let record = hub.peek().expect("rejection is retained");
assert_eq!(record.channel(), RecordChannel::Error);
assert_eq!(
record.error_path().unwrap().as_str(),
"kaptan_config_mavlink::protocol::ProtocolError::BadState"
);
assert!(record.additional_information().unwrap().is_empty());
let accepted = ConfigReply {
result: ResultKind::Accepted,
error: None,
..reply
};
assert_eq!(accepted.publish_rejection(&mut hub), None);
}
}
@@ -0,0 +1,14 @@
//! Transport-neutral façade for the custom configuration service.
//!
//! Public callers see one small protocol API here. Its bounded query,
//! session, replay, and CBOR-import mechanics stay in private sibling modules
//! so no one concern becomes a second service owner.
mod import;
mod query;
mod replay;
mod session;
pub use import::{CAPABILITY_CBOR_IMPORT, NoSnapshotImporter, SnapshotImporter};
pub use query::{ConfigInfo, DescriptorPage, ServiceResponse, ValueResponse};
pub use session::ConfigProtocol;
@@ -0,0 +1,248 @@
//! Bounded CBOR snapshot transfer state.
use kaptan_config::{CommitError, ConfigManager, ConfigModel, ProfileStore};
use super::session::{ConfigProtocol, Session};
use crate::{ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind};
/// Maximum complete CBOR snapshot accepted by the protocol.
pub(super) const IMPORT_MAXIMUM_BYTES: usize = 4096;
/// Payload bytes carried by each aligned import chunk.
pub(super) const IMPORT_CHUNK_BYTES: usize = 192;
pub(super) const IMPORT_CHUNK_COUNT: usize = IMPORT_MAXIMUM_BYTES.div_ceil(IMPORT_CHUNK_BYTES);
/// `KAPTAN_CONFIG_INFO.capability_flags`: bounded CBOR transaction import.
pub const CAPABILITY_CBOR_IMPORT: u8 = 1;
/// Decodes a complete bounded CBOR snapshot into this service's candidate.
///
/// Vehicle-specific implementations live above this transport adapter so the
/// generic MAVLink layer never owns a profile schema or board capabilities.
pub trait SnapshotImporter<C> {
/// Whether this concrete deployment exposes the bounded import path.
const IMPORT_SUPPORTED: bool;
/// Current format carried inside the deployment's raw CBOR profile blob.
const PROFILE_FORMAT: u16;
/// Converts one complete CBOR payload to a candidate. The service performs
/// the subsequent complete-context validation before replacing the RAM
/// transaction.
fn decode(&self, bytes: &[u8]) -> Result<C, ProtocolError>;
}
/// Default importer used when a deployment deliberately does not expose CBOR
/// import yet. It keeps the service type usable for descriptor-only tests.
#[derive(Clone, Copy, Debug, Default)]
pub struct NoSnapshotImporter;
impl<C> SnapshotImporter<C> for NoSnapshotImporter {
const IMPORT_SUPPORTED: bool = false;
const PROFILE_FORMAT: u16 = 0;
fn decode(&self, _: &[u8]) -> Result<C, ProtocolError> {
Err(ProtocolError::CborMalformed)
}
}
#[derive(Clone, Copy)]
pub(super) struct ImportBuffer {
owner: u64,
session_id: u32,
transfer_id: u32,
revision: u32,
length: u16,
crc32c: u32,
received: [bool; IMPORT_CHUNK_COUNT],
data: [u8; IMPORT_MAXIMUM_BYTES],
}
impl<I> ConfigProtocol<I> {
pub(super) fn begin_import<C>(
&mut self,
owner: u64,
command: ConfigCommand,
session: Session,
) -> ConfigReply
where
C: ConfigModel,
I: SnapshotImporter<C>,
{
let ConfigCommand::ImportBegin {
request_id,
length,
schema_id,
crc32c,
..
} = command
else {
return self.rejected(command, ProtocolError::Internal);
};
if !I::IMPORT_SUPPORTED {
return self.rejected(command, ProtocolError::ImportUnavailable);
}
if schema_id != C::SCHEMA_ID {
return self.rejected(command, ProtocolError::SchemaMismatch);
}
if length == 0 || usize::from(length) > IMPORT_MAXIMUM_BYTES {
return self.rejected(command, ProtocolError::CborTooLarge);
}
let Some(transfer_id) = self.allocate_transfer_id() else {
return self.rejected(command, ProtocolError::Internal);
};
self.import = Some(ImportBuffer {
owner,
session_id: session.id,
transfer_id,
revision: session.revision,
length,
crc32c,
received: [false; IMPORT_CHUNK_COUNT],
data: [0; IMPORT_MAXIMUM_BYTES],
});
let mut reply = self.reply(
request_id,
session.id,
session.revision,
Operation::Import,
ResultKind::Accepted,
None,
);
reply.transfer_id = transfer_id;
reply
}
pub(super) fn import_chunk(&mut self, command: ConfigCommand, session: Session) -> ConfigReply {
let ConfigCommand::ImportChunk {
request_id,
transfer_id,
offset,
length,
data,
..
} = command
else {
return self.rejected(command, ProtocolError::Internal);
};
let offset = usize::from(offset);
let length = usize::from(length);
let Some(import) = self.import.as_mut() else {
return self.rejected(command, ProtocolError::BadState);
};
if import.owner != session.owner
|| import.session_id != session.id
|| import.transfer_id != transfer_id
{
return self.rejected(command, ProtocolError::InvalidSession);
}
if import.revision != session.revision {
return self.rejected(command, ProtocolError::StaleRevision);
}
let total = usize::from(import.length);
let Some(end) = offset.checked_add(length) else {
return self.rejected(command, ProtocolError::CborMalformed);
};
if length == 0
|| offset % IMPORT_CHUNK_BYTES != 0
|| end > total
|| (length != IMPORT_CHUNK_BYTES && end != total)
{
return self.rejected(command, ProtocolError::CborMalformed);
}
let chunk = offset / IMPORT_CHUNK_BYTES;
if import.received[chunk] {
if import.data[offset..end] != data[..length] {
return self.rejected(command, ProtocolError::RequestConflict);
}
} else {
import.data[offset..end].copy_from_slice(&data[..length]);
import.received[chunk] = true;
}
self.reply(
request_id,
session.id,
session.revision,
Operation::Import,
ResultKind::Accepted,
None,
)
}
pub(super) fn finish_import<C, S>(
&mut self,
manager: &mut ConfigManager<C, S>,
command: ConfigCommand,
session: Session,
request_id: u32,
transfer_id: u32,
) -> ConfigReply
where
C: ConfigModel,
S: ProfileStore<C>,
I: SnapshotImporter<C>,
{
let Some(import) = self.import else {
return self.rejected(command, ProtocolError::BadState);
};
if import.owner != session.owner
|| import.session_id != session.id
|| import.transfer_id != transfer_id
{
return self.rejected(command, ProtocolError::InvalidSession);
}
if import.revision != session.revision {
return self.rejected(command, ProtocolError::StaleRevision);
}
let total = usize::from(import.length);
let chunks = total.div_ceil(IMPORT_CHUNK_BYTES);
if import.received[..chunks].iter().any(|received| !received) {
return self.rejected(command, ProtocolError::CborMalformed);
}
if crc32c(&import.data[..total]) != import.crc32c {
return self.rejected(command, ProtocolError::BlobCrc);
}
let candidate = match self.importer.decode(&import.data[..total]) {
Ok(candidate) => candidate,
Err(error) => return self.rejected(command, error),
};
let Some(next_revision) = session.revision.checked_add(1) else {
return self.rejected(command, ProtocolError::Internal);
};
match manager.replace_transaction(candidate) {
Ok(()) => {
let Some(session) = self.session.as_mut() else {
return self.rejected(command, ProtocolError::Internal);
};
session.revision = next_revision;
let (session_id, revision) = (session.id, session.revision);
self.import = None;
self.reply(
request_id,
session_id,
revision,
Operation::Import,
ResultKind::Accepted,
None,
)
}
Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
Err(CommitError::InvalidCandidate(violation)) => {
self.rejected(command, ProtocolError::Violation(violation))
}
Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal),
}
}
}
pub(super) fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in bytes {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = if crc & 1 == 0 {
crc >> 1
} else {
(crc >> 1) ^ 0x82f6_3b78
};
}
}
!crc
}
@@ -0,0 +1,208 @@
//! Read-only protocol discovery and value-query responses.
use kaptan_config::{
ConfigManager, ConfigModel, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation,
ProfileStore,
};
use super::{import::SnapshotImporter, session::ConfigProtocol};
use crate::{ProtocolError, ValueSource};
/// Bounded descriptor page returned by a schema discovery request.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DescriptorPage {
/// Request being answered.
pub request_id: u32,
/// First requested descriptor index.
pub first: u16,
/// Number of descriptors present in `descriptors`.
pub len: u8,
/// Fixed-capacity response page; `None` slots are unused.
pub descriptors: [Option<&'static FieldDescriptor>; 4],
}
/// One typed value read from an explicitly selected configuration snapshot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ValueResponse {
/// Request being answered.
pub request_id: u32,
/// Transaction session for transaction values, otherwise zero.
pub session_id: u32,
/// Current transaction revision, otherwise zero.
pub revision: u32,
/// Requested stable field identity.
pub field: FieldId,
/// Declared scalar value.
pub value: ConfigValue,
}
/// Correlated protocol/schema discovery response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigInfo {
/// Request being answered.
pub request_id: u32,
/// Boot-active generation, or `None` for the factory profile.
pub active_generation: Option<Generation>,
/// Last committed generation, or `None` for the factory profile.
pub committed_generation: Option<Generation>,
/// Exact schema identifier expected by profile import.
pub schema_id: [u8; 16],
/// Version carried inside the one raw CBOR profile blob.
pub profile_format: u16,
/// Number of editable fields in the registry.
pub field_count: u16,
/// Current configuration lifecycle state.
pub state: ConfigState,
/// Deployment capabilities such as bounded CBOR import.
pub capability_flags: u8,
}
/// Exact response produced by the custom configuration service.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ServiceResponse {
/// Correlated protocol/schema discovery response.
Info(ConfigInfo),
/// Completion for a mutating operation or rejected query.
Result(crate::ConfigReply),
/// Bounded self-describing schema page.
Descriptors(DescriptorPage),
/// One typed scalar value.
Value(ValueResponse),
}
impl<I> ConfigProtocol<I> {
/// Returns static descriptor metadata by stable schema index.
pub fn descriptor<C, S>(
&self,
manager: &ConfigManager<C, S>,
index: u16,
) -> Option<&'static FieldDescriptor>
where
C: ConfigModel,
S: ProfileStore<C>,
{
manager.descriptor(index)
}
/// Reads one typed value without opening or changing a transaction.
pub fn value<C, S>(
&self,
manager: &ConfigManager<C, S>,
source: ValueSource,
field: FieldId,
) -> Option<ConfigValue>
where
C: ConfigModel,
S: ProfileStore<C>,
{
match source {
ValueSource::Active => manager.active_value(field),
ValueSource::Committed => manager.committed_value(field),
ValueSource::Transaction => manager.transaction_value(field),
}
}
pub(super) fn descriptors<C, S>(
&self,
manager: &ConfigManager<C, S>,
request_id: u32,
first: u16,
requested: u16,
) -> DescriptorPage
where
C: ConfigModel,
S: ProfileStore<C>,
{
let mut descriptors = [None; 4];
let count = requested.min(descriptors.len() as u16);
let mut len = 0;
for offset in 0..count {
let Some(index) = first.checked_add(offset) else {
break;
};
let Some(descriptor) = manager.descriptor(index) else {
break;
};
descriptors[usize::from(len)] = Some(descriptor);
len += 1;
}
DescriptorPage {
request_id,
first,
len,
descriptors,
}
}
pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
where
C: ConfigModel,
S: ProfileStore<C>,
I: SnapshotImporter<C>,
{
ConfigInfo {
request_id,
active_generation: manager.active_generation(),
committed_generation: manager.committed_generation(),
schema_id: C::SCHEMA_ID,
profile_format: I::PROFILE_FORMAT,
field_count: manager.field_count(),
state: self.state,
capability_flags: self.capability_flags_for::<C>(),
}
}
pub(super) fn value_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
owner: u64,
request_id: u32,
session_id: u32,
field: FieldId,
source: ValueSource,
) -> ServiceResponse
where
C: ConfigModel,
S: ProfileStore<C>,
{
let (value, reply_session, revision) = match source {
ValueSource::Active => (manager.active_value(field), 0, 0),
ValueSource::Committed => (manager.committed_value(field), 0, 0),
ValueSource::Transaction => {
let Some(session) = self.session else {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::InvalidSession,
));
};
if session.id != session_id || session.owner != owner {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::InvalidSession,
));
}
(
manager.transaction_value(field),
session.id,
session.revision,
)
}
};
let Some(value) = value else {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::UnknownField(field),
));
};
ServiceResponse::Value(ValueResponse {
request_id,
session_id: reply_session,
revision,
field,
value,
})
}
}
@@ -0,0 +1,272 @@
//! Bounded request-replay cache for idempotent client retries.
use kaptan_config::ConfigValue;
use super::session::ConfigProtocol;
use crate::{ConfigCommand, ConfigReply, ProtocolError};
pub(super) const REQUEST_CACHE_CAPACITY: usize = 8;
#[derive(Clone, Copy)]
pub(super) struct RememberedResult {
pub(super) owner: u64,
pub(super) command: ConfigCommand,
pub(super) reply: ConfigReply,
}
impl<I> ConfigProtocol<I> {
pub(super) fn cached(&self, owner: u64, command: ConfigCommand) -> Option<ConfigReply> {
for cached in self.remembered.iter().flatten() {
if cached.owner != owner || cached.command.request_id() != command.request_id() {
continue;
}
if same_command(cached.command, command) {
return Some(cached.reply);
}
return Some(self.rejected(command, ProtocolError::RequestConflict));
}
None
}
pub(super) fn remember(&mut self, result: RememberedResult) {
self.remembered[self.next_remembered] = Some(result);
self.next_remembered = (self.next_remembered + 1) % REQUEST_CACHE_CAPACITY;
}
}
fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
match (left, right) {
(
ConfigCommand::InfoRequest {
request_id: left_request,
target: left_target,
},
ConfigCommand::InfoRequest {
request_id: right_request,
target: right_target,
},
) => left_request == right_request && left_target == right_target,
(
ConfigCommand::DescriptorRequest {
request_id: left_request,
first: left_first,
count: left_count,
target: left_target,
},
ConfigCommand::DescriptorRequest {
request_id: right_request,
first: right_first,
count: right_count,
target: right_target,
},
) => {
left_request == right_request
&& left_first == right_first
&& left_count == right_count
&& left_target == right_target
}
(
ConfigCommand::ValueRequest {
request_id: left_request,
session_id: left_session,
field: left_field,
source: left_source,
target: left_target,
},
ConfigCommand::ValueRequest {
request_id: right_request,
session_id: right_session,
field: right_field,
source: right_source,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_field == right_field
&& left_source == right_source
&& left_target == right_target
}
(
ConfigCommand::Begin {
request_id: left_request,
expected_generation: left_generation,
target: left_target,
},
ConfigCommand::Begin {
request_id: right_request,
expected_generation: right_generation,
target: right_target,
},
) => {
left_request == right_request
&& left_generation == right_generation
&& left_target == right_target
}
(
ConfigCommand::Set {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
field: left_field,
value: left_value,
target: left_target,
},
ConfigCommand::Set {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
field: right_field,
value: right_value,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_field == right_field
&& same_value(left_value, right_value)
&& left_target == right_target
}
(
ConfigCommand::Validate {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::Validate {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_target == right_target
}
(
ConfigCommand::Commit {
request_id: left_request,
session_id: left_session,
expected_generation: left_generation,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::Commit {
request_id: right_request,
session_id: right_session,
expected_generation: right_generation,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_generation == right_generation
&& left_revision == right_revision
&& left_target == right_target
}
(
ConfigCommand::Abort {
request_id: left_request,
session_id: left_session,
target: left_target,
},
ConfigCommand::Abort {
request_id: right_request,
session_id: right_session,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_target == right_target
}
(
ConfigCommand::ImportBegin {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
length: left_length,
schema_id: left_schema,
crc32c: left_crc,
target: left_target,
},
ConfigCommand::ImportBegin {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
length: right_length,
schema_id: right_schema,
crc32c: right_crc,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_length == right_length
&& left_schema == right_schema
&& left_crc == right_crc
&& left_target == right_target
}
(
ConfigCommand::ImportChunk {
request_id: left_request,
session_id: left_session,
transfer_id: left_transfer,
offset: left_offset,
length: left_length,
data: left_data,
target: left_target,
},
ConfigCommand::ImportChunk {
request_id: right_request,
session_id: right_session,
transfer_id: right_transfer,
offset: right_offset,
length: right_length,
data: right_data,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_transfer == right_transfer
&& left_offset == right_offset
&& left_length == right_length
&& left_data == right_data
&& left_target == right_target
}
(
ConfigCommand::ImportFinish {
request_id: left_request,
session_id: left_session,
transfer_id: left_transfer,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::ImportFinish {
request_id: right_request,
session_id: right_session,
transfer_id: right_transfer,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_transfer == right_transfer
&& left_revision == right_revision
&& left_target == right_target
}
_ => false,
}
}
fn same_value(left: ConfigValue, right: ConfigValue) -> bool {
left.kind() == right.kind() && left.raw_bits() == right.raw_bits()
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,501 @@
//! Generated Kaptan-dialect message translation.
use kaptan_config::{
ApplyPolicy, ConfigState, ConfigValue, FieldDescriptor, FieldId, Generation, ValidationRule,
ValueKind,
};
use kaptan_log::KaptanError;
use kaptan_mavlink::message::Message;
use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_RESULT_DATA,
KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation, KaptanConfigPolicy,
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
MavMessage,
};
use crate::{
ConfigCommand, ConfigReply, Operation, ProtocolError, ResultKind, Target, ValueSource,
service::{ConfigInfo, ValueResponse},
};
/// Rejects a generated message that is not a Kaptan configuration command.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DecodeError {
/// The message belongs to a response, telemetry, or another service.
UnsupportedMessage,
/// A wire field identity used a reserved sentinel.
InvalidFieldId,
/// The message used an unknown value-source discriminator.
InvalidValueSource,
/// A boolean setter used a representation other than zero or one.
InvalidBoolean,
/// A configuration request used a broadcast/zero target endpoint.
InvalidTarget,
}
/// Converts one generated custom request into a transport-neutral command.
///
/// This only decodes a validated MAVLink payload. An authenticated ingress must
/// still wrap the result in [`crate::Authorized`] before dispatch.
pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
if matches!(message.target_system_id(), Some(0))
|| matches!(message.target_component_id(), Some(0))
{
return Err(DecodeError::InvalidTarget);
}
match message {
MavMessage::KAPTAN_CONFIG_INFO_REQUEST(data) => Ok(ConfigCommand::InfoRequest {
request_id: data.request_id,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
Ok(ConfigCommand::DescriptorRequest {
request_id: data.request_id,
first: data.first,
count: data.count,
target: Target::new(data.target_system, data.target_component),
})
}
MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(data) => Ok(ConfigCommand::ValueRequest {
request_id: data.request_id,
session_id: data.session_id,
field: field_id(data.field_id)?,
source: value_source(data.source)?,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_BEGIN(data) => Ok(ConfigCommand::ImportBegin {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
length: data.length,
schema_id: data.schema_id,
crc32c: data.crc32c,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_CHUNK(data) => Ok(ConfigCommand::ImportChunk {
request_id: data.request_id,
session_id: data.session_id,
transfer_id: data.transfer_id,
offset: data.offset,
length: data.length,
data: data.data,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_IMPORT_FINISH(data) => Ok(ConfigCommand::ImportFinish {
request_id: data.request_id,
session_id: data.session_id,
transfer_id: data.transfer_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
request_id: data.request_id,
expected_generation: data.expected_generation,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::F32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_BOOL(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::Bool(boolean(data.value)?),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_I32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::I32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_U32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::U32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_U64(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::U64(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F64(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::F64(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_VALIDATE(data) => Ok(ConfigCommand::Validate {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
request_id: data.request_id,
session_id: data.session_id,
expected_generation: data.expected_generation,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_ABORT(data) => Ok(ConfigCommand::Abort {
request_id: data.request_id,
session_id: data.session_id,
target: Target::new(data.target_system, data.target_component),
}),
_ => Err(DecodeError::UnsupportedMessage),
}
}
const fn field_id(value: u16) -> Result<FieldId, DecodeError> {
match FieldId::try_new(value) {
Some(value) => Ok(value),
None => Err(DecodeError::InvalidFieldId),
}
}
const fn value_source(value: KaptanConfigValueSource) -> Result<ValueSource, DecodeError> {
match value {
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_ACTIVE => Ok(ValueSource::Active),
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED => Ok(ValueSource::Committed),
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_TRANSACTION => {
Ok(ValueSource::Transaction)
}
}
}
const fn boolean(value: u8) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
_ => Err(DecodeError::InvalidBoolean),
}
}
/// Encodes one authoritative service completion.
pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
KAPTAN_CONFIG_RESULT_DATA {
generation: reply.generation,
request_id: reply.request_id,
session_id: reply.session_id,
transfer_id: reply.transfer_id,
revision: reply.revision,
primary_field: reply.primary_field(),
related_field: reply.related_field(),
validation_rule: reply.validation_rule(),
operation: operation(reply.operation),
result: result(reply.result),
error: error(reply.error),
config_state: state(reply.state),
}
}
/// Encodes configuration protocol/schema status for a Kaptan control station.
pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA {
KAPTAN_CONFIG_INFO_DATA {
active_generation: info.active_generation.map_or(0, Generation::get),
committed_generation: info.committed_generation.map_or(0, Generation::get),
request_id: info.request_id,
schema_id: info.schema_id,
profile_format: info.profile_format,
field_count: info.field_count,
protocol_major: 1,
protocol_minor: 0,
config_state: state(info.state),
capability_flags: info.capability_flags,
}
}
/// Encodes self-describing metadata for one schema field.
pub fn encode_descriptor(
request_id: u32,
descriptor_index: u16,
descriptor: FieldDescriptor,
) -> KAPTAN_CONFIG_DESCRIPTOR_DATA {
KAPTAN_CONFIG_DESCRIPTOR_DATA {
minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits),
maximum_bits: descriptor.maximum.map_or(0, ConfigValue::raw_bits),
default_bits: descriptor.default.raw_bits(),
request_id,
descriptor_index,
field_id: descriptor.id.get(),
display_key: descriptor.display_key.into(),
unit: descriptor.unit.into(),
value_kind: value_kind(descriptor.kind),
apply_policy: policy(descriptor.apply_policy),
flags: 0,
}
}
/// Encodes a typed scalar read response for the Kaptan control station.
pub fn encode_value(value: ValueResponse) -> KAPTAN_CONFIG_VALUE_DATA {
KAPTAN_CONFIG_VALUE_DATA {
value_bits: value.value.raw_bits(),
request_id: value.request_id,
session_id: value.session_id,
revision: value.revision,
field_id: value.field.get(),
value_kind: value_kind(value.value.kind()),
}
}
const fn value_kind(kind: ValueKind) -> KaptanConfigValueKind {
match kind {
ValueKind::Bool => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_BOOL,
ValueKind::I32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_I32,
ValueKind::U32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32,
ValueKind::U64 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U64,
ValueKind::F32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F32,
ValueKind::F64 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F64,
}
}
const fn policy(policy: ApplyPolicy) -> KaptanConfigPolicy {
match policy {
ApplyPolicy::Live => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_LIVE,
ApplyPolicy::DisarmedReset => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_DISARMED_RESET,
ApplyPolicy::RestartRequired => KaptanConfigPolicy::KAPTAN_CONFIG_POLICY_RESTART_REQUIRED,
}
}
const fn operation(operation: Operation) -> KaptanConfigOperation {
match operation {
Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY,
Operation::Begin => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN,
Operation::Set => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET,
Operation::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE,
Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT,
Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT,
Operation::Import => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_IMPORT,
}
}
const fn result(result: ResultKind) -> KaptanConfigResult {
match result {
ResultKind::Accepted => KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED,
ResultKind::CommittedRestartRequired => {
KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED
}
ResultKind::Rejected => KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED,
}
}
const fn state(state: ConfigState) -> KaptanConfigState {
match state {
ConfigState::Active => KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE,
ConfigState::Editing => KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING,
ConfigState::RestartRequired => KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED,
}
}
const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
match error {
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET,
Some(ProtocolError::AuthorizationDenied) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_AUTHORIZATION_DENIED
}
Some(ProtocolError::GenerationConflict) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_GENERATION_CONFLICT
}
Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
Some(ProtocolError::InvalidSession) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
}
Some(ProtocolError::RequestConflict) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_REQUEST_CONFLICT
}
Some(ProtocolError::StaleRevision) => KaptanConfigError::KAPTAN_CONFIG_ERROR_STALE_REVISION,
Some(ProtocolError::BadState) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_STATE,
Some(ProtocolError::UnknownField(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
}
Some(ProtocolError::TypeMismatch(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
}
Some(ProtocolError::Violation(violation)) => match violation.rule() {
ValidationRule::NonFinite => KaptanConfigError::KAPTAN_CONFIG_ERROR_NON_FINITE,
ValidationRule::BelowMinimum | ValidationRule::Zero => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_BELOW_MINIMUM
}
ValidationRule::AboveMaximum => KaptanConfigError::KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM,
ValidationRule::Inconsistent => KaptanConfigError::KAPTAN_CONFIG_ERROR_CROSS_FIELD,
ValidationRule::UnsupportedCapability => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
}
ValidationRule::UnsupportedFormat => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_FORMAT
}
},
Some(ProtocolError::StorageFailure) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE
}
Some(ProtocolError::CborMalformed) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_MALFORMED,
Some(ProtocolError::CborTooLarge) => KaptanConfigError::KAPTAN_CONFIG_ERROR_CBOR_TOO_LARGE,
Some(ProtocolError::BlobCrc) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BLOB_CRC,
Some(ProtocolError::SchemaMismatch) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_SCHEMA_MISMATCH
}
Some(ProtocolError::ImportUnavailable) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
}
Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{ConfigState, ConfigViolation, ValidationRule};
use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
};
use super::*;
use crate::CAPABILITY_CBOR_IMPORT;
#[test]
fn generated_set_message_preserves_typed_value_and_identifiers() {
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::Set {
request_id: 7,
session_id: 8,
expected_revision: 9,
field: FieldId::new(10),
value: ConfigValue::F32(1.5),
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_info_request_is_correlated_and_exactly_targeted() {
let message = MavMessage::KAPTAN_CONFIG_INFO_REQUEST(KAPTAN_CONFIG_INFO_REQUEST_DATA {
request_id: 31,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::InfoRequest {
request_id: 31,
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_config_requests_reject_broadcast_targets() {
let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 0,
target_component: 2,
});
let zero_component = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 1,
target_component: 0,
});
assert_eq!(
decode_command(&zero_system),
Err(DecodeError::InvalidTarget)
);
assert_eq!(
decode_command(&zero_component),
Err(DecodeError::InvalidTarget)
);
}
#[test]
fn result_encodes_exact_validation_identity() {
let reply = ConfigReply {
request_id: 1,
session_id: 2,
transfer_id: 0,
revision: 3,
generation: 4,
operation: Operation::Set,
result: ResultKind::Rejected,
error: Some(ProtocolError::Violation(ConfigViolation::field(
FieldId::new(11),
ValidationRule::NonFinite,
))),
state: ConfigState::Editing,
};
let encoded = encode_result(reply);
assert_eq!(encoded.primary_field, 11);
assert_eq!(encoded.validation_rule, ValidationRule::NonFinite.code());
assert_eq!(
encoded.error,
KaptanConfigError::KAPTAN_CONFIG_ERROR_NON_FINITE
);
}
#[test]
fn unavailable_import_has_its_own_structured_error_code() {
let reply = ConfigReply {
request_id: 1,
session_id: 0,
transfer_id: 0,
revision: 0,
generation: 0,
operation: Operation::Import,
result: ResultKind::Rejected,
error: Some(ProtocolError::ImportUnavailable),
state: ConfigState::Active,
};
assert_eq!(
encode_result(reply).error,
KaptanConfigError::KAPTAN_CONFIG_ERROR_IMPORT_UNAVAILABLE
);
}
#[test]
fn info_preserves_service_capabilities() {
let info = encode_info(ConfigInfo {
request_id: 7,
active_generation: None,
committed_generation: None,
schema_id: [0; 16],
profile_format: 1,
field_count: 23,
state: ConfigState::Active,
capability_flags: CAPABILITY_CBOR_IMPORT,
});
assert_eq!(info.request_id, 7);
assert_eq!(info.capability_flags, CAPABILITY_CBOR_IMPORT);
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "kaptan-config"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-config-derive = { path = "../kaptan-config-derive" }
kaptan-log = { path = "../kaptan-log" }
+85
View File
@@ -0,0 +1,85 @@
//! Bounded, transport-neutral configuration validation errors.
use crate::FieldId;
/// The invariant violated by a configuration field or complete profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u16)]
pub enum ValidationRule {
/// A floating-point value was NaN or infinite.
NonFinite = 1,
/// A value required to be nonzero was zero.
Zero = 2,
/// A value was below its inclusive lower bound.
BelowMinimum = 3,
/// A value exceeded its inclusive upper bound.
AboveMaximum = 4,
/// Individually valid fields do not form a valid complete configuration.
Inconsistent = 5,
/// The candidate requires a board capability that is unavailable.
UnsupportedCapability = 6,
/// The encoded snapshot format cannot be used by this firmware.
UnsupportedFormat = 7,
}
impl ValidationRule {
/// Stable numeric protocol/error-catalog code.
pub const fn code(self) -> u16 {
self as u16
}
}
/// An allocation-free validation issue.
///
/// The control station resolves numeric fields and rules through the schema
/// metadata. `related` identifies the other field for relational invariants.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigViolation {
primary: Option<FieldId>,
related: Option<FieldId>,
rule: ValidationRule,
}
impl ConfigViolation {
/// Constructs a field-local violation.
pub const fn field(primary: FieldId, rule: ValidationRule) -> Self {
Self {
primary: Some(primary),
related: None,
rule,
}
}
/// Constructs a relational violation.
pub const fn related(primary: FieldId, related: FieldId, rule: ValidationRule) -> Self {
Self {
primary: Some(primary),
related: Some(related),
rule,
}
}
/// Constructs a profile-level violation with no scalar identity.
pub const fn global(rule: ValidationRule) -> Self {
Self {
primary: None,
related: None,
rule,
}
}
/// First field associated with this issue.
pub const fn primary(self) -> Option<FieldId> {
self.primary
}
/// Related field for cross-field validation.
pub const fn related_field(self) -> Option<FieldId> {
self.related
}
/// Violated invariant.
pub const fn rule(self) -> ValidationRule {
self.rule
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Transport-neutral typed configuration fields and descriptors.
use crate::{ConfigViolation, ValidationRule};
use kaptan_log::KaptanError;
/// A stable, schema-local identity for one editable configuration field.
///
/// IDs are never reused within a schema generation. They are compact wire
/// identities; display strings belong to metadata and diagnostics only.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
#[repr(transparent)]
pub struct FieldId(u16);
impl FieldId {
/// Creates a stable field identity.
pub const fn new(value: u16) -> Self {
assert!(
value != 0 && value != u16::MAX,
"reserved configuration field id"
);
Self(value)
}
/// Parses a field identity received from an untrusted transport.
pub const fn try_new(value: u16) -> Option<Self> {
if value == 0 || value == u16::MAX {
None
} else {
Some(Self(value))
}
}
/// Returns the transport representation.
pub const fn get(self) -> u16 {
self.0
}
}
/// The scalar representation of a configuration value.
///
/// The variants deliberately preserve native widths: durations, for example,
/// remain `u64` microseconds instead of being lossy display-unit conversions.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ConfigValue {
/// Boolean value.
Bool(bool),
/// Signed 32-bit integer.
I32(i32),
/// Unsigned 32-bit integer.
U32(u32),
/// Unsigned 64-bit integer.
U64(u64),
/// IEEE-754 single-precision value.
F32(f32),
/// IEEE-754 double-precision value.
F64(f64),
}
impl ConfigValue {
/// The declared value kind.
pub const fn kind(self) -> ValueKind {
match self {
Self::Bool(_) => ValueKind::Bool,
Self::I32(_) => ValueKind::I32,
Self::U32(_) => ValueKind::U32,
Self::U64(_) => ValueKind::U64,
Self::F32(_) => ValueKind::F32,
Self::F64(_) => ValueKind::F64,
}
}
/// Canonical scalar bits for compact transport encodings.
pub const fn raw_bits(self) -> u64 {
match self {
Self::Bool(value) => value as u64,
Self::I32(value) => value as u32 as u64,
Self::U32(value) => value as u64,
Self::U64(value) => value,
Self::F32(value) => value.to_bits() as u64,
Self::F64(value) => value.to_bits(),
}
}
}
/// The scalar kind carried in a descriptor or typed protocol message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ValueKind {
/// Boolean.
Bool = 1,
/// Signed 32-bit integer.
I32 = 2,
/// Unsigned 32-bit integer.
U32 = 3,
/// Unsigned 64-bit integer.
U64 = 4,
/// Single-precision float.
F32 = 5,
/// Double-precision float.
F64 = 6,
}
/// When a committed field may affect the active runtime.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ApplyPolicy {
/// Reserved for individually proven live-safe changes.
Live = 1,
/// Reserved for controlled output-inhibited reconfiguration.
DisarmedReset = 2,
/// P1 policy: the field takes effect only at the next boot.
RestartRequired = 3,
}
/// Static metadata for one configuration field.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldDescriptor {
/// Stable schema-local identity.
pub id: FieldId,
/// GCS display key; never used as a wire identity.
pub display_key: &'static str,
/// Scalar storage type.
pub kind: ValueKind,
/// Inclusive lower bound when the value is ordered.
pub minimum: Option<ConfigValue>,
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// Firmware-build default.
pub default: ConfigValue,
/// Human-readable unit for schema metadata.
pub unit: &'static str,
/// Activation policy advertised to a control station.
pub apply_policy: ApplyPolicy,
}
/// Failure while changing one transaction field.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum FieldWriteError {
/// No descriptor exposes the requested identity.
UnknownField(FieldId),
/// The supplied scalar type differs from the descriptor type.
WrongType {
/// Requested field.
field: FieldId,
/// Expected descriptor kind.
expected: ValueKind,
/// Supplied scalar kind.
actual: ValueKind,
},
/// A locally validated scalar violated a stable rule.
Violation(ConfigViolation),
}
impl FieldWriteError {
/// Returns the typed local validation issue, if this was one.
pub const fn violation(self) -> Option<ConfigViolation> {
match self {
Self::Violation(violation) => Some(violation),
Self::UnknownField(_) | Self::WrongType { .. } => None,
}
}
/// Returns a field-local non-finite error.
pub const fn non_finite(field: FieldId) -> Self {
Self::Violation(ConfigViolation::field(field, ValidationRule::NonFinite))
}
}
/// Vehicle-specific editable candidate model.
///
/// Implementations expose every operator-editable scalar through the static
/// descriptor registry. A field write performs only local validation; complete
/// relational and capability validation happens through [`Self::validate`].
pub trait ConfigModel: Clone {
/// Immutable configuration consumed by the runtime at boot.
type Active;
/// Immutable compiled board/vehicle capability contract.
type Context;
/// Exact identifier for the current externally transferable schema.
const SCHEMA_ID: [u8; 16];
/// Returns the descriptor at a stable zero-based schema index.
fn descriptor(index: u16) -> Option<&'static FieldDescriptor>;
/// Number of fields exposed by the compiled schema.
fn field_count() -> u16;
/// Returns a candidate value by stable ID.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Returns one value from the immutable boot-active view.
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue>;
/// Applies exactly one locally valid scalar to this candidate.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Fully validates and converts the candidate to an immutable active view.
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation>;
}
+25
View File
@@ -0,0 +1,25 @@
//! Runtime-configuration primitives shared by vehicle-specific schemas.
//!
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. CBOR storage and MAVLink transport are separate adapters.
#![no_std]
extern crate self as kaptan_config;
pub mod error;
pub mod field;
pub mod lifecycle;
pub mod storage;
pub mod validation;
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind,
};
pub use kaptan_config_derive::ConfigValidate;
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use storage::{Generation, ProfileStore};
pub use validation::{ConfigAggregate, ConfigSection, ValidateFields};
@@ -0,0 +1,420 @@
//! Transactional committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
Generation, ProfileStore,
};
use kaptan_log::KaptanError;
/// Observable state of the bounded configuration transaction slot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigState {
/// The boot-active and committed snapshots match and no edit is open.
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after reboot.
RestartRequired,
}
/// Failure while recovering a boot configuration from a store.
#[derive(Debug, KaptanError)]
pub enum StartupError<E> {
/// The storage backend could not provide a committed record.
Store(E),
/// A committed record did not validate against this firmware's schema.
InvalidPersisted(ConfigViolation),
}
/// Failure while beginning a transaction.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BeginError {
/// Exactly one transaction is already open.
TransactionActive,
}
/// Failure while writing a scalar into the bounded transaction.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum SetError {
/// No transaction is open.
NoTransaction,
/// The candidate rejected the local scalar without mutation.
Field(FieldWriteError),
}
/// Failure while validating or committing a transaction.
#[derive(Debug, PartialEq, KaptanError)]
pub enum CommitError<E> {
/// No transaction is open.
NoTransaction,
/// The complete candidate violates a relational or capability invariant.
InvalidCandidate(ConfigViolation),
/// The storage backend could not commit the snapshot.
Store(E),
}
/// Owns the immutable boot-active profile, the latest durable candidate, and
/// exactly one RAM edit transaction.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
/// changes after boot. `commit` atomically persists a complete validated
/// candidate, then reports [`ConfigState::RestartRequired`].
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
committed: C,
transaction: Option<C>,
context: C::Context,
active_generation: Option<Generation>,
committed_generation: Option<Generation>,
store: S,
restart_required: bool,
}
impl<C, S> ConfigManager<C, S>
where
C: ConfigModel,
S: ProfileStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
/// is sufficient to arm; no recovery is implicit.
pub fn new(factory: C, context: C::Context, store: S) -> Result<Self, ConfigViolation> {
let active = factory.validate(&context)?;
Ok(Self {
active,
committed: factory,
transaction: None,
context,
active_generation: None,
committed_generation: None,
store,
restart_required: false,
})
}
/// Recovers the newest valid committed snapshot or uses the supplied
/// factory candidate when the store is empty.
pub fn recover(
factory: C,
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
committed,
transaction: None,
context,
active_generation: Some(generation),
committed_generation: Some(generation),
store,
restart_required: false,
})
}
/// Returns the immutable configuration selected at boot.
pub const fn active(&self) -> &C::Active {
&self.active
}
/// Returns the last durably committed candidate value.
pub fn committed_value(&self, id: FieldId) -> Option<ConfigValue> {
self.committed.field_value(id)
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
}
/// Returns a transaction value when an edit is open.
pub fn transaction_value(&self, id: FieldId) -> Option<ConfigValue> {
self.transaction.as_ref()?.field_value(id)
}
/// Returns descriptor metadata by stable schema index.
pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> {
C::descriptor(index)
}
/// Number of fields exposed by the compiled registry.
pub fn field_count(&self) -> u16 {
C::field_count()
}
/// Returns the current lifecycle state.
pub const fn state(&self) -> ConfigState {
if self.transaction.is_some() {
ConfigState::Editing
} else if self.restart_required {
ConfigState::RestartRequired
} else {
ConfigState::Active
}
}
/// Returns the boot-active snapshot generation.
pub const fn active_generation(&self) -> Option<Generation> {
self.active_generation
}
/// Returns the most recently recovered or committed generation.
pub const fn committed_generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Returns the most recently recovered or committed generation.
///
/// Retained as a concise alias for optimistic commit callers.
pub const fn generation(&self) -> Option<Generation> {
self.committed_generation
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
return Err(BeginError::TransactionActive);
}
self.transaction = Some(self.committed.clone());
Ok(())
}
/// Applies one locally valid scalar in the open transaction.
///
/// Cross-field-invalid intermediate states are allowed so multi-field
/// edits can be made naturally. They are rejected by `validate`/`commit`.
pub fn set(&mut self, id: FieldId, value: ConfigValue) -> Result<ConfigValue, SetError> {
let candidate = self.transaction.as_mut().ok_or(SetError::NoTransaction)?;
candidate.set_field(id, value).map_err(SetError::Field)?;
candidate
.field_value(id)
.ok_or(SetError::Field(FieldWriteError::UnknownField(id)))
}
/// Validates the complete open transaction without changing persistent or
/// active state.
pub fn validate(&self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
candidate
.validate(&self.context)
.map(|_| ())
.map_err(CommitError::InvalidCandidate)
}
/// Replaces the open RAM transaction with one complete imported candidate.
///
/// Unlike scalar edits, imports are accepted only after complete
/// relational and capability validation. Neither the boot-active nor the
/// committed snapshot is changed here.
pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError<S::Error>> {
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let transaction = self
.transaction
.as_mut()
.ok_or(CommitError::NoTransaction)?;
*transaction = candidate;
Ok(())
}
/// Validates and durably commits the complete open transaction.
pub fn commit(&mut self) -> Result<Generation, CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
let generation = self.store.persist(candidate).map_err(CommitError::Store)?;
self.committed = candidate.clone();
self.transaction = None;
self.committed_generation = Some(generation);
self.restart_required = true;
Ok(generation)
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
self.transaction.take().is_some()
}
/// Releases the owned store when the configuration owner shuts down.
pub fn into_store(self) -> S {
self.store
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
const DESCRIPTORS: [FieldDescriptor; 2] = [
FieldDescriptor {
id: VALUE,
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(2),
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
FieldDescriptor {
id: OTHER,
display_key: "test.other",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
default: ConfigValue::U32(4),
unit: "count",
apply_policy: ApplyPolicy::RestartRequired,
},
];
#[derive(Clone)]
struct Candidate {
value: u32,
other: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Active {
value: u32,
other: u32,
}
impl ConfigModel for Candidate {
type Active = Active;
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"CONFIGLIFECYCLE1";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
DESCRIPTORS.get(usize::from(index))
}
fn field_count() -> u16 {
DESCRIPTORS.len() as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(self.value)),
OTHER => Some(ConfigValue::U32(self.other)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(active.value)),
OTHER => Some(ConfigValue::U32(active.other)),
_ => None,
}
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
actual: value.kind(),
});
};
if !(1..=10).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
match id {
VALUE => self.value = value,
OTHER => self.other = value,
_ => return Err(FieldWriteError::UnknownField(id)),
}
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
if self.value > self.other {
return Err(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent,
));
}
Ok(Active {
value: self.value,
other: self.other,
})
}
}
struct MemoryStore;
impl ProfileStore<Candidate> for MemoryStore {
type Error = ();
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
Ok(Generation::new(7))
}
}
#[test]
fn invalid_intermediate_transaction_never_changes_active_or_committed() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
assert_eq!(
manager.set(VALUE, ConfigValue::U32(5)),
Ok(ConfigValue::U32(5))
);
assert_eq!(
manager.validate(),
Err(CommitError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent
)))
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(2)));
assert_eq!(manager.transaction_value(VALUE), Some(ConfigValue::U32(5)));
assert_eq!(manager.state(), ConfigState::Editing);
}
#[test]
fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(Generation::new(7)));
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.active_generation(), None);
assert_eq!(manager.committed_generation(), Some(Generation::new(7)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
assert!(manager.abort());
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
}
}
@@ -0,0 +1,48 @@
//! Persistence interface for complete validated candidate snapshots.
use core::num::NonZeroU64;
/// Storage-owned monotonically increasing committed snapshot generation.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct Generation(NonZeroU64);
impl Generation {
/// Creates a generation supplied only by a persistence backend.
pub const fn new(value: u64) -> Self {
match NonZeroU64::new(value) {
Some(value) => Self(value),
None => panic!("configuration generation must be nonzero"),
}
}
/// Parses an on-disk or wire generation, rejecting the factory sentinel.
pub const fn try_new(value: u64) -> Option<Self> {
match NonZeroU64::new(value) {
Some(value) => Some(Self(value)),
None => None,
}
}
/// Returns the committed generation value.
pub const fn get(self) -> u64 {
self.0.get()
}
}
/// Durable storage of complete configuration snapshots.
///
/// Flight implementations use a two-slot journal: a record contains a header,
/// bounded CBOR payload, integrity check, and a commit marker written last.
/// A backend must retain its previous valid record until the replacement is
/// fully verified. Host file stores may implement the same interface without
/// claiming flash power-loss semantics.
pub trait ProfileStore<C> {
/// Backend-specific I/O or integrity failure.
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<(Generation, C)>, Self::Error>;
/// Persists one complete already-validated candidate atomically.
fn persist(&mut self, candidate: &C) -> Result<Generation, Self::Error>;
}
@@ -0,0 +1,63 @@
//! Local scalar configuration sections implemented by the derive macro.
use crate::{ConfigValue, ConfigViolation, FieldId, FieldWriteError};
/// Validates local scalar invariants of one record.
///
/// The derive macro handles finite, nonzero, and inclusive range checks.
/// Cross-field, capability, calibration, and lifecycle invariants belong in a
/// handwritten post-validation hook on the owning vehicle schema.
pub trait ValidateFields {
/// Rejects the first violated scalar or declared post-validation rule.
fn validate_fields(&self) -> Result<(), ConfigViolation>;
}
/// A complete scalar configuration section generated from `#[config(...)]`
/// field attributes.
///
/// Its metadata, default construction, reads, local writes, and scalar
/// validation are all produced from the same field declarations. A section
/// setter never commits a locally invalid value. Cross-section and capability
/// invariants deliberately remain the aggregate candidate's responsibility.
pub trait ConfigSection: ValidateFields {
/// Number of scalar descriptors generated for this section.
const FIELD_COUNT: usize;
/// Builds the section from derive-generated defaults after validating it.
fn default_section() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Returns one scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Applies one scalar only when this section owns the ID and its local
/// generated validation accepts the new value.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
/// Validates only generated scalar invariants. This intentionally omits
/// a derive `post` hook so staged multi-field edits can remain temporarily
/// cross-field-invalid until aggregate validation/commit.
fn validate_local_fields(&self) -> Result<(), ConfigViolation>;
}
/// An ordered aggregate of generated configuration sections.
///
/// The derive owns routing/default construction so adding a field to an
/// existing section cannot silently bypass local validation. The aggregate
/// owner still defines capability checks and immutable active construction.
pub trait ConfigAggregate: ValidateFields {
/// Builds and fully validates all nested sections from their derived
/// defaults.
fn default_aggregate() -> Result<Self, ConfigViolation>
where
Self: Sized;
/// Returns one nested scalar by stable field identity.
fn field_value(&self, id: FieldId) -> Option<ConfigValue>;
/// Routes one scalar write to its owning generated section. The setter
/// applies local validation only, so a transaction may temporarily hold a
/// cross-field-invalid multi-edit candidate.
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>;
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "kaptan-control"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-log = { path = "../kaptan-log" }
kaptan-math = { path = "../kaptan-math" }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,17 @@
[package]
name = "kaptan-data-channel"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-board = { path = "../kaptan-board" }
kaptan-log = { path = "../kaptan-log" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-mavlink = { path = "../kaptan-mavlink" }
kaptan-sensor = { path = "../kaptan-sensor" }
kaptan-time = { path = "../kaptan-time" }
@@ -0,0 +1,497 @@
//! Complete outbound MAVLink data flow.
//!
//! This crate owns fixed-rate scheduling, mailbox consumption, vertical-data
//! freshness, MAVLink encoding, and bounded queue classification. A platform
//! supplies only mailbox references, a declared MAVLink state, arming state,
//! and a transport sink.
#![no_std]
use kaptan_board::{BoardConfig, DataChannelRates};
use kaptan_dma::TxCompletion;
use kaptan_log::KaptanError;
use kaptan_mavlink::message::MessageData;
use kaptan_mavlink::{
MavlinkFrame,
encoder::{DistanceSensorConfig, MavlinkEncoder},
message::{MavCmd, MavResult, MavState},
transmitter::FrameClass,
transmitter::TransmitFaultKind,
};
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
mod mailbox;
pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
/// Platform transport used by a data channel.
///
/// The channel decides exactly which encoded frame is admitted and with which
/// delivery class. The platform retains only DMA endpoint ownership.
pub trait FrameSink {
/// Admits one fully encoded frame to the platform's bounded transport.
fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission;
/// Releases or faults the active DMA frame after a terminal interrupt.
fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion;
/// Returns unconfigurable sole-link loss counters.
fn health(&self) -> TxHealth;
/// Removes one exact terminal transport-fault category for the central
/// observability owner. It is never encoded from an ISR or transport
/// driver directly.
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind>;
}
/// Outcome returned to the one platform telemetry owner for each attempted
/// frame admission. It never triggers logging recursively.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FrameAdmission {
/// The queue retained this encoded frame, possibly after replacing a
/// pending lower-priority frame.
Retained(kaptan_mavlink::transmitter::EnqueueResult),
}
impl FrameAdmission {
/// Whether the frame is represented by the lower transport and the log
/// hub may acknowledge its source record.
pub const fn is_retained(self) -> bool {
matches!(
self,
Self::Retained(
kaptan_mavlink::transmitter::EnqueueResult::Enqueued
| kaptan_mavlink::transmitter::EnqueueResult::ReplacedBestEffort
| kaptan_mavlink::transmitter::EnqueueResult::ReplacedCritical
)
)
}
/// Whether no pending queue slot accepted the source frame.
pub const fn is_queue_full(self) -> bool {
matches!(
self,
Self::Retained(
kaptan_mavlink::transmitter::EnqueueResult::VitalQueueFull
| kaptan_mavlink::transmitter::EnqueueResult::CriticalQueueFull
| kaptan_mavlink::transmitter::EnqueueResult::BestEffortDropped
)
)
}
}
/// Terminal outcome from the sole DMA owner.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DmaCompletion {
/// No new transport fault was observed.
Complete,
/// A DMA terminal or restart fault was counted by the platform.
Fault,
}
/// Unconfigurable loss counters from sole-link admission/DMA ownership.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TxHealth {
/// Dropped or displaced pending frames, including vital admission loss.
pub admission_loss_count: u32,
/// DMA preparation/start/terminal transfer faults.
pub dma_fault_count: u32,
/// DMA-visible memory preparation faults.
pub memory_fault_count: u32,
/// DMA-engine transfer-start faults.
pub engine_fault_count: u32,
/// Active-DMA terminal faults or halts.
pub terminal_fault_count: u32,
}
/// Mailboxes read by one data-channel worker.
#[derive(Clone, Copy)]
pub struct DataSources<'a> {
/// Latest IMU observation from the acquisition worker.
pub imu: &'a LatestImuMailbox,
/// Latest range observation from either supported vertical route.
pub range: &'a LatestRangeMailbox,
/// Latest optical-flow observation from MTF-01.
pub optical_flow: &'a LatestOpticalFlowMailbox,
}
/// Vertical-sensor metadata and freshness policy for an outbound data channel.
#[derive(Clone, Copy)]
pub struct VerticalDataConfig {
/// Metadata required to encode `DISTANCE_SENSOR`; `None` disables it.
pub distance: Option<DistanceSensorConfig>,
/// MTF-01 sensor ID for `OPTICAL_FLOW`; `None` disables it.
pub optical_flow_sensor_id: Option<u8>,
/// Maximum age, in service ticks, for cached vertical observations.
pub maximum_age_ticks: u8,
}
/// Data-channel construction failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DataChannelError {
/// Rejected profiles must not construct a physical telemetry channel.
NotTelemetryProfile,
/// A configured channel must be due at least once per second.
ZeroRate,
/// A configured channel exceeds the service frequency.
RateExceedsServiceFrequency,
/// Two configured channels cannot share the one-frame-per-tick budget.
ScheduleFull,
}
/// Complete outbound data flow for one MAVLink endpoint.
pub struct DataChannel<'a, Sink, const SERVICE_HZ: usize> {
sink: Sink,
encoder: MavlinkEncoder,
schedule: Schedule<SERVICE_HZ>,
sources: DataSources<'a>,
vertical: VerticalDataConfig,
observed_imu: u32,
observed_range: u32,
last_range: Option<RangeSnapshot>,
range_age_ticks: u8,
observed_optical_flow: u32,
last_optical_flow: Option<OpticalFlowSnapshot>,
optical_flow_age_ticks: u8,
}
impl<'a, Sink, const SERVICE_HZ: usize> DataChannel<'a, Sink, SERVICE_HZ>
where
Sink: FrameSink,
{
/// Creates an outbound data flow from validated portable board metadata.
pub fn new(
sink: Sink,
config: BoardConfig,
vertical: VerticalDataConfig,
sources: DataSources<'a>,
) -> Result<Self, DataChannelError> {
let telemetry = config
.telemetry()
.ok_or(DataChannelError::NotTelemetryProfile)?;
let schedule = Schedule::from_rates(telemetry.data_channel)?;
Ok(Self {
sink,
encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id),
schedule,
sources,
vertical,
observed_imu: 0,
observed_range: 0,
last_range: None,
range_age_ticks: u8::MAX,
observed_optical_flow: 0,
last_optical_flow: None,
optical_flow_age_ticks: u8::MAX,
})
}
/// Runs all outbound work due for one platform service tick.
///
/// No schedule token escapes this method. It refreshes cached vertical
/// observations, selects due data, encodes the MAVLink message, and admits
/// it with the correct bounded-queue class.
pub fn on_service_tick(&mut self, system_status: MavState, motors_armed: bool) {
self.refresh_vertical_data();
match self.schedule.next_due() {
Some(DueData::RawImu) => {
if let Some(sample) = self.sources.imu.take_newer(&mut self.observed_imu) {
let _ = self.sink.send(
FrameClass::BestEffort,
self.encoder.raw_imu_snapshot(&sample),
);
}
}
Some(DueData::Distance) if self.range_age_ticks <= self.vertical.maximum_age_ticks => {
if let (Some(range), Some(config)) = (self.last_range, self.vertical.distance) {
let _ = self.sink.send(
FrameClass::BestEffort,
self.encoder.distance_sensor_snapshot(&range, config),
);
}
}
Some(DueData::OpticalFlow)
if self.optical_flow_age_ticks <= self.vertical.maximum_age_ticks =>
{
if let (Some(flow), Some(sensor_id)) =
(self.last_optical_flow, self.vertical.optical_flow_sensor_id)
{
let _ = self.sink.send(
FrameClass::BestEffort,
self.encoder.optical_flow_snapshot(&flow, sensor_id),
);
}
}
Some(DueData::Heartbeat) => {
let _ = self.sink.send(
FrameClass::Vital,
self.encoder.heartbeat(system_status, motors_armed),
);
}
Some(DueData::Distance | DueData::OpticalFlow) | None => {}
}
}
/// Completes the active DMA lifecycle for this channel's transport.
pub fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion {
self.sink.on_dma_complete(completion)
}
/// Emits a critical acknowledgement for a received MAVLink command.
pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) {
let _ = self.sink.send(
FrameClass::Critical,
self.encoder.command_ack(command, result),
);
}
/// Encodes an adapter-owned generated MAVLink data record through the
/// channel's sole encoder and bounded transport sink.
pub fn send_custom<M: MessageData>(
&mut self,
class: FrameClass,
message: &M,
) -> FrameAdmission {
self.sink.send(class, self.encoder.custom(message))
}
/// Returns local sole-link accounting for platform diagnostics. It is not
/// serialized through Kaptan's bounded diagnostic messages.
pub fn transport_health(&self) -> TxHealth {
self.sink.health()
}
/// Removes one exact sole-link transport fault category after the platform
/// has counted it. The platform maps it to a typed log record on its
/// bounded telemetry worker.
pub fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
self.sink.take_transport_fault()
}
fn refresh_vertical_data(&mut self) {
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
self.last_range = Some(range);
self.range_age_ticks = 0;
} else {
self.range_age_ticks = self.range_age_ticks.saturating_add(1);
}
if let Some(flow) = self
.sources
.optical_flow
.take_newer(&mut self.observed_optical_flow)
{
self.last_optical_flow = Some(flow);
self.optical_flow_age_ticks = 0;
} else {
self.optical_flow_age_ticks = self.optical_flow_age_ticks.saturating_add(1);
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DueData {
Heartbeat,
RawImu,
Distance,
OpticalFlow,
}
struct Schedule<const SERVICE_HZ: usize> {
phase: usize,
due: [Option<DueData>; SERVICE_HZ],
}
impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
fn from_rates(rates: DataChannelRates) -> Result<Self, DataChannelError> {
assert!(
SERVICE_HZ != 0,
"data-channel service frequency must be nonzero"
);
if rates.service_hz != SERVICE_HZ {
return Err(DataChannelError::RateExceedsServiceFrequency);
}
let schedule = Self {
phase: 0,
due: [None; SERVICE_HZ],
}
.with(DueData::RawImu, rates.raw_imu_hz)?
.with(DueData::Heartbeat, rates.heartbeat_hz)?;
let schedule = match rates.range_hz {
Some(hertz) => schedule.with(DueData::Distance, hertz)?,
None => schedule,
};
match rates.optical_flow_hz {
Some(hertz) => schedule.with(DueData::OpticalFlow, hertz),
None => Ok(schedule),
}
}
fn next_due(&mut self) -> Option<DueData> {
let due = self.due[self.phase];
self.phase = if self.phase + 1 == SERVICE_HZ {
0
} else {
self.phase + 1
};
due
}
fn with(mut self, data: DueData, hertz: usize) -> Result<Self, DataChannelError> {
if hertz == 0 {
return Err(DataChannelError::ZeroRate);
}
if hertz > SERVICE_HZ {
return Err(DataChannelError::RateExceedsServiceFrequency);
}
let mut count = 0;
while count < hertz {
let mut index = (count * SERVICE_HZ) / hertz;
let mut probes = 0;
while self.due[index].is_some() {
index = if index + 1 == SERVICE_HZ {
0
} else {
index + 1
};
probes += 1;
if probes == SERVICE_HZ {
return Err(DataChannelError::ScheduleFull);
}
}
self.due[index] = Some(data);
count += 1;
}
Ok(self)
}
}
#[cfg(test)]
mod tests {
use super::{
DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox,
LatestOpticalFlowMailbox, LatestRangeMailbox, TransmitFaultKind, TxHealth,
VerticalDataConfig,
};
use kaptan_board::{
BoardConfig, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
VerticalSensingKind,
};
use kaptan_dma::TxCompletion;
use kaptan_log::Severity;
use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass};
use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
};
use kaptan_time::Timestamp;
struct Sink {
best_effort: u8,
critical: u8,
completions: u8,
}
impl FrameSink for Sink {
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission {
match class {
FrameClass::Vital => self.critical += 1,
FrameClass::BestEffort => self.best_effort += 1,
FrameClass::Critical => self.critical += 1,
}
FrameAdmission::Retained(kaptan_mavlink::transmitter::EnqueueResult::Enqueued)
}
fn on_dma_complete(&mut self, _: TxCompletion) -> DmaCompletion {
self.completions += 1;
DmaCompletion::Complete
}
fn health(&self) -> TxHealth {
TxHealth::default()
}
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
None
}
}
const CONFIG: BoardConfig = BoardConfig {
name: "test",
mavlink: MavlinkEndpoint {
system_id: 1,
component_id: 1,
port: MavlinkPort::Serial1,
baud: 115_200,
},
profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::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 owns_rate_selection_encoding_admission_and_completion() {
let imu = LatestImuMailbox::new();
let range = LatestRangeMailbox::new();
let flow = LatestOpticalFlowMailbox::new();
let mut channel: DataChannel<'_, Sink, 100> = DataChannel::new(
Sink {
best_effort: 0,
critical: 0,
completions: 0,
},
CONFIG,
VerticalDataConfig {
distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig {
sensor_id: 0,
orientation:
kaptan_mavlink::message::MavSensorOrientation::MAV_SENSOR_ROTATION_NONE,
minimum_distance_centimetres: 1,
maximum_distance_centimetres: 1_000,
}),
optical_flow_sensor_id: Some(0),
maximum_age_ticks: 10,
},
DataSources {
imu: &imu,
range: &range,
optical_flow: &flow,
},
)
.unwrap();
for tick in 0..100 {
imu.publish(ImuSnapshot::new(
Timestamp::from_microseconds(tick),
[0; 3],
[0; 3],
0,
));
range.publish(RangeSnapshot::new(Timestamp::from_microseconds(tick), 100));
flow.publish(OpticalFlowSnapshot::new(
Timestamp::from_microseconds(tick),
[0; 2],
100,
100,
));
channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
}
channel.on_dma_complete(TxCompletion::Complete);
let sink = channel.sink;
assert_eq!(sink.best_effort, 60);
assert_eq!(sink.critical, 2);
assert_eq!(sink.completions, 1);
}
}
@@ -0,0 +1,227 @@
//! Lock-free single-producer, single-consumer latest-value handoff.
use core::{
marker::PhantomData,
sync::atomic::{AtomicU32, Ordering},
};
use kaptan_sensor::{imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use kaptan_time::Timestamp;
/// Converts an observation to the atomic word representation used by a
/// [`LatestMailbox`].
///
/// This is a data-channel handoff contract, not a sensor-domain contract:
/// sensor crates own their fields and units, while this crate owns their
/// lock-free representation between an acquisition producer and consumer.
pub trait MailboxValue<const WORDS: usize>: Copy {
/// Converts this observation into its handoff representation.
fn into_words(self) -> [u32; WORDS];
/// Reconstructs an observation from its handoff representation.
fn from_words(words: [u32; WORDS]) -> Self;
}
/// Lock-free latest-value SPSC mailbox.
///
/// Publishing a newer value intentionally replaces an unread older value: the
/// output path is best-effort and must never delay acquisition. The sequence
/// counter is a seqlock; odd values indicate a writer and even values identify
/// a stable snapshot. Exactly one producer and one consumer may use an
/// instance concurrently. Sequence operations are sequentially consistent so
/// a reader cannot validate an old even sequence around concurrently written
/// atomic payload words; the payload words themselves need only be relaxed.
pub struct LatestMailbox<Value, const WORDS: usize> {
sequence: AtomicU32,
words: [AtomicU32; WORDS],
value: PhantomData<Value>,
}
impl<Value: MailboxValue<WORDS>, const WORDS: usize> LatestMailbox<Value, WORDS> {
/// Creates an empty mailbox.
pub const fn new() -> Self {
Self {
sequence: AtomicU32::new(0),
words: [const { AtomicU32::new(0) }; WORDS],
value: PhantomData,
}
}
/// Publishes a snapshot, replacing any unread older snapshot.
pub fn publish(&self, value: Value) {
let sequence = self.sequence.fetch_add(1, Ordering::SeqCst);
debug_assert_eq!(sequence & 1, 0, "SPSC producer must not reenter");
for (destination, word) in self.words.iter().zip(value.into_words()) {
destination.store(word, Ordering::Relaxed);
}
self.sequence
.store(sequence.wrapping_add(2), Ordering::SeqCst);
}
/// Returns the newest stable snapshot different from `observed_sequence`.
pub fn take_newer(&self, observed_sequence: &mut u32) -> Option<Value> {
let before = self.sequence.load(Ordering::SeqCst);
if before == *observed_sequence || before & 1 != 0 {
return None;
}
let mut words = [0; WORDS];
for (destination, source) in words.iter_mut().zip(&self.words) {
*destination = source.load(Ordering::Relaxed);
}
if self.sequence.load(Ordering::SeqCst) != before {
return None;
}
*observed_sequence = before;
Some(Value::from_words(words))
}
}
impl<Value: MailboxValue<WORDS>, const WORDS: usize> Default for LatestMailbox<Value, WORDS> {
fn default() -> Self {
Self::new()
}
}
/// Latest-value mailbox for IMU data-channel snapshots.
pub type LatestImuMailbox = LatestMailbox<ImuSnapshot, 9>;
/// Latest-value mailbox for range data-channel snapshots.
pub type LatestRangeMailbox = LatestMailbox<RangeSnapshot, 3>;
/// Latest-value mailbox for optical-flow data-channel snapshots.
pub type LatestOpticalFlowMailbox = LatestMailbox<OpticalFlowSnapshot, 6>;
impl MailboxValue<9> for ImuSnapshot {
fn into_words(self) -> [u32; 9] {
let timestamp = self.timestamp.as_microseconds();
[
timestamp as u32,
(timestamp >> 32) as u32,
self.acceleration_milligravity[0] as u16 as u32,
self.acceleration_milligravity[1] as u16 as u32,
self.acceleration_milligravity[2] as u16 as u32,
self.angular_rate_milliradians_per_second[0] as u16 as u32,
self.angular_rate_milliradians_per_second[1] as u16 as u32,
self.angular_rate_milliradians_per_second[2] as u16 as u32,
self.temperature_centidegrees_celsius as u16 as u32,
]
}
fn from_words(words: [u32; 9]) -> Self {
Self::new(
timestamp_from_words(words[0], words[1]),
[
words[2] as u16 as i16,
words[3] as u16 as i16,
words[4] as u16 as i16,
],
[
words[5] as u16 as i16,
words[6] as u16 as i16,
words[7] as u16 as i16,
],
words[8] as u16 as i16,
)
}
}
impl MailboxValue<3> for RangeSnapshot {
fn into_words(self) -> [u32; 3] {
let timestamp = self.timestamp.as_microseconds();
[
timestamp as u32,
(timestamp >> 32) as u32,
self.distance_millimetres,
]
}
fn from_words(words: [u32; 3]) -> Self {
Self::new(timestamp_from_words(words[0], words[1]), words[2])
}
}
impl MailboxValue<6> for OpticalFlowSnapshot {
fn into_words(self) -> [u32; 6] {
let timestamp = self.timestamp.as_microseconds();
[
timestamp as u32,
(timestamp >> 32) as u32,
self.velocity_millimetres_per_second[0] as u32,
self.velocity_millimetres_per_second[1] as u32,
self.ground_distance_millimetres,
u32::from(self.quality),
]
}
fn from_words(words: [u32; 6]) -> Self {
Self::new(
timestamp_from_words(words[0], words[1]),
[words[2] as i32, words[3] as i32],
words[4],
words[5] as u8,
)
}
}
const fn timestamp_from_words(low: u32, high: u32) -> Timestamp {
Timestamp::from_microseconds((low as u64) | ((high as u64) << 32))
}
#[cfg(test)]
mod tests {
use super::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
};
use kaptan_time::Timestamp;
#[test]
fn publishes_only_the_newest_unread_imu_sample() {
let mailbox = LatestImuMailbox::new();
let mut observed = 0;
mailbox.publish(ImuSnapshot::new(
Timestamp::from_microseconds(1),
[1; 3],
[2; 3],
3,
));
mailbox.publish(ImuSnapshot::new(
Timestamp::from_microseconds(2),
[4; 3],
[5; 3],
6,
));
let sample = mailbox.take_newer(&mut observed).unwrap();
assert_eq!(sample.timestamp, Timestamp::from_microseconds(2));
assert_eq!(sample.acceleration_milligravity, [4; 3]);
assert!(mailbox.take_newer(&mut observed).is_none());
}
#[test]
fn publishes_only_the_newest_unread_range_sample() {
let mailbox = LatestRangeMailbox::new();
let mut observed = 0;
mailbox.publish(RangeSnapshot::new(Timestamp::from_microseconds(1), 100));
mailbox.publish(RangeSnapshot::new(Timestamp::from_microseconds(2), 200));
assert_eq!(
mailbox.take_newer(&mut observed),
Some(RangeSnapshot::new(Timestamp::from_microseconds(2), 200))
);
assert!(mailbox.take_newer(&mut observed).is_none());
}
#[test]
fn preserves_optical_flow_fields() {
let mailbox = LatestOpticalFlowMailbox::new();
let mut observed = 0;
let sample = OpticalFlowSnapshot::new(Timestamp::from_microseconds(1), [-6, 7], 8, 9);
mailbox.publish(sample);
assert_eq!(mailbox.take_newer(&mut observed), Some(sample));
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "kaptan-dma"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
+73
View File
@@ -0,0 +1,73 @@
//! DMA memory ownership and non-blocking transfer contracts.
//!
//! Board/HAL code proves the memory region, cache policy, and peripheral route.
//! Runtime code sees only prepared immutable TX ranges and terminal outcomes.
#![no_std]
/// A borrowed byte range prepared for a device-to-read DMA transfer.
///
/// This is a capability rather than a general byte-slice wrapper. It exists
/// only after a platform-selected policy proves DMA reachability and CPU-to-device
/// visibility for the selected controller.
pub struct DmaReadableBuffer<'a> {
bytes: &'a [u8],
}
impl<'a> DmaReadableBuffer<'a> {
/// Creates a DMA-read capability after platform-specific validation.
///
/// # Safety
///
/// - `bytes` is reachable by the selected DMA engine.
/// - CPU writes to `bytes` are visible before transfer begins.
/// - `bytes` remains addressable, immovable, and immutable until a terminal
/// completion or a documented route-halt outcome releases ownership.
pub const unsafe fn new_unchecked(bytes: &'a [u8]) -> Self {
Self { bytes }
}
/// Returns the DMA-visible byte range.
pub const fn bytes(&self) -> &'a [u8] {
self.bytes
}
}
/// Prepares CPU-owned bytes for a DMA transmit handoff.
///
/// A cache-disabled board validates the RAM range. A cached board must clean
/// the exact range and issue the required ordering barrier before returning.
pub trait DmaTxBufferFactory {
/// Platform-specific preparation failure.
type Error;
/// Validates and prepares a frame for device DMA reads.
fn prepare<'a>(&mut self, bytes: &'a [u8]) -> Result<DmaReadableBuffer<'a>, Self::Error>;
}
/// Terminal result reported by a DMA transmit interrupt handler.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TxCompletion {
/// The selected engine has no terminal event pending.
NotPending,
/// DMA reached the peripheral data-register endpoint.
///
/// This does not prove that the final UART stop bit left the wire.
Complete,
/// The engine reported a terminal transfer fault.
TransferFault,
/// The route entered a halted state and requires explicit reinitialization.
Halted,
}
/// Owns one non-blocking DMA-backed transmit route.
///
/// The caller retains the prepared range until a terminal completion or fault.
/// Implementations must not poll for a hardware state transition from this API.
pub trait DmaTxEngine {
/// Start-operation failure.
type StartError;
/// Starts one DMA transfer from a prepared readable buffer.
fn start(&mut self, buffer: DmaReadableBuffer<'_>) -> Result<(), Self::StartError>;
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "kaptan-drivers"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-log = { path = "../kaptan-log" }
kaptan-sensor = { path = "../kaptan-sensor" }
kaptan-i2c = { path = "../kaptan-i2c" }
kaptan-spi = { path = "../kaptan-spi" }
kaptan-time = { path = "../kaptan-time" }
@@ -0,0 +1,17 @@
//! ICM-42688-P operation errors.
use kaptan_log::KaptanError;
/// ICM-42688-P operation failure.
#[derive(Debug, Eq, PartialEq, KaptanError)]
pub enum Error<BusError> {
/// The selected SPI route rejected a transfer submission.
Start(BusError),
/// The responding device does not identify as an ICM-42688-P.
UnexpectedIdentity(u8),
/// An acquisition request arrived before initialization completed or while
/// another transfer still owned the selected route.
NotReady,
/// The selected SPI route reported a terminal transfer fault.
TransferFault,
}
@@ -0,0 +1,310 @@
//! ICM-42688-P register protocol and asynchronous sample driver.
mod error;
mod sample;
pub mod service;
use kaptan_sensor::imu::ImuSnapshot;
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::Timestamp;
pub use error::Error;
pub use service::{Icm42688Service, Icm42688ServiceEvent};
const WHO_AM_I: u8 = 0x75;
const EXPECTED_WHO_AM_I: u8 = 0x47;
const DEVICE_CONFIG: u8 = 0x11;
const SOFT_RESET: u8 = 0x01;
const PWR_MGMT0: u8 = 0x4e;
const GYRO_CONFIG0: u8 = 0x4f;
const ACCEL_CONFIG0: u8 = 0x50;
const GYRO_ACCEL_CONFIG0: u8 = 0x52;
const INT_CONFIG: u8 = 0x14;
const INT_CONFIG0: u8 = 0x63;
const INT_CONFIG1: u8 = 0x64;
const INT_SOURCE0: u8 = 0x65;
const INTF_CONFIG1: u8 = 0x4d;
const TEMP_DATA1: u8 = 0x1d;
const SAMPLE_BYTES: usize = 14;
const TRANSFER_CAPACITY: usize = SAMPLE_BYTES + 1;
// SPI mode 0, active-high push-pull, pulsed INT1. Data-ready is cleared by
// the SPI burst read and its source is explicitly enabled on INT1.
const CONFIGURATION: [(u8, u8); 9] = [
(INT_CONFIG, 0x03),
(INT_CONFIG0, 0x00),
(INT_CONFIG1, 0x60),
(INTF_CONFIG1, 0x40),
(GYRO_ACCEL_CONFIG0, 0xff),
(INT_SOURCE0, 0x08),
(PWR_MGMT0, 0x0f),
// ±500°/s and ±4 g at 1 kHz. The two range fields use the ICM-42688-P
// encoding in bits 7:5 and the output data rate is in bits 3:0.
(GYRO_CONFIG0, 0x46),
(ACCEL_CONFIG0, 0x46),
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum State {
Dormant,
Resetting,
WaitingForReset,
ReadingIdentity,
WritingConfiguration(usize),
WaitingForSensorStart,
Ready,
ReadingSample { timestamp: Timestamp },
}
/// Observable outcome of one ICM-42688-P SPI terminal event.
#[derive(Debug, Eq, PartialEq)]
pub enum Icm42688Event<BusError> {
/// The selected SPI interrupt had no terminal transfer event.
NotPending,
/// One initialization transfer completed; initialization continues.
Initializing,
/// The ICM-42688-P identity and configuration sequence completed.
Initialized,
/// One coherent IMU sample completed.
Sample(ImuSnapshot),
/// Initialization or acquisition failed.
Fault(Error<BusError>),
}
/// Configured ICM-42688-P driver.
///
/// The driver owns fixed transfer buffers and progresses exclusively from
/// timer, data-ready and SPI-DMA completion events. It never waits for a
/// peripheral status flag.
pub struct Icm42688<Bus> {
bus: Bus,
state: State,
transmit: [u8; TRANSFER_CAPACITY],
receive: [u8; TRANSFER_CAPACITY],
}
impl<Bus> Icm42688<Bus> {
/// Creates an inactive ICM-42688-P state machine.
pub const fn new(bus: Bus) -> Self {
Self {
bus,
state: State::Dormant,
transmit: [0; TRANSFER_CAPACITY],
receive: [0; TRANSFER_CAPACITY],
}
}
}
impl<Bus: SpiDevice> Icm42688<Bus> {
/// Starts the sensor reset. A subsequent periodic tick provides the
/// datasheet-required reset-settling interval before identification.
pub fn begin_initialization(&mut self) -> Result<(), Error<Bus::StartError>> {
if self.state != State::Dormant {
return Err(Error::NotReady);
}
self.prepare_write(DEVICE_CONFIG, SOFT_RESET);
self.submit(2)?;
self.state = State::Resetting;
Ok(())
}
/// Advances reset/wake delays from the board periodic event.
pub fn advance_initialization(&mut self) -> Result<(), Error<Bus::StartError>> {
match self.state {
State::WaitingForReset => {
self.prepare_read(WHO_AM_I, 2);
self.submit(2)?;
self.state = State::ReadingIdentity;
}
State::WaitingForSensorStart => {
self.start_configuration(7)?;
}
_ => {}
}
Ok(())
}
/// Starts one temperature, accelerometer and gyroscope burst read.
pub fn request_sample(&mut self, timestamp: Timestamp) -> Result<(), Error<Bus::StartError>> {
if self.state != State::Ready {
return Err(Error::NotReady);
}
self.prepare_read(TEMP_DATA1, TRANSFER_CAPACITY);
self.submit(TRANSFER_CAPACITY)?;
self.state = State::ReadingSample { timestamp };
Ok(())
}
/// Advances the state machine from an already-acknowledged SPI completion.
pub fn on_transfer_complete(
&mut self,
completion: SpiCompletion,
) -> Icm42688Event<Bus::StartError> {
self.bus.complete_transfer(completion);
match completion {
SpiCompletion::NotPending => Icm42688Event::NotPending,
SpiCompletion::TransferFault => {
self.state = State::Dormant;
Icm42688Event::Fault(Error::TransferFault)
}
SpiCompletion::Complete => self.advance_after_completion(),
}
}
/// Returns whether an IMU data-ready event can start a sensor transfer.
pub fn is_ready(&self) -> bool {
self.state == State::Ready
}
fn advance_after_completion(&mut self) -> Icm42688Event<Bus::StartError> {
match self.state {
State::Dormant => Icm42688Event::NotPending,
State::Resetting => {
self.state = State::WaitingForReset;
Icm42688Event::Initializing
}
State::WaitingForReset | State::WaitingForSensorStart => Icm42688Event::NotPending,
State::ReadingIdentity => {
let identity = self.receive[1];
if identity != EXPECTED_WHO_AM_I {
self.state = State::Dormant;
return Icm42688Event::Fault(Error::UnexpectedIdentity(identity));
}
self.start_configuration_event(0)
}
State::WritingConfiguration(index) => {
let next = index + 1;
if next == 7 {
// PWR_MGMT0 starts both sensor paths. The following
// timer tick gives them more than the required 200 µs
// before their output-rate configuration is written.
self.state = State::WaitingForSensorStart;
Icm42688Event::Initializing
} else if next == CONFIGURATION.len() {
self.state = State::Ready;
Icm42688Event::Initialized
} else {
self.start_configuration_event(next)
}
}
State::Ready => Icm42688Event::NotPending,
State::ReadingSample { timestamp } => {
let mut bytes = [0; SAMPLE_BYTES];
bytes.copy_from_slice(&self.receive[1..]);
self.state = State::Ready;
Icm42688Event::Sample(sample::decode(&bytes, timestamp))
}
}
}
fn start_configuration_event(&mut self, index: usize) -> Icm42688Event<Bus::StartError> {
match self.start_configuration(index) {
Ok(()) => Icm42688Event::Initializing,
Err(error) => Icm42688Event::Fault(error),
}
}
fn start_configuration(&mut self, index: usize) -> Result<(), Error<Bus::StartError>> {
let (register, value) = CONFIGURATION[index];
self.prepare_write(register, value);
if let Err(error) = self.submit(2) {
self.state = State::Dormant;
return Err(error);
}
self.state = State::WritingConfiguration(index);
Ok(())
}
fn prepare_read(&mut self, register: u8, length: usize) {
self.transmit = [0; TRANSFER_CAPACITY];
self.receive = [0; TRANSFER_CAPACITY];
self.transmit[0] = register | 0x80;
debug_assert!(length <= TRANSFER_CAPACITY);
}
fn prepare_write(&mut self, register: u8, value: u8) {
self.transmit = [0; TRANSFER_CAPACITY];
self.receive = [0; TRANSFER_CAPACITY];
self.transmit[..2].copy_from_slice(&[register, value]);
}
fn submit(&mut self, length: usize) -> Result<(), Error<Bus::StartError>> {
let (bus, transmit, receive) = (&mut self.bus, &self.transmit, &mut self.receive);
bus.start_transfer(&transmit[..length], &mut receive[..length])
.map_err(Error::Start)
}
}
#[cfg(test)]
mod tests {
use super::{Error, Icm42688, Icm42688Event, TRANSFER_CAPACITY};
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::Timestamp;
struct Bus {
response: [u8; TRANSFER_CAPACITY],
}
impl SpiDevice for Bus {
type StartError = ();
fn start_transfer(&mut self, _: &[u8], receive: &mut [u8]) -> Result<(), Self::StartError> {
receive.copy_from_slice(&self.response[..receive.len()]);
Ok(())
}
fn complete_transfer(&mut self, _: SpiCompletion) {}
}
#[test]
fn accepts_a_sample_only_after_identity_and_wake_delays() {
let mut response = [0; TRANSFER_CAPACITY];
response[1] = 0x47;
let mut imu = Icm42688::new(Bus { response });
imu.begin_initialization().unwrap();
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Initializing
));
assert!(matches!(imu.advance_initialization(), Ok(())));
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Initializing
));
for _ in 0..7 {
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Initializing
));
}
assert!(matches!(imu.advance_initialization(), Ok(())));
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Initializing
));
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Initialized
));
assert!(imu.is_ready());
imu.request_sample(Timestamp::from_microseconds(1)).unwrap();
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Sample(_)
));
}
#[test]
fn rejects_a_wrong_identity() {
let mut imu = Icm42688::new(Bus {
response: [0; TRANSFER_CAPACITY],
});
imu.begin_initialization().unwrap();
let _ = imu.on_transfer_complete(SpiCompletion::Complete);
imu.advance_initialization().unwrap();
assert!(matches!(
imu.on_transfer_complete(SpiCompletion::Complete),
Icm42688Event::Fault(Error::UnexpectedIdentity(0))
));
}
}
@@ -0,0 +1,57 @@
//! ICM-42688-P burst-sample decoding.
use kaptan_sensor::imu::ImuSnapshot;
use kaptan_time::Timestamp;
use super::SAMPLE_BYTES;
pub(crate) fn decode(bytes: &[u8; SAMPLE_BYTES], timestamp: Timestamp) -> ImuSnapshot {
ImuSnapshot::new(
timestamp,
[
scale_acceleration(read_i16(bytes, 2)),
scale_acceleration(read_i16(bytes, 4)),
scale_acceleration(read_i16(bytes, 6)),
],
[
scale_angular_rate(read_i16(bytes, 8)),
scale_angular_rate(read_i16(bytes, 10)),
scale_angular_rate(read_i16(bytes, 12)),
],
scale_temperature(read_i16(bytes, 0)),
)
}
const fn read_i16(bytes: &[u8; SAMPLE_BYTES], offset: usize) -> i16 {
i16::from_be_bytes([bytes[offset], bytes[offset + 1]])
}
// The configured ±4 g range has 8,192 counts per g.
const fn scale_acceleration(raw: i16) -> i16 {
((raw as i32 * 1_000) / 8_192) as i16
}
// The configured ±500°/s range has 65.5 counts/(°/s).
const fn scale_angular_rate(raw: i16) -> i16 {
((raw as i32 * 17_453) / 65_500) as i16
}
// ICM-42688-P temperature is 132.48 counts/°C with a 25°C offset.
const fn scale_temperature(raw: i16) -> i16 {
((raw as i32 * 100) / 13_248 + 2_500) as i16
}
#[cfg(test)]
mod tests {
use super::decode;
use kaptan_time::Timestamp;
#[test]
fn decodes_the_icm_temperature_first_layout() {
let bytes = [0, 0, 0x20, 0, 0, 0, 0, 0, 0x7f, 0xeb, 0, 0, 0, 0];
let sample = decode(&bytes, Timestamp::from_microseconds(7));
assert_eq!(sample.acceleration_milligravity[0], 1_000);
assert_eq!(sample.angular_rate_milliradians_per_second[0], 8_725);
assert_eq!(sample.temperature_centidegrees_celsius, 2_500);
}
}
@@ -0,0 +1,82 @@
//! Timestamped ICM-42688-P acquisition service.
use super::{Error, Icm42688, Icm42688Event};
use kaptan_sensor::imu::ImuSnapshot;
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::MonotonicClock;
/// One observable result from an ICM-42688-P acquisition service.
#[derive(Debug)]
pub enum Icm42688ServiceEvent<BusError> {
/// No accepted sample or terminal fault was produced.
Idle,
/// IMU configuration completed and the DRDY route may be enabled.
Initialized,
/// A coherent IMU observation is ready for publication or control.
Sample(ImuSnapshot),
/// The ICM-42688-P path entered a terminal fault state.
Fault(Error<BusError>),
}
/// Owns ICM-42688-P state, its clock, and sample acceptance.
pub struct Icm42688Service<Clock, Bus> {
clock: Clock,
imu: Icm42688<Bus>,
}
impl<Clock, Bus> Icm42688Service<Clock, Bus> {
/// Creates an inactive ICM-42688-P acquisition service.
pub const fn new(clock: Clock, imu: Icm42688<Bus>) -> Self {
Self { clock, imu }
}
}
impl<Clock, Bus> Icm42688Service<Clock, Bus>
where
Clock: MonotonicClock,
Bus: SpiDevice,
{
/// Submits the asynchronous reset transaction.
pub fn begin(&mut self) -> Result<(), Error<Bus::StartError>> {
self.imu.begin_initialization()
}
/// Advances reset and sensor-start settling states on a periodic tick.
pub fn on_periodic_tick(&mut self) -> Icm42688ServiceEvent<Bus::StartError> {
if self.imu.is_ready() {
return match self.imu.request_sample(self.clock.now()) {
Ok(()) | Err(Error::NotReady) => Icm42688ServiceEvent::Idle,
Err(error) => Icm42688ServiceEvent::Fault(error),
};
}
let _ = self.clock.now();
match self.imu.advance_initialization() {
Ok(()) => Icm42688ServiceEvent::Idle,
Err(error) => Icm42688ServiceEvent::Fault(error),
}
}
/// Handles one coalesced data-ready event.
pub fn on_data_ready(&mut self) -> Icm42688ServiceEvent<Bus::StartError> {
if !self.imu.is_ready() {
return Icm42688ServiceEvent::Idle;
}
match self.imu.request_sample(self.clock.now()) {
Ok(()) | Err(Error::NotReady) => Icm42688ServiceEvent::Idle,
Err(error) => Icm42688ServiceEvent::Fault(error),
}
}
/// Advances the transaction state from a DMA terminal result.
pub fn on_transfer_complete(
&mut self,
completion: SpiCompletion,
) -> Icm42688ServiceEvent<Bus::StartError> {
match self.imu.on_transfer_complete(completion) {
Icm42688Event::NotPending | Icm42688Event::Initializing => Icm42688ServiceEvent::Idle,
Icm42688Event::Initialized => Icm42688ServiceEvent::Initialized,
Icm42688Event::Sample(sample) => Icm42688ServiceEvent::Sample(sample),
Icm42688Event::Fault(error) => Icm42688ServiceEvent::Fault(error),
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Hardware-independent chip protocol drivers.
//!
//! Drivers express device register protocols and data conversion through small
//! bus traits. MCU, pin, DMA, interrupt, and RTIC mechanics belong below this
//! layer in a HAL route implementation and its platform composition.
#![no_std]
/// TDK InvenSense ICM-42688-P six-axis IMU driver.
pub mod icm42688;
/// InvenSense MPU6000 six-axis IMU driver.
pub mod mpu6000;
/// Garmin LIDAR-Lite v3 I²C rangefinder driver.
pub mod lidar_lite_v3;
/// MicoAir MTF-01 Microlink UART range and optical-flow driver.
pub mod mtf01;
@@ -0,0 +1,31 @@
//! LIDAR-Lite v3 operation errors.
use kaptan_log::KaptanError;
/// LIDAR-Lite v3 operation failure.
#[derive(Debug, Eq, PartialEq, KaptanError)]
pub enum Error<BusError> {
/// The selected I²C route rejected a transaction submission.
Start(BusError),
/// An acquisition request arrived while another measurement was active.
NotReady,
/// The selected I²C route reported a terminal transaction fault.
TransferFault,
/// The device reported a process error for the completed measurement.
ProcessError,
/// The device reported that its reference or receiver bias is unhealthy.
Unhealthy,
/// The device reported that no usable signal peak was detected.
InvalidSignal,
/// A measurement remained busy beyond the driver's bounded acquisition interval.
MeasurementTimeout,
/// The device reported a documented no-signal sentinel distance of zero or one centimetre.
NoSignal,
}
/// LIDAR-Lite v3 startup lifecycle failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum StartupError {
/// Startup completion was reported when the driver was not settling.
NotSettling,
}
@@ -0,0 +1,781 @@
//! Garmin LIDAR-Lite v3 register protocol driver.
mod error;
mod registers;
pub mod service;
use kaptan_i2c::{I2cBus, I2cCompletion};
use kaptan_sensor::range::RangeSnapshot;
use kaptan_time::Timestamp;
pub use error::{Error, StartupError};
pub use service::{LidarLiteV3Service, LidarLiteV3ServiceEvent};
use registers::{
ACQ_COMMAND, AUTO_INCREMENT, DEFAULT_ADDRESS, FULL_DELAY_HIGH,
MAX_NO_SIGNAL_DISTANCE_CENTIMETRES, MEASURE_WITH_RECEIVER_BIAS, MEASURE_WITHOUT_RECEIVER_BIAS,
STATUS, STATUS_BUSY, STATUS_HEALTHY, STATUS_INVALID_SIGNAL, STATUS_PROCESS_ERROR,
};
const COMMAND_LENGTH: usize = 2;
const STATUS_LENGTH: usize = 1;
const DISTANCE_LENGTH: usize = 2;
/// An acquisition with receiver-bias correction may take up to 135 ms.
///
/// The extra margin preserves liveness if the optical engine remains busy
/// while still accepting a documented slow acquisition.
const MEASUREMENT_TIMEOUT_MICROSECONDS: u64 = 200_000;
/// Receiver-bias correction selection for one acquisition.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AcquisitionMode {
/// Perform receiver-bias correction before measuring.
WithReceiverBiasCorrection,
/// Omit receiver-bias correction for a faster measurement.
///
/// The manufacturer recommends a corrected measurement periodically when
/// using this mode repeatedly.
WithoutReceiverBiasCorrection,
}
impl AcquisitionMode {
const fn command(self) -> u8 {
match self {
Self::WithReceiverBiasCorrection => MEASURE_WITH_RECEIVER_BIAS,
Self::WithoutReceiverBiasCorrection => MEASURE_WITHOUT_RECEIVER_BIAS,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum State {
Settling,
Idle,
Starting { timestamp: Timestamp },
Waiting { timestamp: Timestamp },
WritingStatusRegister { timestamp: Timestamp },
StatusReadPending { timestamp: Timestamp },
ReadingStatus { timestamp: Timestamp },
DistanceRegisterPending { timestamp: Timestamp },
WritingDistanceRegister { timestamp: Timestamp },
DistanceReadPending { timestamp: Timestamp },
ReadingDistance { timestamp: Timestamp },
}
/// A LIDAR-Lite v3 7-bit I²C address.
///
/// The device accepts its default address, `98`, and configurable addresses
/// with a clear least-significant bit, as specified by its address register.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LidarLiteV3Address(u8);
impl LidarLiteV3Address {
/// The device's factory-default 7-bit I²C address.
pub const DEFAULT: Self = Self(DEFAULT_ADDRESS);
/// Validates a configured LIDAR-Lite v3 7-bit I²C address.
pub const fn new(address: u8) -> Option<Self> {
if address <= 127 && address & 1 == 0 {
Some(Self(address))
} else {
None
}
}
/// Returns the address in the 7-bit representation required by [`I2cBus`].
pub const fn as_7bit(self) -> u8 {
self.0
}
}
/// Observable outcome of one I²C terminal event.
#[derive(Debug, Eq, PartialEq)]
pub enum LidarLiteV3Event<BusError> {
/// The selected I²C interrupt had no terminal transaction event.
NotPending,
/// The acquisition command was accepted by the device.
MeasurementStarted,
/// The measurement is still in progress; call [`LidarLiteV3::poll_measurement`]
/// again after a platform-selected interval.
MeasurementBusy,
/// The status-read phase is pending after its register selection completed.
ReadingStatus,
/// The distance-register selection phase is pending after the device became ready.
SelectingDistanceRegister,
/// The final distance-read phase is pending after its register selection completed.
ReadingMeasurement,
/// One valid range observation completed.
Measurement(RangeSnapshot),
/// Acquisition or transport failed.
Fault(Error<BusError>),
}
/// LIDAR-Lite v3 driver using its I²C interface.
///
/// This state machine neither delays nor polls hardware. The board calls
/// [`Self::poll_measurement`] periodically to start each STOP-separated phase
/// and reports I²C completion from its controller interrupt path.
pub struct LidarLiteV3<Bus> {
bus: Bus,
address: LidarLiteV3Address,
state: State,
command: [u8; COMMAND_LENGTH],
register: [u8; STATUS_LENGTH],
receive: [u8; DISTANCE_LENGTH],
}
impl<Bus> LidarLiteV3<Bus> {
/// Creates a driver for a device using its default 7-bit address, `98`.
///
/// Call [`Self::finish_startup`] only after the board has waited the
/// device's required 22 ms power-on settling interval.
pub const fn new(bus: Bus) -> Self {
Self::with_address(bus, LidarLiteV3Address::DEFAULT)
}
/// Creates a driver for a device with a previously configured address.
///
/// Call [`Self::finish_startup`] only after the board has waited the
/// device's required 22 ms power-on settling interval.
pub const fn with_address(bus: Bus, address: LidarLiteV3Address) -> Self {
Self {
bus,
address,
state: State::Settling,
command: [0; COMMAND_LENGTH],
register: [0; STATUS_LENGTH],
receive: [0; DISTANCE_LENGTH],
}
}
/// Marks the device ready after the board has observed its 22 ms power-on
/// settling interval.
pub fn finish_startup(&mut self) -> Result<(), StartupError> {
if self.state != State::Settling {
return Err(StartupError::NotSettling);
}
self.state = State::Idle;
Ok(())
}
/// Acknowledges a controller interrupt against the owned bus.
///
/// The callback must only acknowledge the current controller interrupt;
/// starting another transaction would violate this driver's phase state.
pub fn acknowledge_with<T>(&mut self, callback: impl FnOnce(&mut Bus) -> T) -> T {
callback(&mut self.bus)
}
}
impl<Bus: I2cBus> LidarLiteV3<Bus> {
/// Starts one distance acquisition without waiting for I²C or the device.
pub fn request_measurement(
&mut self,
timestamp: Timestamp,
mode: AcquisitionMode,
) -> Result<(), Error<Bus::StartError>> {
if self.state != State::Idle {
return Err(Error::NotReady);
}
self.command = [ACQ_COMMAND, mode.command()];
self.bus
.start_write(self.address.as_7bit(), &self.command)
.map_err(Error::Start)?;
self.state = State::Starting { timestamp };
Ok(())
}
/// Advances the next STOP-separated phase after a platform-selected interval.
///
/// LIDAR-Lite v3 requires a STOP before a new START. The I²C terminal IRQ
/// therefore only records phase completion; this periodic path begins the
/// following transaction after the bus has been released.
pub fn poll_measurement(&mut self, now: Timestamp) -> Result<(), Error<Bus::StartError>> {
let timestamp = match self.state {
State::Waiting { timestamp }
| State::StatusReadPending { timestamp }
| State::DistanceRegisterPending { timestamp }
| State::DistanceReadPending { timestamp } => timestamp,
State::Settling | State::Idle => return Err(Error::NotReady),
_ => {
if self.measurement_timed_out(now) {
self.bus.cancel_transaction();
self.state = State::Idle;
return Err(Error::MeasurementTimeout);
}
return Err(Error::NotReady);
}
};
if self.measurement_timed_out(now) {
self.bus.cancel_transaction();
self.state = State::Idle;
return Err(Error::MeasurementTimeout);
}
match self.state {
State::Waiting { .. } => self.start_status_read(timestamp),
State::StatusReadPending { .. } => self.start_read_status(timestamp),
State::DistanceRegisterPending { .. } => self.start_distance_read(timestamp),
State::DistanceReadPending { .. } => self.start_read_distance(timestamp),
_ => unreachable!(),
}
}
/// Advances the state machine from an already-acknowledged I²C terminal event.
pub fn on_transaction_complete(
&mut self,
completion: I2cCompletion,
) -> LidarLiteV3Event<Bus::StartError> {
if completion == I2cCompletion::NotPending || !self.has_active_transaction() {
return LidarLiteV3Event::NotPending;
}
match completion {
I2cCompletion::TransferFault => {
self.bus.complete_transaction(completion, None);
self.state = State::Idle;
LidarLiteV3Event::Fault(Error::TransferFault)
}
I2cCompletion::Complete => {
let read = match self.state {
State::ReadingStatus { .. } => Some(&mut self.receive[..STATUS_LENGTH]),
State::ReadingDistance { .. } => Some(&mut self.receive[..DISTANCE_LENGTH]),
_ => None,
};
self.bus.complete_transaction(completion, read);
self.advance_after_completion()
}
I2cCompletion::NotPending => unreachable!(),
}
}
/// Returns whether a new acquisition can be requested.
pub fn is_ready(&self) -> bool {
self.state == State::Idle
}
fn has_active_transaction(&self) -> bool {
matches!(
self.state,
State::Starting { .. }
| State::WritingStatusRegister { .. }
| State::ReadingStatus { .. }
| State::WritingDistanceRegister { .. }
| State::ReadingDistance { .. }
)
}
fn measurement_timed_out(&self, now: Timestamp) -> bool {
let timestamp = match self.state {
State::Starting { timestamp }
| State::Waiting { timestamp }
| State::WritingStatusRegister { timestamp }
| State::StatusReadPending { timestamp }
| State::ReadingStatus { timestamp }
| State::DistanceRegisterPending { timestamp }
| State::WritingDistanceRegister { timestamp }
| State::DistanceReadPending { timestamp }
| State::ReadingDistance { timestamp } => timestamp,
State::Settling | State::Idle => return false,
};
now.as_microseconds()
.saturating_sub(timestamp.as_microseconds())
>= MEASUREMENT_TIMEOUT_MICROSECONDS
}
fn advance_after_completion(&mut self) -> LidarLiteV3Event<Bus::StartError> {
match self.state {
State::Settling => LidarLiteV3Event::NotPending,
State::Idle => LidarLiteV3Event::NotPending,
State::Starting { timestamp } => {
self.state = State::Waiting { timestamp };
LidarLiteV3Event::MeasurementStarted
}
State::Waiting { .. } => LidarLiteV3Event::NotPending,
State::WritingStatusRegister { timestamp } => {
self.state = State::StatusReadPending { timestamp };
LidarLiteV3Event::ReadingStatus
}
State::StatusReadPending { .. } => LidarLiteV3Event::NotPending,
State::ReadingStatus { timestamp } => {
if self.receive[0] & STATUS_BUSY != 0 {
self.state = State::Waiting { timestamp };
LidarLiteV3Event::MeasurementBusy
} else if self.receive[0] & STATUS_PROCESS_ERROR != 0 {
self.state = State::Idle;
LidarLiteV3Event::Fault(Error::ProcessError)
} else if self.receive[0] & STATUS_INVALID_SIGNAL != 0 {
self.state = State::Idle;
LidarLiteV3Event::Fault(Error::InvalidSignal)
} else if self.receive[0] & STATUS_HEALTHY == 0 {
self.state = State::Idle;
LidarLiteV3Event::Fault(Error::Unhealthy)
} else {
self.state = State::DistanceRegisterPending { timestamp };
LidarLiteV3Event::SelectingDistanceRegister
}
}
State::DistanceRegisterPending { .. } => LidarLiteV3Event::NotPending,
State::WritingDistanceRegister { timestamp } => {
self.state = State::DistanceReadPending { timestamp };
LidarLiteV3Event::ReadingMeasurement
}
State::DistanceReadPending { .. } => LidarLiteV3Event::NotPending,
State::ReadingDistance { timestamp } => {
let distance_centimetres = u16::from_be_bytes(self.receive);
self.state = State::Idle;
if distance_centimetres <= MAX_NO_SIGNAL_DISTANCE_CENTIMETRES {
LidarLiteV3Event::Fault(Error::NoSignal)
} else {
LidarLiteV3Event::Measurement(RangeSnapshot::new(
timestamp,
u32::from(distance_centimetres) * 10,
))
}
}
}
}
fn start_status_read(&mut self, timestamp: Timestamp) -> Result<(), Error<Bus::StartError>> {
self.register[0] = STATUS;
self.receive = [0; DISTANCE_LENGTH];
self.bus
.start_write(self.address.as_7bit(), &self.register)
.map_err(Error::Start)?;
self.state = State::WritingStatusRegister { timestamp };
Ok(())
}
fn start_read_status(&mut self, timestamp: Timestamp) -> Result<(), Error<Bus::StartError>> {
self.bus
.start_read(self.address.as_7bit(), STATUS_LENGTH)
.map_err(Error::Start)?;
self.state = State::ReadingStatus { timestamp };
Ok(())
}
fn start_distance_read(&mut self, timestamp: Timestamp) -> Result<(), Error<Bus::StartError>> {
self.register[0] = FULL_DELAY_HIGH | AUTO_INCREMENT;
self.receive = [0; DISTANCE_LENGTH];
self.bus
.start_write(self.address.as_7bit(), &self.register)
.map_err(Error::Start)?;
self.state = State::WritingDistanceRegister { timestamp };
Ok(())
}
fn start_read_distance(&mut self, timestamp: Timestamp) -> Result<(), Error<Bus::StartError>> {
self.bus
.start_read(self.address.as_7bit(), DISTANCE_LENGTH)
.map_err(Error::Start)?;
self.state = State::ReadingDistance { timestamp };
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::registers::{STATUS_HEALTHY, STATUS_INVALID_SIGNAL, STATUS_PROCESS_ERROR};
use super::{
AcquisitionMode, Error, LidarLiteV3, LidarLiteV3Address, LidarLiteV3Event,
MEASUREMENT_TIMEOUT_MICROSECONDS, StartupError,
};
use kaptan_i2c::{I2cBus, I2cCompletion};
use kaptan_sensor::range::RangeSnapshot;
use kaptan_time::Timestamp;
#[derive(Debug, Eq, PartialEq)]
enum FakeError {
Start,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Transaction {
None,
Write {
address: u8,
bytes: [u8; 2],
length: usize,
},
Read {
address: u8,
length: usize,
},
}
struct FakeBus {
transaction: Transaction,
response: [u8; 2],
fail_start: bool,
completions: usize,
cancellations: usize,
}
impl FakeBus {
fn ready() -> Self {
Self {
transaction: Transaction::None,
response: [0; 2],
fail_start: false,
completions: 0,
cancellations: 0,
}
}
}
fn initialized() -> LidarLiteV3<FakeBus> {
let mut driver = LidarLiteV3::new(FakeBus::ready());
driver.finish_startup().unwrap();
driver
}
impl I2cBus for FakeBus {
type StartError = FakeError;
fn start_write(&mut self, address: u8, write: &[u8]) -> Result<(), Self::StartError> {
if self.fail_start {
return Err(FakeError::Start);
}
let mut bytes = [0; 2];
bytes[..write.len()].copy_from_slice(write);
self.transaction = Transaction::Write {
address,
bytes,
length: write.len(),
};
Ok(())
}
fn start_read(&mut self, address: u8, length: usize) -> Result<(), Self::StartError> {
if self.fail_start {
return Err(FakeError::Start);
}
self.transaction = Transaction::Read { address, length };
Ok(())
}
fn complete_transaction(&mut self, completion: I2cCompletion, read: Option<&mut [u8]>) {
if completion == I2cCompletion::Complete
&& let Some(read) = read
{
read.copy_from_slice(&self.response[..read.len()]);
}
self.completions += 1;
}
fn cancel_transaction(&mut self) {
self.cancellations += 1;
}
}
#[test]
fn waits_for_startup_and_rejects_invalid_addresses() {
let mut driver = LidarLiteV3::new(FakeBus::ready());
assert!(!driver.is_ready());
assert_eq!(
driver.on_transaction_complete(I2cCompletion::NotPending),
LidarLiteV3Event::NotPending
);
assert_eq!(driver.bus.completions, 0);
assert_eq!(
driver.on_transaction_complete(I2cCompletion::TransferFault),
LidarLiteV3Event::NotPending
);
assert_eq!(driver.bus.completions, 0);
assert!(!driver.is_ready());
assert!(matches!(
driver.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
),
Err(Error::NotReady)
));
driver.finish_startup().unwrap();
assert!(driver.is_ready());
assert_eq!(driver.finish_startup(), Err(StartupError::NotSettling));
assert_eq!(LidarLiteV3Address::new(98).unwrap().as_7bit(), 98);
assert_eq!(LidarLiteV3Address::new(196), None);
assert_eq!(LidarLiteV3Address::new(99), None);
}
#[test]
fn measures_after_stop_separated_status_and_distance_reads() {
let mut driver = initialized();
let timestamp = Timestamp::from_microseconds(42);
driver
.request_measurement(timestamp, AcquisitionMode::WithReceiverBiasCorrection)
.unwrap();
assert_eq!(
driver.bus.transaction,
Transaction::Write {
address: LidarLiteV3Address::DEFAULT.as_7bit(),
bytes: [0, 4],
length: 2
}
);
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::MeasurementStarted
);
driver.poll_measurement(timestamp).unwrap();
assert_eq!(
driver.bus.transaction,
Transaction::Write {
address: LidarLiteV3Address::DEFAULT.as_7bit(),
bytes: [1, 0],
length: 1
}
);
driver.bus.response = [STATUS_HEALTHY, 0];
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::ReadingStatus
);
driver.poll_measurement(timestamp).unwrap();
assert_eq!(
driver.bus.transaction,
Transaction::Read {
address: LidarLiteV3Address::DEFAULT.as_7bit(),
length: 1
}
);
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::SelectingDistanceRegister
);
driver.poll_measurement(timestamp).unwrap();
assert_eq!(
driver.bus.transaction,
Transaction::Write {
address: LidarLiteV3Address::DEFAULT.as_7bit(),
bytes: [143, 0],
length: 1
}
);
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::ReadingMeasurement
);
driver.bus.response = 291_u16.to_be_bytes();
driver.poll_measurement(timestamp).unwrap();
assert_eq!(
driver.bus.transaction,
Transaction::Read {
address: LidarLiteV3Address::DEFAULT.as_7bit(),
length: 2
}
);
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Measurement(RangeSnapshot::new(timestamp, 2_910))
);
assert!(driver.is_ready());
}
#[test]
fn keeps_polling_while_the_device_is_busy() {
let mut driver = initialized();
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithoutReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [STATUS_HEALTHY | 1, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::MeasurementBusy
);
assert!(!driver.is_ready());
assert!(
driver
.poll_measurement(Timestamp::from_microseconds(0))
.is_ok()
);
}
#[test]
fn recovers_from_a_measurement_that_remains_busy_too_long() {
let mut driver = initialized();
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
assert_eq!(
driver.poll_measurement(Timestamp::from_microseconds(
MEASUREMENT_TIMEOUT_MICROSECONDS,
)),
Err(Error::MeasurementTimeout)
);
assert!(driver.is_ready());
assert_eq!(driver.bus.cancellations, 1);
}
#[test]
fn reports_device_status_failures() {
let mut driver = initialized();
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [STATUS_HEALTHY | STATUS_PROCESS_ERROR, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Fault(Error::ProcessError)
);
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [0, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Fault(Error::Unhealthy)
);
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [STATUS_HEALTHY | STATUS_INVALID_SIGNAL, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Fault(Error::InvalidSignal)
);
}
#[test]
fn rejects_no_signal_and_preserves_the_full_raw_distance_range() {
let mut driver = initialized();
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [STATUS_HEALTHY, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver.bus.response = 1_u16.to_be_bytes();
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Fault(Error::NoSignal)
);
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [STATUS_HEALTHY, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver.bus.response = 0_u16.to_be_bytes();
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Fault(Error::NoSignal)
);
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver
.poll_measurement(Timestamp::from_microseconds(0))
.unwrap();
driver.bus.response = [STATUS_HEALTHY, 0];
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
let _ = driver.on_transaction_complete(I2cCompletion::Complete);
driver.bus.response = u16::MAX.to_be_bytes();
let _ = driver.poll_measurement(Timestamp::from_microseconds(0));
assert_eq!(
driver.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3Event::Measurement(RangeSnapshot::new(
Timestamp::from_microseconds(0),
655_350,
))
);
}
#[test]
fn resets_to_idle_after_a_transport_fault() {
let mut driver = initialized();
driver
.request_measurement(
Timestamp::from_microseconds(0),
AcquisitionMode::WithReceiverBiasCorrection,
)
.unwrap();
assert_eq!(
driver.on_transaction_complete(I2cCompletion::TransferFault),
LidarLiteV3Event::Fault(Error::TransferFault)
);
assert!(driver.is_ready());
}
}
@@ -0,0 +1,14 @@
//! LIDAR-Lite v3 I²C register definitions.
pub(crate) const DEFAULT_ADDRESS: u8 = 98;
pub(crate) const ACQ_COMMAND: u8 = 0;
pub(crate) const STATUS: u8 = 1;
pub(crate) const FULL_DELAY_HIGH: u8 = 15;
pub(crate) const AUTO_INCREMENT: u8 = 128;
pub(crate) const STATUS_BUSY: u8 = 1;
pub(crate) const STATUS_INVALID_SIGNAL: u8 = 1 << 3;
pub(crate) const STATUS_HEALTHY: u8 = 1 << 5;
pub(crate) const STATUS_PROCESS_ERROR: u8 = 1 << 6;
pub(crate) const MEASURE_WITHOUT_RECEIVER_BIAS: u8 = 3;
pub(crate) const MEASURE_WITH_RECEIVER_BIAS: u8 = 4;
pub(crate) const MAX_NO_SIGNAL_DISTANCE_CENTIMETRES: u16 = 1;
@@ -0,0 +1,180 @@
//! Timestamped LIDAR-Lite v3 acquisition service.
use super::{AcquisitionMode, Error, LidarLiteV3, LidarLiteV3Event, StartupError};
use kaptan_i2c::{I2cBus, I2cCompletion};
use kaptan_sensor::range::RangeSnapshot;
use kaptan_time::MonotonicClock;
/// One observable result from a LIDAR-Lite v3 acquisition service.
#[derive(Debug)]
pub enum LidarLiteV3ServiceEvent<BusError> {
/// No observation or terminal fault was produced.
Idle,
/// One coherent range observation is ready for publication.
Measurement(RangeSnapshot),
/// The LIDAR-Lite v3 path entered a terminal fault state.
Fault(Error<BusError>),
}
/// Owns LIDAR-Lite v3 state, its clock, and measurement acceptance.
pub struct LidarLiteV3Service<Clock, Bus> {
clock: Clock,
lidar: LidarLiteV3<Bus>,
mode: AcquisitionMode,
}
impl<Clock, Bus> LidarLiteV3Service<Clock, Bus> {
/// Creates an inactive LIDAR-Lite v3 acquisition service.
pub const fn new(clock: Clock, lidar: LidarLiteV3<Bus>, mode: AcquisitionMode) -> Self {
Self { clock, lidar, mode }
}
/// Completes the LIDAR-Lite v3 startup interval selected by the board.
pub fn finish_startup(&mut self) -> Result<(), StartupError> {
self.lidar.finish_startup()
}
}
impl<Clock: MonotonicClock, Bus: I2cBus> LidarLiteV3Service<Clock, Bus> {
/// Starts a new measurement, or polls one already in progress.
pub fn on_periodic_tick(&mut self) -> LidarLiteV3ServiceEvent<Bus::StartError> {
let now = self.clock.now();
let result = if self.lidar.is_ready() {
self.lidar.request_measurement(now, self.mode)
} else {
self.lidar.poll_measurement(now)
};
match result {
Ok(()) | Err(Error::NotReady) => LidarLiteV3ServiceEvent::Idle,
Err(error) => LidarLiteV3ServiceEvent::Fault(error),
}
}
/// Advances the LIDAR-Lite v3 transaction state from an I²C terminal event.
pub fn on_transaction_complete(
&mut self,
completion: I2cCompletion,
) -> LidarLiteV3ServiceEvent<Bus::StartError> {
match self.lidar.on_transaction_complete(completion) {
LidarLiteV3Event::Measurement(sample) => LidarLiteV3ServiceEvent::Measurement(sample),
LidarLiteV3Event::Fault(error) => LidarLiteV3ServiceEvent::Fault(error),
_ => LidarLiteV3ServiceEvent::Idle,
}
}
/// Acknowledges a board-controller interrupt and advances the acquisition.
///
/// The closure keeps controller-specific interrupt handling at the board
/// boundary while preserving this service as the sole owner of LIDAR state.
pub fn on_controller_interrupt<F>(
&mut self,
acknowledge: F,
) -> LidarLiteV3ServiceEvent<Bus::StartError>
where
F: FnOnce(&mut Bus) -> I2cCompletion,
{
let completion = self.lidar.acknowledge_with(acknowledge);
self.on_transaction_complete(completion)
}
}
#[cfg(test)]
mod tests {
use super::{LidarLiteV3Service, LidarLiteV3ServiceEvent};
use crate::lidar_lite_v3::{AcquisitionMode, LidarLiteV3};
use kaptan_i2c::{I2cBus, I2cCompletion};
use kaptan_time::{MonotonicClock, Timestamp};
struct Clock;
impl MonotonicClock for Clock {
fn now(&mut self) -> Timestamp {
Timestamp::from_microseconds(123)
}
}
struct Bus {
response: [u8; 2],
}
impl I2cBus for Bus {
type StartError = ();
fn start_write(&mut self, _: u8, _: &[u8]) -> Result<(), Self::StartError> {
Ok(())
}
fn start_read(&mut self, _: u8, _: usize) -> Result<(), Self::StartError> {
Ok(())
}
fn complete_transaction(&mut self, completion: I2cCompletion, read: Option<&mut [u8]>) {
if completion == I2cCompletion::Complete
&& let Some(read) = read
{
read.copy_from_slice(&self.response[..read.len()]);
}
}
fn cancel_transaction(&mut self) {}
}
#[test]
fn produces_a_measurement_after_stop_separated_i2c_transactions() {
let mut service = LidarLiteV3Service::new(
Clock,
LidarLiteV3::new(Bus { response: [0; 2] }),
AcquisitionMode::WithReceiverBiasCorrection,
);
service.finish_startup().unwrap();
assert!(matches!(
service.on_periodic_tick(),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_transaction_complete(I2cCompletion::Complete),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_periodic_tick(),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_controller_interrupt(|bus| {
bus.response = [1 << 5, 0];
I2cCompletion::Complete
}),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_periodic_tick(),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_controller_interrupt(|_| I2cCompletion::Complete),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_periodic_tick(),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_controller_interrupt(|bus| {
bus.response = [0x01, 0x23];
I2cCompletion::Complete
}),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_periodic_tick(),
LidarLiteV3ServiceEvent::Idle
));
assert!(matches!(
service.on_controller_interrupt(|_| I2cCompletion::Complete),
LidarLiteV3ServiceEvent::Measurement(sample)
if sample.timestamp == Timestamp::from_microseconds(123)
&& sample.distance_millimetres == 2_910
));
}
}
@@ -0,0 +1,125 @@
//! MPU6000 full-scale configuration and conversion factors.
/// MPU6000 full-scale configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Mpu6000Config {
/// Accelerometer full-scale range.
pub accelerometer: AccelerometerRange,
/// Gyroscope full-scale range.
pub gyroscope: GyroscopeRange,
/// Digital low-pass filter configuration.
pub digital_low_pass_filter: DigitalLowPassFilter,
/// Divider applied to the filter's internal sample rate.
pub sample_rate_divider: u8,
}
impl Default for Mpu6000Config {
fn default() -> Self {
Self {
accelerometer: AccelerometerRange::PlusMinus4G,
gyroscope: GyroscopeRange::PlusMinus1000DegreesPerSecond,
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
sample_rate_divider: 9,
}
}
}
/// MPU6000 digital low-pass filter bandwidth selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DigitalLowPassFilter {
/// 260 Hz bandwidth; internal sample rate is 8 kHz.
Bandwidth260Hz,
/// 184 Hz bandwidth; internal sample rate is 1 kHz.
Bandwidth184Hz,
/// 94 Hz bandwidth; internal sample rate is 1 kHz.
Bandwidth94Hz,
/// 42 Hz bandwidth; internal sample rate is 1 kHz.
Bandwidth42Hz,
/// 21 Hz bandwidth; internal sample rate is 1 kHz.
Bandwidth21Hz,
/// 10 Hz bandwidth; internal sample rate is 1 kHz.
Bandwidth10Hz,
/// 5 Hz bandwidth; internal sample rate is 1 kHz.
Bandwidth5Hz,
}
impl DigitalLowPassFilter {
pub(crate) const fn register_value(self) -> u8 {
match self {
Self::Bandwidth260Hz => 0,
Self::Bandwidth184Hz => 1,
Self::Bandwidth94Hz => 2,
Self::Bandwidth42Hz => 3,
Self::Bandwidth21Hz => 4,
Self::Bandwidth10Hz => 5,
Self::Bandwidth5Hz => 6,
}
}
}
/// MPU6000 accelerometer full-scale range.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AccelerometerRange {
/// ±2 g.
PlusMinus2G,
/// ±4 g.
PlusMinus4G,
/// ±8 g.
PlusMinus8G,
/// ±16 g.
PlusMinus16G,
}
impl AccelerometerRange {
pub(crate) const fn register_value(self) -> u8 {
match self {
Self::PlusMinus2G => 0x00,
Self::PlusMinus4G => 0x08,
Self::PlusMinus8G => 0x10,
Self::PlusMinus16G => 0x18,
}
}
pub(crate) const fn counts_per_g(self) -> i32 {
match self {
Self::PlusMinus2G => 16_384,
Self::PlusMinus4G => 8_192,
Self::PlusMinus8G => 4_096,
Self::PlusMinus16G => 2_048,
}
}
}
/// MPU6000 gyroscope full-scale range.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GyroscopeRange {
/// ±250 degrees per second.
PlusMinus250DegreesPerSecond,
/// ±500 degrees per second.
PlusMinus500DegreesPerSecond,
/// ±1000 degrees per second.
PlusMinus1000DegreesPerSecond,
/// ±2000 degrees per second.
PlusMinus2000DegreesPerSecond,
}
impl GyroscopeRange {
pub(crate) const fn register_value(self) -> u8 {
match self {
Self::PlusMinus250DegreesPerSecond => 0x00,
Self::PlusMinus500DegreesPerSecond => 0x08,
Self::PlusMinus1000DegreesPerSecond => 0x10,
Self::PlusMinus2000DegreesPerSecond => 0x18,
}
}
/// Divisor for $\mathrm{mrad/s} = raw \times 17453 / divisor$.
pub(crate) const fn milliradians_per_second_divisor(self) -> i32 {
match self {
Self::PlusMinus250DegreesPerSecond => 131_000,
Self::PlusMinus500DegreesPerSecond => 65_500,
Self::PlusMinus1000DegreesPerSecond => 32_800,
Self::PlusMinus2000DegreesPerSecond => 16_400,
}
}
}
@@ -0,0 +1,17 @@
//! MPU6000 operation errors.
use kaptan_log::KaptanError;
/// MPU6000 operation failure.
#[derive(Debug, Eq, PartialEq, KaptanError)]
pub enum Error<BusError> {
/// The selected SPI route rejected a transfer submission.
Start(BusError),
/// The responding device does not identify as an MPU6000.
UnexpectedIdentity(u8),
/// An acquisition request arrived before initialization completed or while
/// another transfer still owned the selected route.
NotReady,
/// The selected SPI route reported a terminal transfer fault.
TransferFault,
}
@@ -0,0 +1,363 @@
//! MPU6000 register protocol and sample driver.
mod config;
mod error;
mod registers;
mod sample;
pub mod service;
use kaptan_sensor::imu::ImuSnapshot;
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::Timestamp;
pub use config::{AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000Config};
pub use error::Error;
pub use service::{Mpu6000Service, Mpu6000ServiceEvent};
use registers::{
ACCEL_CONFIG, ACCEL_XOUT_H, CLOCK_SOURCE_PLL_X_GYRO, CONFIG, DATA_READY_INTERRUPT,
DEVICE_RESET, ENABLE_ALL_SENSOR_AXES, EXPECTED_WHO_AM_I, GYRO_CONFIG, I2C_INTERFACE_DISABLE,
INT_ENABLE, INT_PIN_CFG, INTERRUPT_CLEAR_ON_ANY_READ, PWR_MGMT_1, PWR_MGMT_2, SAMPLE_BYTES,
SMPLRT_DIV, USER_CTRL, WHO_AM_I,
};
const TRANSFER_CAPACITY: usize = SAMPLE_BYTES + 1;
const CONFIGURATION: [(u8, u8); 9] = [
(PWR_MGMT_1, CLOCK_SOURCE_PLL_X_GYRO),
(USER_CTRL, I2C_INTERFACE_DISABLE),
(PWR_MGMT_2, ENABLE_ALL_SENSOR_AXES),
(CONFIG, 0),
(SMPLRT_DIV, 0),
(ACCEL_CONFIG, 0),
(GYRO_CONFIG, 0),
(INT_PIN_CFG, INTERRUPT_CLEAR_ON_ANY_READ),
(INT_ENABLE, DATA_READY_INTERRUPT),
];
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum State {
Dormant,
Resetting,
WaitingForReset,
ReadingIdentity,
WritingConfiguration(usize),
Ready,
ReadingSample { timestamp: Timestamp },
}
/// Observable outcome of one SPI terminal event.
#[derive(Debug, Eq, PartialEq)]
pub enum Mpu6000Event<BusError> {
/// The selected SPI interrupt had no terminal transfer event.
NotPending,
/// One initialization transfer completed; initialization continues.
Initializing,
/// The MPU6000 identity and configuration sequence completed.
Initialized,
/// One coherent IMU sample completed.
Sample(ImuSnapshot),
/// Initialization or acquisition failed.
Fault(Error<BusError>),
}
/// Configured MPU6000 driver.
///
/// The driver owns fixed transfer buffers and advances only from external
/// interrupt events. It never waits for SPI status flags.
pub struct Mpu6000<Bus> {
bus: Bus,
config: Mpu6000Config,
state: State,
transmit: [u8; TRANSFER_CAPACITY],
receive: [u8; TRANSFER_CAPACITY],
}
impl<Bus> Mpu6000<Bus> {
/// Creates an inactive MPU6000 state machine.
pub const fn new(bus: Bus, config: Mpu6000Config) -> Self {
Self {
bus,
config,
state: State::Dormant,
transmit: [0; TRANSFER_CAPACITY],
receive: [0; TRANSFER_CAPACITY],
}
}
}
impl<Bus: SpiDevice> Mpu6000<Bus> {
/// Starts a device reset without waiting for SPI.
///
/// The caller must subsequently call [`Self::advance_initialization`] only
/// after the platform-selected reset-settling interval has elapsed.
pub fn begin_initialization(&mut self) -> Result<(), Error<Bus::StartError>> {
if self.state != State::Dormant {
return Err(Error::NotReady);
}
self.prepare_write(PWR_MGMT_1, DEVICE_RESET);
self.submit(2)?;
self.state = State::Resetting;
Ok(())
}
/// Starts identity verification after the required reset-settling interval.
///
/// This operation only submits the next asynchronous SPI transfer; it
/// never waits for a peripheral or sensor status flag.
pub fn advance_initialization(&mut self) -> Result<(), Error<Bus::StartError>> {
if self.state != State::WaitingForReset {
return Ok(());
}
self.prepare_read(WHO_AM_I, 2);
if let Err(error) = self.submit(2) {
self.state = State::Dormant;
return Err(error);
}
self.state = State::ReadingIdentity;
Ok(())
}
/// Starts one fourteen-byte sensor read when initialization is complete.
pub fn request_sample(&mut self, timestamp: Timestamp) -> Result<(), Error<Bus::StartError>> {
if self.state != State::Ready {
return Err(Error::NotReady);
}
self.prepare_read(ACCEL_XOUT_H, TRANSFER_CAPACITY);
self.submit(TRANSFER_CAPACITY)?;
self.state = State::ReadingSample { timestamp };
Ok(())
}
/// Advances the state machine from an already-acknowledged DMA terminal event.
pub fn on_transfer_complete(
&mut self,
completion: SpiCompletion,
) -> Mpu6000Event<Bus::StartError> {
self.bus.complete_transfer(completion);
match completion {
SpiCompletion::NotPending => Mpu6000Event::NotPending,
SpiCompletion::TransferFault => {
self.state = State::Dormant;
Mpu6000Event::Fault(Error::TransferFault)
}
SpiCompletion::Complete => self.advance_after_completion(),
}
}
/// Returns whether an IMU data-ready event can start a sensor transfer.
pub fn is_ready(&self) -> bool {
self.state == State::Ready
}
fn advance_after_completion(&mut self) -> Mpu6000Event<Bus::StartError> {
match self.state {
State::Dormant => Mpu6000Event::NotPending,
State::Resetting => {
self.state = State::WaitingForReset;
Mpu6000Event::Initializing
}
State::WaitingForReset => Mpu6000Event::NotPending,
State::ReadingIdentity => {
let identity = self.receive[1];
if identity != EXPECTED_WHO_AM_I {
self.state = State::Dormant;
return Mpu6000Event::Fault(Error::UnexpectedIdentity(identity));
}
self.start_configuration(0)
}
State::WritingConfiguration(index) => {
let next = index + 1;
if next == CONFIGURATION.len() {
self.state = State::Ready;
Mpu6000Event::Initialized
} else {
self.start_configuration(next)
}
}
State::Ready => Mpu6000Event::NotPending,
State::ReadingSample { timestamp } => {
let mut bytes = [0; SAMPLE_BYTES];
bytes.copy_from_slice(&self.receive[1..]);
self.state = State::Ready;
Mpu6000Event::Sample(sample::decode(&bytes, timestamp, self.config))
}
}
}
fn start_configuration(&mut self, index: usize) -> Mpu6000Event<Bus::StartError> {
let (register, value) = CONFIGURATION[index];
let value = match register {
CONFIG => self.config.digital_low_pass_filter.register_value(),
SMPLRT_DIV => self.config.sample_rate_divider,
ACCEL_CONFIG => self.config.accelerometer.register_value(),
GYRO_CONFIG => self.config.gyroscope.register_value(),
_ => value,
};
self.prepare_write(register, value);
match self.submit(2) {
Ok(()) => {
self.state = State::WritingConfiguration(index);
Mpu6000Event::Initializing
}
Err(error) => {
self.state = State::Dormant;
Mpu6000Event::Fault(error)
}
}
}
fn prepare_read(&mut self, register: u8, length: usize) {
self.transmit = [0; TRANSFER_CAPACITY];
self.receive = [0; TRANSFER_CAPACITY];
self.transmit[0] = register | 0x80;
debug_assert!(length <= TRANSFER_CAPACITY);
}
fn prepare_write(&mut self, register: u8, value: u8) {
self.transmit = [0; TRANSFER_CAPACITY];
self.receive = [0; TRANSFER_CAPACITY];
self.transmit[..2].copy_from_slice(&[register, value]);
}
fn submit(&mut self, length: usize) -> Result<(), Error<Bus::StartError>> {
let (bus, transmit, receive) = (&mut self.bus, &self.transmit, &mut self.receive);
bus.start_transfer(&transmit[..length], &mut receive[..length])
.map_err(Error::Start)
}
}
#[cfg(test)]
mod tests {
use super::{Error, Mpu6000, Mpu6000Config, Mpu6000Event, TRANSFER_CAPACITY};
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::Timestamp;
#[derive(Debug, Eq, PartialEq)]
enum FakeError {
Start,
}
struct FakeBus {
receive: [u8; TRANSFER_CAPACITY],
length: usize,
fail_start: bool,
}
impl FakeBus {
fn ready() -> Self {
Self {
receive: [0; TRANSFER_CAPACITY],
length: 0,
fail_start: false,
}
}
}
impl SpiDevice for FakeBus {
type StartError = FakeError;
fn start_transfer(&mut self, _: &[u8], receive: &mut [u8]) -> Result<(), Self::StartError> {
if self.fail_start {
return Err(FakeError::Start);
}
self.length = receive.len();
receive.copy_from_slice(&self.receive[..self.length]);
Ok(())
}
fn complete_transfer(&mut self, _: SpiCompletion) {}
}
#[test]
fn configures_a_verified_device_without_waiting() {
let mut bus = FakeBus::ready();
bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
driver.begin_initialization().unwrap();
assert_eq!(
driver.on_transfer_complete(SpiCompletion::Complete),
Mpu6000Event::Initializing
);
driver.advance_initialization().unwrap();
for _ in 0..9 {
assert_eq!(
driver.on_transfer_complete(SpiCompletion::Complete),
Mpu6000Event::Initializing
);
}
assert_eq!(
driver.on_transfer_complete(SpiCompletion::Complete),
Mpu6000Event::Initialized
);
assert!(driver.is_ready());
}
#[test]
fn rejects_an_unexpected_identity() {
let mut bus = FakeBus::ready();
bus.receive[1] = 0x70;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
driver.begin_initialization().unwrap();
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
driver.advance_initialization().unwrap();
assert!(matches!(
driver.on_transfer_complete(SpiCompletion::Complete),
Mpu6000Event::Fault(Error::UnexpectedIdentity(0x70))
));
}
#[test]
fn converts_a_complete_burst_to_core_units() {
let burst = [
0x20, 0x00, 0xE0, 0x00, 0x00, 0x00, // accel: 1g, -1g, 0g
0x00, 0x00, // 36.53 C
0x40, 0x10, 0xBF, 0xF0, 0x00, 0x00, // gyro: 500, -500, 0 dps
];
let mut bus = FakeBus::ready();
bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
driver.begin_initialization().unwrap();
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
driver.advance_initialization().unwrap();
for _ in 0..9 {
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
}
let _ = driver.on_transfer_complete(SpiCompletion::Complete);
driver.bus.receive[1..].copy_from_slice(&burst);
driver
.request_sample(Timestamp::from_microseconds(42))
.unwrap();
let Mpu6000Event::Sample(sample) = driver.on_transfer_complete(SpiCompletion::Complete)
else {
panic!("sample transfer must complete");
};
assert_eq!(sample.acceleration_milligravity, [1_000, -1_000, 0]);
assert_eq!(
sample.angular_rate_milliradians_per_second,
[8_726, -8_726, 0]
);
assert_eq!(sample.temperature_centidegrees_celsius, 3_653);
}
#[test]
fn reports_a_route_transfer_fault() {
let mut bus = FakeBus::ready();
bus.receive[1] = 0x68;
let mut driver = Mpu6000::new(bus, Mpu6000Config::default());
driver.begin_initialization().unwrap();
assert!(matches!(
driver.on_transfer_complete(SpiCompletion::TransferFault),
Mpu6000Event::Fault(Error::TransferFault)
));
}
}
@@ -0,0 +1,22 @@
//! MPU6000 register addresses and fixed protocol values.
pub(crate) const WHO_AM_I: u8 = 0x75;
pub(crate) const EXPECTED_WHO_AM_I: u8 = 0x68;
pub(crate) const PWR_MGMT_1: u8 = 0x6B;
pub(crate) const PWR_MGMT_2: u8 = 0x6C;
pub(crate) const USER_CTRL: u8 = 0x6A;
pub(crate) const SMPLRT_DIV: u8 = 0x19;
pub(crate) const CONFIG: u8 = 0x1A;
pub(crate) const GYRO_CONFIG: u8 = 0x1B;
pub(crate) const ACCEL_CONFIG: u8 = 0x1C;
pub(crate) const INT_PIN_CFG: u8 = 0x37;
pub(crate) const INT_ENABLE: u8 = 0x38;
pub(crate) const ACCEL_XOUT_H: u8 = 0x3B;
pub(crate) const CLOCK_SOURCE_PLL_X_GYRO: u8 = 0x01;
pub(crate) const DEVICE_RESET: u8 = 0x80;
pub(crate) const I2C_INTERFACE_DISABLE: u8 = 0x10;
pub(crate) const ENABLE_ALL_SENSOR_AXES: u8 = 0x00;
pub(crate) const INTERRUPT_CLEAR_ON_ANY_READ: u8 = 0x10;
pub(crate) const DATA_READY_INTERRUPT: u8 = 0x01;
pub(crate) const SAMPLE_BYTES: usize = 14;
@@ -0,0 +1,43 @@
//! MPU6000 burst-sample decoding.
use kaptan_sensor::imu::ImuSnapshot;
use kaptan_time::Timestamp;
use super::{config::Mpu6000Config, registers::SAMPLE_BYTES};
pub(crate) fn decode(
bytes: &[u8; SAMPLE_BYTES],
timestamp: Timestamp,
config: Mpu6000Config,
) -> ImuSnapshot {
ImuSnapshot::new(
timestamp,
[
scale_acceleration(read_i16(bytes, 0), config),
scale_acceleration(read_i16(bytes, 2), config),
scale_acceleration(read_i16(bytes, 4), config),
],
[
scale_angular_rate(read_i16(bytes, 8), config),
scale_angular_rate(read_i16(bytes, 10), config),
scale_angular_rate(read_i16(bytes, 12), config),
],
scale_temperature(read_i16(bytes, 6)),
)
}
const fn read_i16(bytes: &[u8; SAMPLE_BYTES], offset: usize) -> i16 {
i16::from_be_bytes([bytes[offset], bytes[offset + 1]])
}
const fn scale_acceleration(raw: i16, config: Mpu6000Config) -> i16 {
((raw as i32 * 1_000) / config.accelerometer.counts_per_g()) as i16
}
const fn scale_angular_rate(raw: i16, config: Mpu6000Config) -> i16 {
((raw as i32 * 17_453) / config.gyroscope.milliradians_per_second_divisor()) as i16
}
const fn scale_temperature(raw: i16) -> i16 {
((raw as i32 * 100) / 340 + 3_653) as i16
}
@@ -0,0 +1,157 @@
//! Timestamped MPU6000 acquisition service.
use super::{Error, Mpu6000, Mpu6000Event};
use kaptan_sensor::imu::ImuSnapshot;
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::MonotonicClock;
/// One observable result from an MPU6000 acquisition service.
#[derive(Debug)]
pub enum Mpu6000ServiceEvent<BusError> {
/// No accepted sample or terminal fault was produced.
Idle,
/// MPU configuration completed and the DRDY route may be enabled.
Initialized,
/// A coherent IMU observation is ready for publication or control.
Sample(ImuSnapshot),
/// The MPU6000 path entered a terminal fault state.
Fault(Error<BusError>),
}
/// Owns MPU6000 state, its clock, and sample acceptance.
///
/// Exactly one platform worker invokes this service. Hardware ISRs only report
/// deferred DRDY, timer, and SPI-DMA terminal events to that worker.
pub struct Mpu6000Service<Clock, Bus> {
clock: Clock,
imu: Mpu6000<Bus>,
}
impl<Clock, Bus> Mpu6000Service<Clock, Bus> {
/// Creates an inactive MPU6000 acquisition service.
pub const fn new(clock: Clock, imu: Mpu6000<Bus>) -> Self {
Self { clock, imu }
}
}
impl<Clock, Bus> Mpu6000Service<Clock, Bus>
where
Clock: MonotonicClock,
Bus: SpiDevice,
{
/// Submits the asynchronous sensor reset transaction.
pub fn begin(&mut self) -> Result<(), Error<Bus::StartError>> {
self.imu.begin_initialization()
}
/// Advances the reset-settling state on a board periodic event.
pub fn on_periodic_tick(&mut self) -> Mpu6000ServiceEvent<Bus::StartError> {
if self.imu.is_ready() {
return match self.imu.request_sample(self.clock.now()) {
Ok(()) | Err(Error::NotReady) => Mpu6000ServiceEvent::Idle,
Err(error) => Mpu6000ServiceEvent::Fault(error),
};
}
let _ = self.clock.now();
match self.imu.advance_initialization() {
Ok(()) => Mpu6000ServiceEvent::Idle,
Err(error) => Mpu6000ServiceEvent::Fault(error),
}
}
/// Handles one coalesced data-ready event.
pub fn on_data_ready(&mut self) -> Mpu6000ServiceEvent<Bus::StartError> {
if !self.imu.is_ready() {
return Mpu6000ServiceEvent::Idle;
}
match self.imu.request_sample(self.clock.now()) {
Ok(()) | Err(Error::NotReady) => Mpu6000ServiceEvent::Idle,
Err(error) => Mpu6000ServiceEvent::Fault(error),
}
}
/// Advances the MPU6000 transaction state from a DMA terminal result.
pub fn on_transfer_complete(
&mut self,
completion: SpiCompletion,
) -> Mpu6000ServiceEvent<Bus::StartError> {
match self.imu.on_transfer_complete(completion) {
Mpu6000Event::NotPending | Mpu6000Event::Initializing => Mpu6000ServiceEvent::Idle,
Mpu6000Event::Initialized => Mpu6000ServiceEvent::Initialized,
Mpu6000Event::Sample(sample) => Mpu6000ServiceEvent::Sample(sample),
Mpu6000Event::Fault(error) => Mpu6000ServiceEvent::Fault(error),
}
}
}
#[cfg(test)]
mod tests {
use super::{Mpu6000Service, Mpu6000ServiceEvent};
use crate::mpu6000::{Mpu6000, Mpu6000Config};
use kaptan_spi::{SpiCompletion, SpiDevice};
use kaptan_time::{MonotonicClock, Timestamp};
struct Clock;
impl MonotonicClock for Clock {
fn now(&mut self) -> Timestamp {
Timestamp::from_microseconds(42)
}
}
struct Bus {
response: [u8; 15],
}
impl SpiDevice for Bus {
type StartError = ();
fn start_transfer(&mut self, _: &[u8], receive: &mut [u8]) -> Result<(), Self::StartError> {
receive.copy_from_slice(&self.response[..receive.len()]);
Ok(())
}
fn complete_transfer(&mut self, _: SpiCompletion) {}
}
#[test]
fn accepts_a_sample_only_after_initialization() {
let mut response = [0; 15];
response[1] = 0x68;
let mut service = Mpu6000Service::new(
Clock,
Mpu6000::new(Bus { response }, Mpu6000Config::default()),
);
service.begin().unwrap();
assert!(matches!(service.on_data_ready(), Mpu6000ServiceEvent::Idle));
assert!(matches!(
service.on_transfer_complete(SpiCompletion::Complete),
Mpu6000ServiceEvent::Idle
));
assert!(matches!(
service.on_periodic_tick(),
Mpu6000ServiceEvent::Idle
));
for _ in 0..9 {
assert!(matches!(
service.on_transfer_complete(SpiCompletion::Complete),
Mpu6000ServiceEvent::Idle
));
}
assert!(matches!(
service.on_transfer_complete(SpiCompletion::Complete),
Mpu6000ServiceEvent::Initialized
));
assert!(matches!(
service.on_periodic_tick(),
Mpu6000ServiceEvent::Idle
));
assert!(matches!(
service.on_transfer_complete(SpiCompletion::Complete),
Mpu6000ServiceEvent::Sample(_)
));
}
}
@@ -0,0 +1,471 @@
//! MicoAir MTF-01 Microlink UART frame decoder.
//!
//! Device documentation belongs beside this module.
//!
//! The MTF-01 continuously transmits fixed-size Microlink range-sensor frames.
//! Feed each received UART byte to [`mtf01::Mtf01::receive_byte`]. The parser is
//! allocation-free, does not use packed structs, and resynchronizes on a new
//! frame header after malformed input.
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use kaptan_time::{MonotonicClock, Timestamp};
const FRAME_HEADER: u8 = 0xEF;
const RANGE_SENSOR_MESSAGE_ID: u8 = 0x51;
const PAYLOAD_LENGTH: usize = 20;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum State {
Header,
DeviceId,
SystemId,
MessageId,
Sequence,
Length,
Payload { index: usize },
Checksum,
}
/// One complete MTF-01 range and optical-flow report.
///
/// This preserves the MTF-01's device-specific fields. Use
/// [`Self::range_snapshot`] or [`Self::optical_flow_snapshot`] to obtain a
/// device-independent observation for the rest of the flight stack.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Mtf01Report {
/// Flight-controller monotonic time at frame reception.
pub timestamp: Timestamp,
/// MTF-01 system time, in milliseconds.
pub sensor_time_milliseconds: u32,
/// Measured distance in millimetres. Zero means unavailable.
pub distance_millimetres: u32,
/// MTF-01 range-signal strength, from 0 through 255.
pub distance_strength: u8,
/// MTF-01 distance precision; smaller values indicate higher accuracy.
pub distance_precision: u8,
/// Whether the MTF-01 marked the distance measurement valid.
pub distance_valid: bool,
/// Optical-flow velocity along the MTF-01 X axis, in centimetres/second
/// at one metre above ground.
pub flow_velocity_x_centimetres_per_second_at_one_metre: i16,
/// Optical-flow velocity along the MTF-01 Y axis, in centimetres/second
/// at one metre above ground.
pub flow_velocity_y_centimetres_per_second_at_one_metre: i16,
/// MTF-01 optical-flow quality, from 0 through 255.
pub flow_quality: u8,
/// Whether the MTF-01 marked the optical-flow measurement valid.
pub flow_valid: bool,
}
impl Mtf01Report {
/// Creates an MTF-01 observation from one validated Microlink frame.
#[allow(clippy::too_many_arguments)]
pub const fn new(
timestamp: Timestamp,
sensor_time_milliseconds: u32,
distance_millimetres: u32,
distance_strength: u8,
distance_precision: u8,
distance_valid: bool,
flow_velocity_x_centimetres_per_second_at_one_metre: i16,
flow_velocity_y_centimetres_per_second_at_one_metre: i16,
flow_quality: u8,
flow_valid: bool,
) -> Self {
Self {
timestamp,
sensor_time_milliseconds,
distance_millimetres,
distance_strength,
distance_precision,
distance_valid,
flow_velocity_x_centimetres_per_second_at_one_metre,
flow_velocity_y_centimetres_per_second_at_one_metre,
flow_quality,
flow_valid,
}
}
/// Returns the valid range portion of this report.
pub const fn range_snapshot(self) -> Option<RangeSnapshot> {
if self.distance_valid && self.distance_millimetres != 0 {
Some(RangeSnapshot::new(
self.timestamp,
self.distance_millimetres,
))
} else {
None
}
}
/// Returns the valid, ground-referenced optical-flow portion of this
/// report. MTF-01 velocities are specified in centimetres per second at
/// one metre; the device documentation scales them by measured height.
pub fn optical_flow_snapshot(self) -> Option<OpticalFlowSnapshot> {
if !self.distance_valid || self.distance_millimetres == 0 || !self.flow_valid {
return None;
}
let velocity_x = i64::from(self.flow_velocity_x_centimetres_per_second_at_one_metre)
* i64::from(self.distance_millimetres)
/ 100;
let velocity_y = i64::from(self.flow_velocity_y_centimetres_per_second_at_one_metre)
* i64::from(self.distance_millimetres)
/ 100;
if velocity_x < i64::from(i32::MIN)
|| velocity_x > i64::from(i32::MAX)
|| velocity_y < i64::from(i32::MIN)
|| velocity_y > i64::from(i32::MAX)
{
return None;
}
Some(OpticalFlowSnapshot::new(
self.timestamp,
[velocity_x as i32, velocity_y as i32],
self.distance_millimetres,
self.flow_quality,
))
}
}
/// MTF-01 Microlink decoder.
///
/// One decoder is owned by one UART receive path. It accepts the MTF-01's
/// documented range-sensor message ID and 20-byte payload length. Device and
/// system IDs are deliberately accepted as transmitted, matching MicoAir's
/// reference decoder and allowing sensor IDs configured outside factory
/// defaults.
pub struct Mtf01 {
state: State,
checksum: u8,
payload: [u8; PAYLOAD_LENGTH],
}
impl Mtf01 {
/// Creates a decoder awaiting the `0xEF` Microlink frame header.
pub const fn new() -> Self {
Self {
state: State::Header,
checksum: 0,
payload: [0; PAYLOAD_LENGTH],
}
}
/// Accepts one UART byte and returns a complete, checksum-validated MTF-01
/// report when available.
///
/// `timestamp` should be the controller monotonic time at receipt of this
/// byte. It is used only for a completed frame, so it records the arrival
/// time of the report rather than an arbitrary intermediate byte.
pub fn receive_byte(&mut self, timestamp: Timestamp, byte: u8) -> Option<Mtf01Report> {
match self.state {
State::Header => {
if byte == FRAME_HEADER {
self.checksum = byte;
self.state = State::DeviceId;
}
}
State::DeviceId => {
self.accept(byte, State::SystemId);
}
State::SystemId => {
self.accept(byte, State::MessageId);
}
State::MessageId => {
if byte == RANGE_SENSOR_MESSAGE_ID {
self.accept(byte, State::Sequence);
} else {
self.restart(byte);
}
}
State::Sequence => self.accept(byte, State::Length),
State::Length => {
if usize::from(byte) == PAYLOAD_LENGTH {
self.accept(byte, State::Payload { index: 0 });
} else {
self.restart(byte);
}
}
State::Payload { index } => {
self.payload[index] = byte;
self.checksum = self.checksum.wrapping_add(byte);
self.state = if index + 1 == PAYLOAD_LENGTH {
State::Checksum
} else {
State::Payload { index: index + 1 }
};
}
State::Checksum => {
let valid = byte == self.checksum;
// The checksum terminates the frame even when its value is
// also the Microlink header. The reference parser returns to
// its initial state here rather than treating checksum data as
// the start of the next frame.
self.reset();
if valid {
return Some(self.snapshot(timestamp));
}
}
}
None
}
/// Drops any partial frame and awaits a new header.
pub fn reset(&mut self) {
self.state = State::Header;
self.checksum = 0;
}
fn accept(&mut self, byte: u8, next: State) {
self.checksum = self.checksum.wrapping_add(byte);
self.state = next;
}
fn restart(&mut self, byte: u8) {
if byte == FRAME_HEADER {
self.checksum = FRAME_HEADER;
self.state = State::DeviceId;
} else {
self.reset();
}
}
fn snapshot(&self, timestamp: Timestamp) -> Mtf01Report {
Mtf01Report::new(
timestamp,
u32::from_le_bytes(self.payload[0..4].try_into().unwrap()),
u32::from_le_bytes(self.payload[4..8].try_into().unwrap()),
self.payload[8],
self.payload[9],
self.payload[10] == 1,
i16::from_le_bytes(self.payload[12..14].try_into().unwrap()),
i16::from_le_bytes(self.payload[14..16].try_into().unwrap()),
self.payload[16],
self.payload[17] == 1,
)
}
}
impl Default for Mtf01 {
fn default() -> Self {
Self::new()
}
}
/// Timestamping MTF-01 UART receive service.
///
/// Feed this service from the selected UART receive interrupt or DMA consumer.
/// The board retains ownership of pin muxing, buffering, and interrupt
/// acknowledgement.
pub struct Mtf01Service<Clock> {
clock: Clock,
decoder: Mtf01,
}
impl<Clock> Mtf01Service<Clock> {
/// Creates an MTF-01 receive service awaiting a Microlink frame header.
pub const fn new(clock: Clock) -> Self {
Self {
clock,
decoder: Mtf01::new(),
}
}
/// Discards a partial frame after a UART transport fault.
pub fn reset(&mut self) {
self.decoder.reset();
}
}
impl<Clock: MonotonicClock> Mtf01Service<Clock> {
/// Consumes one UART byte and returns one validated MTF-01 report.
pub fn on_received_byte(&mut self, byte: u8) -> Option<Mtf01Report> {
self.decoder.receive_byte(self.clock.now(), byte)
}
}
#[cfg(test)]
mod tests {
use super::{Mtf01, Mtf01Report, Mtf01Service};
use kaptan_time::{MonotonicClock, Timestamp};
fn frame(payload: [u8; 20]) -> [u8; 27] {
let mut frame = [0_u8; 27];
frame[..6].copy_from_slice(&[0xEF, 0x0F, 0x00, 0x51, 0xA5, 20]);
frame[6..26].copy_from_slice(&payload);
frame[26] = frame[..26]
.iter()
.fold(0_u8, |checksum, byte| checksum.wrapping_add(*byte));
frame
}
fn sample_frame() -> [u8; 27] {
frame([
0x78, 0x56, 0x34, 0x12, // sensor time: 305419896 ms
0xD2, 0x04, 0x00, 0x00, // distance: 1234 mm
200, 7, 1, 0, // range metadata and reserved byte
0x2E, 0xFB, // flow x: -1234
0xD2, 0x04, // flow y: 1234
250, 1, 0, 0, // flow metadata and reserved u16
])
}
#[test]
fn decodes_a_valid_little_endian_range_and_flow_report() {
let mut decoder = Mtf01::new();
let timestamp = Timestamp::from_microseconds(9_876);
let mut report = None;
for byte in sample_frame() {
report = decoder.receive_byte(timestamp, byte).or(report);
}
assert_eq!(
report,
Some(Mtf01Report::new(
timestamp,
0x1234_5678,
1_234,
200,
7,
true,
-1_234,
1_234,
250,
true,
))
);
}
#[test]
fn rejects_a_bad_checksum_without_publishing_a_report() {
let mut decoder = Mtf01::new();
let mut corrupt = sample_frame();
corrupt[26] ^= 0xFF;
assert!(corrupt.into_iter().all(|byte| {
decoder
.receive_byte(Timestamp::from_microseconds(1), byte)
.is_none()
}));
}
#[test]
fn treats_a_header_valued_checksum_as_a_checksum() {
let mut decoder = Mtf01::new();
let mut payload = [0_u8; 20];
// The fixed header through length sums to 0x08, making 0xE7 produce
// the `0xEF` checksum value that otherwise resembles a new header.
payload[0] = 0xE7;
let checksum_is_header = frame(payload);
assert_eq!(checksum_is_header[26], 0xEF);
let first_report = checksum_is_header
.into_iter()
.find_map(|byte| decoder.receive_byte(Timestamp::from_microseconds(1), byte));
assert!(first_report.is_some());
let report = sample_frame()
.into_iter()
.find_map(|byte| decoder.receive_byte(Timestamp::from_microseconds(2), byte));
assert_eq!(report.unwrap().distance_millimetres, 1_234);
}
#[test]
fn resynchronizes_when_a_header_arrives_during_a_bad_frame() {
let mut decoder = Mtf01::new();
let valid = sample_frame();
let mut bytes = [0_u8; 30];
bytes[..3].copy_from_slice(&[0xEF, 0x0F, 0xEF]);
bytes[3..].copy_from_slice(&valid);
let mut report = None;
for byte in bytes {
report = decoder
.receive_byte(Timestamp::from_microseconds(7), byte)
.or(report);
}
assert_eq!(report.unwrap().distance_millimetres, 1_234);
}
#[test]
fn does_not_accept_another_micolink_message_type() {
let mut decoder = Mtf01::new();
let mut other_message = sample_frame();
other_message[3] = 0x52;
other_message[26] = other_message[..26]
.iter()
.fold(0_u8, |checksum, byte| checksum.wrapping_add(*byte));
assert!(other_message.into_iter().all(|byte| {
decoder
.receive_byte(Timestamp::from_microseconds(1), byte)
.is_none()
}));
}
#[test]
fn accepts_configured_micolink_device_and_system_ids() {
let mut decoder = Mtf01::new();
let mut configured_ids = sample_frame();
configured_ids[1] = 0x42;
configured_ids[2] = 0x99;
configured_ids[26] = configured_ids[..26]
.iter()
.fold(0_u8, |checksum, byte| checksum.wrapping_add(*byte));
let report = configured_ids
.into_iter()
.find_map(|byte| decoder.receive_byte(Timestamp::from_microseconds(1), byte));
assert_eq!(report.unwrap().distance_millimetres, 1_234);
}
#[test]
fn exposes_only_valid_generic_observations() {
let mut report = Mtf01Report::new(
Timestamp::from_microseconds(123),
7,
2_000,
8,
9,
true,
50,
-25,
12,
true,
);
assert_eq!(report.range_snapshot().unwrap().distance_millimetres, 2_000);
assert_eq!(
report
.optical_flow_snapshot()
.unwrap()
.velocity_millimetres_per_second,
[1_000, -500]
);
report.distance_valid = false;
assert_eq!(report.range_snapshot(), None);
assert_eq!(report.optical_flow_snapshot(), None);
}
struct Clock(u64);
impl MonotonicClock for Clock {
fn now(&mut self) -> Timestamp {
self.0 += 1;
Timestamp::from_microseconds(self.0)
}
}
#[test]
fn timestamps_a_report_with_its_last_received_byte() {
let mut service = Mtf01Service::new(Clock(100));
let report = sample_frame()
.into_iter()
.find_map(|byte| service.on_received_byte(byte))
.unwrap();
assert_eq!(report.timestamp, Timestamp::from_microseconds(127));
}
}
@@ -0,0 +1,14 @@
[package]
name = "kaptan-flight-config"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-config = { path = "../kaptan-config" }
kaptan-log = { path = "../kaptan-log" }
minicbor = { version = "0.25", features = ["derive"] }
@@ -0,0 +1,378 @@
//! Bounded CBOR import/export for one explicit current record.
use kaptan_config::{
ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind,
};
use kaptan_log::KaptanError;
use minicbor::{Decoder, Encoder, encode::Write};
use crate::{CandidateProfile, FORMAT_VERSION, SCHEMA_ID, VehicleCapabilities};
/// Maximum complete candidate snapshot accepted by the current vehicle schema.
pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096;
const FORMAT_KEY: u16 = 0;
const SCHEMA_KEY: u16 = u16::MAX;
/// CBOR import failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum CborError {
/// The supplied payload exceeded the bounded configuration storage budget.
TooLarge,
/// CBOR was malformed or did not match a supported versioned wire record.
Malformed,
/// A second CBOR item followed the candidate snapshot.
TrailingData,
/// The decoded candidate violated complete schema/capability validation.
InvalidProfile(ConfigViolation),
}
/// Failure while encoding one complete candidate snapshot.
///
/// The candidate is validated before the first CBOR item is written, so a
/// caller can never export a locally valid but relationally invalid edit.
#[derive(Debug)]
pub enum CborEncodeError<E> {
/// The complete candidate did not validate against immutable board
/// capabilities.
InvalidProfile(ConfigViolation),
/// The caller-owned CBOR writer rejected the encoded snapshot.
Encode(minicbor::encode::Error<E>),
}
/// Decodes exactly one bounded current record and validates it against immutable
/// board capabilities before returning a candidate to its caller.
pub fn decode_candidate(
bytes: &[u8],
capabilities: &VehicleCapabilities,
) -> Result<CandidateProfile, CborError> {
if bytes.len() > MAXIMUM_CANDIDATE_BYTES {
return Err(CborError::TooLarge);
}
let candidate = decode_current(bytes)?;
candidate
.validate(capabilities)
.map_err(CborError::InvalidProfile)?;
Ok(candidate)
}
/// Validates and encodes one complete current snapshot to a caller-owned CBOR
/// writer.
///
/// Validation happens before creating the encoder, ensuring a failed
/// cross-field or capability check emits no partial CBOR record.
pub fn encode_candidate<W>(
candidate: CandidateProfile,
capabilities: &VehicleCapabilities,
writer: W,
) -> Result<(), CborEncodeError<W::Error>>
where
W: Write,
{
candidate
.validate(capabilities)
.map_err(CborEncodeError::InvalidProfile)?;
let mut encoder = Encoder::new(writer);
encoder
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
.map_err(CborEncodeError::Encode)?;
encoder
.u16(FORMAT_KEY)
.and_then(|encoder| encoder.u16(FORMAT_VERSION))
.map_err(CborEncodeError::Encode)?;
encoder
.u16(SCHEMA_KEY)
.and_then(|encoder| encoder.bytes(&SCHEMA_ID))
.map_err(CborEncodeError::Encode)?;
for descriptor in CandidateProfile::descriptors() {
encoder
.u16(descriptor.id.get())
.map_err(CborEncodeError::Encode)?;
encode_value(
&mut encoder,
candidate
.field_value(descriptor.id)
.expect("descriptor field"),
)
.map_err(CborEncodeError::Encode)?;
}
Ok(())
}
fn decode_current(bytes: &[u8]) -> Result<CandidateProfile, CborError> {
let mut decoder = Decoder::new(bytes);
let entry_count = CandidateProfile::FIELD_COUNT as u64 + 2;
if decoder.map().map_err(|_| CborError::Malformed)? != Some(entry_count) {
return Err(CborError::Malformed);
}
let mut format_version = None;
let mut schema_id = None;
let mut values = [None; CandidateProfile::FIELD_COUNT];
for _ in 0..entry_count {
let key = decoder.u16().map_err(|_| CborError::Malformed)?;
match key {
FORMAT_KEY => {
if format_version
.replace(decoder.u16().map_err(|_| CborError::Malformed)?)
.is_some()
{
return Err(CborError::Malformed);
}
}
SCHEMA_KEY => {
let bytes = decoder.bytes().map_err(|_| CborError::Malformed)?;
let value: [u8; 16] = bytes.try_into().map_err(|_| CborError::Malformed)?;
if schema_id.replace(value).is_some() {
return Err(CborError::Malformed);
}
}
_ => {
let index = CandidateProfile::descriptors()
.iter()
.position(|descriptor| descriptor.id.get() == key)
.ok_or(CborError::Malformed)?;
if values[index].is_some() {
return Err(CborError::Malformed);
}
values[index] = Some(decode_value(
&mut decoder,
CandidateProfile::descriptors()[index].kind,
)?);
}
}
}
if decoder.position() != bytes.len() {
return Err(CborError::TrailingData);
}
if format_version != Some(FORMAT_VERSION) || schema_id != Some(SCHEMA_ID) {
return Err(CborError::InvalidProfile(ConfigViolation::global(
ValidationRule::UnsupportedFormat,
)));
}
if values.iter().any(Option::is_none) {
return Err(CborError::Malformed);
}
let mut candidate = CandidateProfile::default();
for (index, value) in values.into_iter().enumerate() {
let descriptor = CandidateProfile::descriptors()
.get(index)
.ok_or(CborError::Malformed)?;
candidate
.set_field(descriptor.id, value.ok_or(CborError::Malformed)?)
.map_err(field_write_error)?;
}
Ok(candidate)
}
fn encode_value<W>(
encoder: &mut Encoder<W>,
value: ConfigValue,
) -> Result<(), minicbor::encode::Error<W::Error>>
where
W: Write,
{
match value {
ConfigValue::Bool(value) => encoder.bool(value)?,
ConfigValue::I32(value) => encoder.i32(value)?,
ConfigValue::U32(value) => encoder.u32(value)?,
ConfigValue::U64(value) => encoder.u64(value)?,
ConfigValue::F32(value) => encoder.f32(value)?,
ConfigValue::F64(value) => encoder.f64(value)?,
};
Ok(())
}
fn decode_value(decoder: &mut Decoder<'_>, kind: ValueKind) -> Result<ConfigValue, CborError> {
match kind {
ValueKind::Bool => decoder.bool().map(ConfigValue::Bool),
ValueKind::I32 => decoder.i32().map(ConfigValue::I32),
ValueKind::U32 => decoder.u32().map(ConfigValue::U32),
ValueKind::U64 => decoder.u64().map(ConfigValue::U64),
ValueKind::F32 => decoder.f32().map(ConfigValue::F32),
ValueKind::F64 => decoder.f64().map(ConfigValue::F64),
}
.map_err(|_| CborError::Malformed)
}
fn field_write_error(error: FieldWriteError) -> CborError {
match error {
FieldWriteError::Violation(issue) => CborError::InvalidProfile(issue),
FieldWriteError::UnknownField(_) | FieldWriteError::WrongType { .. } => {
CborError::Malformed
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_and_trailing_rejection_are_exact() {
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let capabilities = VehicleCapabilities::quad_x();
encode_candidate(CandidateProfile::default(), &capabilities, &mut writer).unwrap();
let length = writer.position();
let bytes = writer.into_inner();
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
let mut trailing = bytes;
trailing[length] = 0xf6;
assert!(matches!(
decode_candidate(&trailing[..=length], &capabilities),
Err(CborError::TrailingData)
));
}
#[test]
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
let capabilities = VehicleCapabilities::quad_x();
let mut candidate = CandidateProfile::default();
candidate
.set_field(kaptan_config::FieldId::new(2), ConfigValue::U64(50_001))
.expect("the scalar is locally valid");
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
assert!(matches!(
encode_candidate(candidate, &capabilities, &mut writer),
Err(CborEncodeError::InvalidProfile(violation))
if violation.rule() == ValidationRule::Inconsistent
));
assert_eq!(writer.position(), 0);
}
#[test]
fn rejects_wrong_format_and_schema_before_candidate_conversion() {
let capabilities = VehicleCapabilities::quad_x();
let (bytes, length) = encoded_test_record(FORMAT_VERSION + 1, SCHEMA_ID, |key| key);
assert!(matches!(
decode_candidate(&bytes[..length], &capabilities),
Err(CborError::InvalidProfile(issue))
if issue.rule() == ValidationRule::UnsupportedFormat
));
let (bytes, length) = encoded_test_record(FORMAT_VERSION, [0; 16], |key| key);
assert!(matches!(
decode_candidate(&bytes[..length], &capabilities),
Err(CborError::InvalidProfile(issue))
if issue.rule() == kaptan_config::ValidationRule::UnsupportedFormat
));
}
#[test]
fn exact_map_rejects_unknown_duplicate_and_missing_fields() {
let capabilities = VehicleCapabilities::quad_x();
let (bytes, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| key);
assert!(decode_candidate(&bytes[..length], &capabilities).is_ok());
let (unknown, length) =
encoded_test_record(
FORMAT_VERSION,
SCHEMA_ID,
|key| {
if key == 1 { 25 } else { key }
},
);
assert!(matches!(
decode_candidate(&unknown[..length], &capabilities),
Err(CborError::Malformed)
));
let (duplicate, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| {
if key == 1 { FORMAT_KEY } else { key }
});
assert!(matches!(
decode_candidate(&duplicate[..length], &capabilities),
Err(CborError::Malformed)
));
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let mut encoder = Encoder::new(&mut writer);
encoder
.map(CandidateProfile::FIELD_COUNT as u64 + 1)
.unwrap();
encoder
.u16(FORMAT_KEY)
.unwrap()
.u16(FORMAT_VERSION)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors().iter().skip(1) {
encoder.u16(descriptor.id.get()).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
}
let length = writer.position();
let missing = writer.into_inner();
assert!(matches!(
decode_candidate(&missing[..length], &capabilities),
Err(CborError::Malformed)
));
}
#[test]
fn cbor_scalar_write_preserves_the_generated_field_and_rule() {
let capabilities = VehicleCapabilities::quad_x();
let mass = CandidateProfile::descriptors()
.iter()
.find(|descriptor| descriptor.display_key == "thrust.mass_kg")
.expect("declared mass descriptor");
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let mut encoder = Encoder::new(&mut writer);
encoder
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
.unwrap();
encoder
.u16(FORMAT_KEY)
.unwrap()
.u16(FORMAT_VERSION)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap();
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors() {
encoder.u16(descriptor.id.get()).unwrap();
let value = if descriptor.id == mass.id {
ConfigValue::F32(0.009)
} else {
candidate.field_value(descriptor.id).unwrap()
};
encode_value(&mut encoder, value).unwrap();
}
let length = writer.position();
let bytes = writer.into_inner();
assert!(matches!(
decode_candidate(&bytes[..length], &capabilities),
Err(CborError::InvalidProfile(issue))
if issue.primary() == Some(mass.id)
&& issue.related_field().is_none()
&& issue.rule() == ValidationRule::BelowMinimum
));
}
fn encoded_test_record<F>(
format_version: u16,
schema_id: [u8; 16],
mut remap_key: F,
) -> ([u8; MAXIMUM_CANDIDATE_BYTES], usize)
where
F: FnMut(u16) -> u16,
{
let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]);
let mut encoder = Encoder::new(&mut writer);
encoder
.map(CandidateProfile::FIELD_COUNT as u64 + 2)
.unwrap();
encoder
.u16(FORMAT_KEY)
.unwrap()
.u16(format_version)
.unwrap();
encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap();
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors() {
encoder.u16(remap_key(descriptor.id.get())).unwrap();
encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap();
}
let length = writer.position();
(writer.into_inner(), length)
}
}
@@ -0,0 +1,20 @@
//! Vehicle configuration schema and CBOR snapshot codec.
#![no_std]
pub mod cbor;
pub mod profile;
pub use cbor::{
CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, decode_candidate, encode_candidate,
};
pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile};
/// Exact identifier of the one unreleased Quad-X profile schema.
///
/// This is an identifier, not a cryptographic fingerprint. A GCS includes it
/// with an import so a profile prepared for another schema cannot be applied.
pub const SCHEMA_ID: [u8; 16] = *b"KAPTANPROFILE-01";
#[cfg(test)]
extern crate std;
@@ -0,0 +1,237 @@
//! Immutable configuration projections consumed by runtime owners.
/// Immutable compiled contract for the physical vehicle/board image.
///
/// A runtime candidate can tune only values inside this contract. It cannot
/// create pins, buses, sensor drivers, or actuator channels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VehicleCapabilities {
/// Number of board-declared logical actuator channels available to the
/// selected vehicle model.
actuator_count: u8,
/// Reviewed physical allocation for this board/airframe image.
///
/// This is capability data rather than a persisted setting: profile edits
/// must never alter motor order, thrust signs, or control authority.
mixer: [[f32; 4]; 4],
}
impl VehicleCapabilities {
/// Returns the reviewed four-actuator Quad-X allocation supported by P1.
///
/// New allocation layouts require a dedicated board-bound airframe review
/// and constructor, rather than accepting an arbitrary coefficient array
/// through a configuration or capability initializer.
pub const fn quad_x() -> Self {
Self {
actuator_count: 4,
mixer: [
[-1.0, 1.0, 1.0, 1.0],
[-1.0, -1.0, -1.0, 1.0],
[1.0, -1.0, 1.0, 1.0],
[1.0, 1.0, -1.0, 1.0],
],
}
}
pub(crate) const fn actuator_count(self) -> u8 {
self.actuator_count
}
pub(crate) const fn mixer(self) -> [[f32; 4]; 4] {
self.mixer
}
}
/// Active prearm timing policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SafetySettings {
pub(crate) prearm_dwell_microseconds: u64,
}
impl SafetySettings {
pub const fn prearm_dwell_microseconds(self) -> u64 {
self.prearm_dwell_microseconds
}
}
/// Active stream timing policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TimingSettings {
pub(crate) maximum_imu_interval_microseconds: u64,
pub(crate) imu_freshness_microseconds: u64,
pub(crate) local_state_freshness_microseconds: u64,
pub(crate) command_link_freshness_microseconds: u64,
}
impl TimingSettings {
pub const fn maximum_imu_interval_microseconds(self) -> u64 {
self.maximum_imu_interval_microseconds
}
pub const fn imu_freshness_microseconds(self) -> u64 {
self.imu_freshness_microseconds
}
pub const fn local_state_freshness_microseconds(self) -> u64 {
self.local_state_freshness_microseconds
}
pub const fn command_link_freshness_microseconds(self) -> u64 {
self.command_link_freshness_microseconds
}
}
/// Active local-aiding uncertainty policy.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AidingSettings {
pub(crate) position_variance_m2: f32,
pub(crate) velocity_variance_m2_per_second_squared: f32,
}
impl AidingSettings {
pub const fn position_variance_m2(self) -> f32 {
self.position_variance_m2
}
pub const fn velocity_variance_m2_per_second_squared(self) -> f32 {
self.velocity_variance_m2_per_second_squared
}
}
/// Active outer-loop controller policy.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ControlSettings {
pub(crate) position_gain_per_second_squared: f32,
pub(crate) velocity_gain_per_second: f32,
pub(crate) maximum_horizontal_acceleration_mps2: f32,
pub(crate) maximum_upward_acceleration_mps2: f32,
pub(crate) maximum_downward_acceleration_mps2: f32,
}
impl ControlSettings {
pub const fn position_gain_per_second_squared(self) -> f32 {
self.position_gain_per_second_squared
}
pub const fn velocity_gain_per_second(self) -> f32 {
self.velocity_gain_per_second
}
pub const fn maximum_horizontal_acceleration_mps2(self) -> f32 {
self.maximum_horizontal_acceleration_mps2
}
pub const fn maximum_upward_acceleration_mps2(self) -> f32 {
self.maximum_upward_acceleration_mps2
}
pub const fn maximum_downward_acceleration_mps2(self) -> f32 {
self.maximum_downward_acceleration_mps2
}
}
/// Active physical thrust envelope.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ThrustSettings {
pub(crate) mass_kg: f32,
pub(crate) maximum_collective_thrust_newtons: f32,
pub(crate) maximum_tilt_tangent: f32,
}
impl ThrustSettings {
pub const fn mass_kg(self) -> f32 {
self.mass_kg
}
pub const fn maximum_collective_thrust_newtons(self) -> f32 {
self.maximum_collective_thrust_newtons
}
pub const fn maximum_tilt_tangent(self) -> f32 {
self.maximum_tilt_tangent
}
}
/// Active scalar PID policy.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PidSettings {
pub(crate) proportional: f32,
pub(crate) integral: f32,
pub(crate) derivative: f32,
pub(crate) output_limit: f32,
pub(crate) integral_limit: f32,
}
impl PidSettings {
pub const fn proportional(self) -> f32 {
self.proportional
}
pub const fn integral(self) -> f32 {
self.integral
}
pub const fn derivative(self) -> f32 {
self.derivative
}
pub const fn output_limit(self) -> f32 {
self.output_limit
}
pub const fn integral_limit(self) -> f32 {
self.integral_limit
}
}
/// Active actuator command bounds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ActuatorLimits {
pub(crate) minimum: f32,
pub(crate) maximum: f32,
pub(crate) slew_per_second: f32,
}
impl ActuatorLimits {
pub const fn minimum(self) -> f32 {
self.minimum
}
pub const fn maximum(self) -> f32 {
self.maximum
}
pub const fn slew_per_second(self) -> f32 {
self.slew_per_second
}
}
/// Immutable complete configuration selected at boot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VehicleProfile {
pub(crate) safety: SafetySettings,
pub(crate) timing: TimingSettings,
pub(crate) aiding: AidingSettings,
pub(crate) control: ControlSettings,
pub(crate) thrust: ThrustSettings,
pub(crate) rate_pid: PidSettings,
pub(crate) actuator_limits: ActuatorLimits,
pub(crate) observability: super::ObservabilitySettings,
pub(crate) mixer: [[f32; 4]; 4],
}
impl VehicleProfile {
pub const fn safety(self) -> SafetySettings {
self.safety
}
pub const fn timing(self) -> TimingSettings {
self.timing
}
pub const fn aiding(self) -> AidingSettings {
self.aiding
}
pub const fn control(self) -> ControlSettings {
self.control
}
pub const fn thrust(self) -> ThrustSettings {
self.thrust
}
pub const fn rate_pid(self) -> PidSettings {
self.rate_pid
}
pub const fn actuator_limits(self) -> ActuatorLimits {
self.actuator_limits
}
/// Immutable boot-active best-effort diagnostic policy.
pub const fn observability(self) -> super::ObservabilitySettings {
self.observability
}
pub const fn mixer(self) -> [[f32; 4]; 4] {
self.mixer
}
}
@@ -0,0 +1,355 @@
//! Private vehicle candidate and whole-profile validation boundary.
use kaptan_config::{
ConfigAggregate, ConfigModel, ConfigValidate, ConfigValue, ConfigViolation, FieldDescriptor,
FieldId, FieldWriteError, ValidateFields, ValidationRule,
};
use super::{
ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings,
SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile,
control::{
ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate,
},
observability::ObservabilityCandidate,
registry,
safety::SafetyCandidate,
timing::TimingCandidate,
};
/// Current persisted CBOR schema for the vehicle candidate.
pub const FORMAT_VERSION: u16 = 1;
/// Complete editable vehicle candidate.
///
/// Fields are private outside this crate. Operators, CBOR import, and MAVLink
/// transport all use the same descriptor/mutation and full-validation path.
#[derive(Clone, Copy, ConfigValidate)]
pub struct CandidateProfile {
#[config(section)]
pub(super) safety: SafetyCandidate,
#[config(section)]
pub(super) timing: TimingCandidate,
#[config(section)]
pub(super) aiding: AidingCandidate,
#[config(section)]
pub(super) control: ControlCandidate,
#[config(section)]
pub(super) thrust: ThrustCandidate,
#[config(section)]
pub(super) rate_pid: PidCandidate,
#[config(section)]
pub(super) actuator_limits: ActuatorLimitsCandidate,
#[config(section)]
pub(super) observability: ObservabilityCandidate,
}
impl Default for CandidateProfile {
fn default() -> Self {
<Self as ConfigAggregate>::default_aggregate()
.expect("compiled configuration defaults must validate")
}
}
impl CandidateProfile {
/// Returns the complete transport-neutral descriptor registry.
pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] {
registry::descriptors()
}
fn from_active(active: &VehicleProfile) -> Self {
let safety = active.safety();
let timing = active.timing();
let aiding = active.aiding();
let control = active.control();
let thrust = active.thrust();
let rate_pid = active.rate_pid();
let actuator_limits = active.actuator_limits();
let observability = active.observability();
Self {
safety: SafetyCandidate {
prearm_dwell_microseconds: safety.prearm_dwell_microseconds(),
},
timing: TimingCandidate {
maximum_imu_interval_microseconds: timing.maximum_imu_interval_microseconds(),
imu_freshness_microseconds: timing.imu_freshness_microseconds(),
local_state_freshness_microseconds: timing.local_state_freshness_microseconds(),
command_link_freshness_microseconds: timing.command_link_freshness_microseconds(),
},
aiding: AidingCandidate {
position_variance_m2: aiding.position_variance_m2(),
velocity_variance_m2_per_second_squared: aiding
.velocity_variance_m2_per_second_squared(),
},
control: ControlCandidate {
position_gain_per_second_squared: control.position_gain_per_second_squared(),
velocity_gain_per_second: control.velocity_gain_per_second(),
maximum_horizontal_acceleration_mps2: control
.maximum_horizontal_acceleration_mps2(),
maximum_upward_acceleration_mps2: control.maximum_upward_acceleration_mps2(),
maximum_downward_acceleration_mps2: control.maximum_downward_acceleration_mps2(),
},
thrust: ThrustCandidate {
mass_kg: thrust.mass_kg(),
maximum_collective_thrust_newtons: thrust.maximum_collective_thrust_newtons(),
maximum_tilt_tangent: thrust.maximum_tilt_tangent(),
},
rate_pid: PidCandidate {
proportional: rate_pid.proportional(),
integral: rate_pid.integral(),
derivative: rate_pid.derivative(),
output_limit: rate_pid.output_limit(),
integral_limit: rate_pid.integral_limit(),
},
actuator_limits: ActuatorLimitsCandidate {
minimum: actuator_limits.minimum(),
maximum: actuator_limits.maximum(),
slew_per_second: actuator_limits.slew_per_second(),
},
observability: ObservabilityCandidate {
maximum_log_level: observability.maximum_log_level(),
},
}
}
fn validate_complete(
&self,
capabilities: &VehicleCapabilities,
) -> Result<VehicleProfile, ConfigViolation> {
self.validate_fields()?;
self.validate_capabilities(capabilities)?;
Ok(VehicleProfile {
safety: SafetySettings {
prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds,
},
timing: TimingSettings {
maximum_imu_interval_microseconds: self.timing.maximum_imu_interval_microseconds,
imu_freshness_microseconds: self.timing.imu_freshness_microseconds,
local_state_freshness_microseconds: self.timing.local_state_freshness_microseconds,
command_link_freshness_microseconds: self
.timing
.command_link_freshness_microseconds,
},
aiding: AidingSettings {
position_variance_m2: self.aiding.position_variance_m2,
velocity_variance_m2_per_second_squared: self
.aiding
.velocity_variance_m2_per_second_squared,
},
control: ControlSettings {
position_gain_per_second_squared: self.control.position_gain_per_second_squared,
velocity_gain_per_second: self.control.velocity_gain_per_second,
maximum_horizontal_acceleration_mps2: self
.control
.maximum_horizontal_acceleration_mps2,
maximum_upward_acceleration_mps2: self.control.maximum_upward_acceleration_mps2,
maximum_downward_acceleration_mps2: self.control.maximum_downward_acceleration_mps2,
},
thrust: ThrustSettings {
mass_kg: self.thrust.mass_kg,
maximum_collective_thrust_newtons: self.thrust.maximum_collective_thrust_newtons,
maximum_tilt_tangent: self.thrust.maximum_tilt_tangent,
},
rate_pid: PidSettings {
proportional: self.rate_pid.proportional,
integral: self.rate_pid.integral,
derivative: self.rate_pid.derivative,
output_limit: self.rate_pid.output_limit,
integral_limit: self.rate_pid.integral_limit,
},
actuator_limits: ActuatorLimits {
minimum: self.actuator_limits.minimum,
maximum: self.actuator_limits.maximum,
slew_per_second: self.actuator_limits.slew_per_second,
},
observability: ObservabilitySettings::new(self.observability.maximum_log_level),
mixer: capabilities.mixer(),
})
}
fn validate_capabilities(
&self,
capabilities: &VehicleCapabilities,
) -> Result<(), ConfigViolation> {
if capabilities.actuator_count() != 4 {
return Err(ConfigViolation::global(
ValidationRule::UnsupportedCapability,
));
}
Ok(())
}
}
impl ConfigModel for CandidateProfile {
type Active = VehicleProfile;
type Context = VehicleCapabilities;
const SCHEMA_ID: [u8; 16] = crate::SCHEMA_ID;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
registry::descriptor(index)
}
fn field_count() -> u16 {
u16::try_from(Self::FIELD_COUNT).expect("field registry exceeds the protocol limit")
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
<Self as ConfigAggregate>::field_value(self, id)
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
<Self as ConfigAggregate>::field_value(&Self::from_active(active), id)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
<Self as ConfigAggregate>::set_field(self, id, value)
}
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
self.validate_complete(context)
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule};
use super::{CandidateProfile, VehicleCapabilities};
use crate::profile::ids;
const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
fn descriptor_id(display_key: &str) -> FieldId {
CandidateProfile::descriptors()
.iter()
.find(|descriptor| descriptor.display_key == display_key)
.expect("declared field descriptor")
.id
}
#[test]
fn every_descriptor_has_a_readable_default_value() {
let candidate = CandidateProfile::default();
for descriptor in CandidateProfile::descriptors() {
assert_eq!(
candidate.field_value(descriptor.id),
Some(descriptor.default)
);
}
}
#[test]
fn aggregated_descriptor_ids_are_unique() {
for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() {
assert!(
CandidateProfile::descriptors()[index + 1..]
.iter()
.all(|other| other.id != descriptor.id)
);
}
}
#[test]
fn cross_field_invalid_edit_is_retained_only_in_the_candidate() {
let mut candidate = CandidateProfile::default();
candidate
.set_field(
ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS,
ConfigValue::U64(51_000),
)
.unwrap();
assert!(matches!(
candidate.validate(&CAPABILITIES),
Err(issue) if issue.rule() == ValidationRule::Inconsistent
));
}
#[test]
fn local_write_error_retains_exact_field_and_rule() {
let mut candidate = CandidateProfile::default();
assert_eq!(
candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)),
Err(FieldWriteError::non_finite(ids::MASS_KG))
);
}
#[test]
fn descriptor_bounds_do_not_conflict_with_derived_field_validation() {
for descriptor in CandidateProfile::descriptors() {
for bound in [descriptor.minimum, descriptor.maximum]
.into_iter()
.flatten()
{
let mut candidate = CandidateProfile::default();
candidate
.set_field(descriptor.id, bound)
.expect("descriptor bound must pass the descriptor-backed setter");
if let Err(issue) = candidate.validate(&CAPABILITIES) {
assert!(
issue.primary() != Some(descriptor.id)
|| issue.rule() == ValidationRule::Inconsistent,
"descriptor bound for {} conflicts with generated field validation: {issue:?}",
descriptor.display_key,
);
}
}
}
}
#[test]
fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() {
let mut candidate = CandidateProfile::default();
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(3),
)
.unwrap();
let active = candidate.validate(&CAPABILITIES).unwrap();
assert_eq!(active.observability().maximum_log_level(), 3);
assert_eq!(active.observability().maximum_log_level(), 3);
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(5),
)
.unwrap();
assert_eq!(
candidate
.validate(&CAPABILITIES)
.unwrap()
.observability()
.maximum_log_level(),
5
);
assert!(matches!(
candidate.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(2),
),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::BelowMinimum
));
}
#[test]
fn every_persisted_log_severity_is_retained_exactly() {
for raw in [3, 4, 5, 6, 7] {
let mut candidate = CandidateProfile::default();
candidate
.set_field(
descriptor_id("observability.maximum_log_level"),
ConfigValue::U32(raw),
)
.expect("descriptor range accepts the stable severity");
assert_eq!(
candidate
.validate(&CAPABILITIES)
.expect("valid complete profile")
.observability()
.maximum_log_level(),
raw
);
}
}
}
@@ -0,0 +1,223 @@
use kaptan_config::{ConfigValidate, ConfigViolation, ValidationRule};
use super::ids;
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct AidingCandidate {
#[config(
id = 6,
key = "aiding.position_variance_m2",
unit = "m2",
default = 0.04,
finite,
range(min = 0.000_001, max = 10_000.0)
)]
pub(super) position_variance_m2: f32,
#[config(
id = 7,
key = "aiding.velocity_variance_m2ps2",
unit = "m2/s2",
default = 0.04,
finite,
range(min = 0.000_001, max = 10_000.0)
)]
pub(super) velocity_variance_m2_per_second_squared: f32,
}
#[derive(Clone, Copy, ConfigValidate)]
pub(super) struct ControlCandidate {
#[config(
id = 8,
key = "control.position_gain_per_s2",
unit = "1/s2",
default = 0.8,
finite,
range(min = 0.0, max = 100.0)
)]
pub(super) position_gain_per_second_squared: f32,
#[config(
id = 9,
key = "control.velocity_gain_per_s",
unit = "1/s",
default = 1.2,
finite,
range(min = 0.0, max = 100.0)
)]
pub(super) velocity_gain_per_second: f32,
#[config(
id = 10,
key = "control.maximum_horizontal_acceleration_mps2",
unit = "m/s2",
default = 3.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_horizontal_acceleration_mps2: f32,
#[config(
id = 11,
key = "control.maximum_upward_acceleration_mps2",
unit = "m/s2",
default = 2.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_upward_acceleration_mps2: f32,
#[config(
id = 12,
key = "control.maximum_downward_acceleration_mps2",
unit = "m/s2",
default = 2.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) maximum_downward_acceleration_mps2: f32,
}
#[derive(Clone, Copy, ConfigValidate)]
#[config(post = Self::validate_thrust)]
pub(super) struct ThrustCandidate {
#[config(
id = 13,
key = "thrust.mass_kg",
unit = "kg",
default = 1.2,
finite,
range(min = 0.01, max = 100.0)
)]
pub(super) mass_kg: f32,
#[config(
id = 14,
key = "thrust.maximum_collective_thrust_n",
unit = "N",
default = 18.0,
finite,
range(min = 0.1, max = 10_000.0)
)]
pub(super) maximum_collective_thrust_newtons: f32,
#[config(
id = 15,
key = "thrust.maximum_tilt_tangent",
unit = "",
default = 0.700_207_53,
finite,
range(min = 0.001, max = 10.0)
)]
pub(super) maximum_tilt_tangent: f32,
}
impl ThrustCandidate {
fn validate_thrust(&self) -> Result<(), ConfigViolation> {
if self.maximum_collective_thrust_newtons <= self.mass_kg * 9.806_65 {
return Err(ConfigViolation::related(
ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS,
ids::MASS_KG,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
#[derive(Clone, Copy, ConfigValidate)]
#[config(post = Self::validate_pid)]
pub(super) struct PidCandidate {
#[config(
id = 16,
key = "control.rate_pid.proportional",
unit = "",
default = 0.035,
finite,
range(min = 0.0, max = 10.0)
)]
pub(super) proportional: f32,
#[config(
id = 17,
key = "control.rate_pid.integral",
unit = "",
default = 0.006,
finite,
range(min = 0.0, max = 10.0)
)]
pub(super) integral: f32,
#[config(
id = 18,
key = "control.rate_pid.derivative",
unit = "",
default = 0.0,
finite,
range(min = 0.0, max = 10.0)
)]
pub(super) derivative: f32,
#[config(
id = 19,
key = "control.rate_pid.output_limit",
unit = "",
default = 0.10,
finite,
range(min = 0.001, max = 1.0)
)]
pub(super) output_limit: f32,
#[config(
id = 20,
key = "control.rate_pid.integral_limit",
unit = "",
default = 0.025,
finite,
range(min = 0.0, max = 1.0)
)]
pub(super) integral_limit: f32,
}
impl PidCandidate {
fn validate_pid(&self) -> Result<(), ConfigViolation> {
if self.integral_limit > self.output_limit {
return Err(ConfigViolation::related(
ids::RATE_PID_INTEGRAL_LIMIT,
ids::RATE_PID_OUTPUT_LIMIT,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
#[derive(Clone, Copy, ConfigValidate)]
#[config(post = Self::validate_actuator_limits)]
pub(super) struct ActuatorLimitsCandidate {
#[config(
id = 21,
key = "actuator.minimum",
unit = "",
default = 0.0,
finite,
range(min = 0.0, max = 1.0)
)]
pub(super) minimum: f32,
#[config(
id = 22,
key = "actuator.maximum",
unit = "",
default = 1.0,
finite,
range(min = 0.0, max = 1.0)
)]
pub(super) maximum: f32,
#[config(
id = 23,
key = "actuator.slew_per_second",
unit = "1/s",
default = 8.0,
finite,
range(min = 0.001, max = 100.0)
)]
pub(super) slew_per_second: f32,
}
impl ActuatorLimitsCandidate {
fn validate_actuator_limits(&self) -> Result<(), ConfigViolation> {
if self.minimum >= self.maximum {
return Err(ConfigViolation::related(
ids::ACTUATOR_MINIMUM,
ids::ACTUATOR_MAXIMUM,
ValidationRule::Inconsistent,
));
}
Ok(())
}
}
@@ -0,0 +1,20 @@
//! Symbolic projections used by relation checks and focused tests.
//!
//! These constants never define an ID. Each projects the metadata generated
//! from its owning `#[config(id = ...)]` declaration.
use kaptan_config::FieldId;
use super::{
control::{ActuatorLimitsCandidate, PidCandidate, ThrustCandidate},
timing::TimingCandidate,
};
pub(super) const MAXIMUM_IMU_INTERVAL_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[0].id;
pub(super) const IMU_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[1].id;
pub(super) const MASS_KG: FieldId = ThrustCandidate::DESCRIPTORS[0].id;
pub(super) const MAXIMUM_COLLECTIVE_THRUST_NEWTONS: FieldId = ThrustCandidate::DESCRIPTORS[1].id;
pub(super) const RATE_PID_OUTPUT_LIMIT: FieldId = PidCandidate::DESCRIPTORS[3].id;
pub(super) const RATE_PID_INTEGRAL_LIMIT: FieldId = PidCandidate::DESCRIPTORS[4].id;
pub(super) const ACTUATOR_MINIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[0].id;
pub(super) const ACTUATOR_MAXIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[1].id;
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff. Show more