refactor: move firmware workspace under firmware
This commit is contained in:
1 parent
153b0bf262
commit
f03fc119a4
222 files changed
+56
-13
No files matched your search
@@ -0,0 +1,3 @@
|
||||
target
|
||||
.git
|
||||
tools/hitl
|
||||
Generated
+856
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "kaptan-actuator"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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(§ion)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "kaptan-dma"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Binary file not shown.
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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));
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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
Reference in new issue
Block a user