feat(log): add bounded diagnostic transport
This commit is contained in:
1 parent
632bf9f01b
commit
0a75860841
46 files changed
+2758
-60
No files matched your search
Generated
+294
@@ -119,6 +119,12 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -135,6 +141,12 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-actuator"
|
name = "kaptan-actuator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -142,11 +154,43 @@ version = "0.1.0"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-board"
|
name = "kaptan-board"
|
||||||
version = "0.1.0"
|
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]]
|
[[package]]
|
||||||
name = "kaptan-control"
|
name = "kaptan-control"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kaptan-log",
|
||||||
"kaptan-math",
|
"kaptan-math",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -156,6 +200,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-board",
|
"kaptan-board",
|
||||||
"kaptan-dma",
|
"kaptan-dma",
|
||||||
|
"kaptan-log",
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
@@ -170,11 +215,30 @@ name = "kaptan-drivers"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kaptan-i2c",
|
"kaptan-i2c",
|
||||||
|
"kaptan-log",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-spi",
|
"kaptan-spi",
|
||||||
"kaptan-time",
|
"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]]
|
[[package]]
|
||||||
name = "kaptan-geprc-f722-v2"
|
name = "kaptan-geprc-f722-v2"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -195,27 +259,62 @@ dependencies = [
|
|||||||
"platform-stm32f7",
|
"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]]
|
[[package]]
|
||||||
name = "kaptan-math"
|
name = "kaptan-math"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"kaptan-log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-mavlink"
|
name = "kaptan-mavlink"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
"kaptan-dma",
|
"kaptan-dma",
|
||||||
|
"kaptan-log",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
"kaptan-tx",
|
"kaptan-tx",
|
||||||
"mavlink",
|
"mavlink",
|
||||||
"mavlink-bindgen",
|
"mavlink-bindgen",
|
||||||
"mavlink-core",
|
"mavlink-core",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kaptan-navigation"
|
name = "kaptan-navigation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kaptan-log",
|
||||||
"kaptan-math",
|
"kaptan-math",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
@@ -225,6 +324,7 @@ dependencies = [
|
|||||||
name = "kaptan-safety"
|
name = "kaptan-safety"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kaptan-log",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -239,11 +339,21 @@ dependencies = [
|
|||||||
name = "kaptan-sitl"
|
name = "kaptan-sitl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"kaptan-config",
|
||||||
|
"kaptan-config-mavlink",
|
||||||
"kaptan-control",
|
"kaptan-control",
|
||||||
|
"kaptan-flight-config",
|
||||||
|
"kaptan-flight-runtime",
|
||||||
|
"kaptan-log",
|
||||||
|
"kaptan-log-mavlink",
|
||||||
"kaptan-math",
|
"kaptan-math",
|
||||||
|
"kaptan-mavlink",
|
||||||
"kaptan-navigation",
|
"kaptan-navigation",
|
||||||
"kaptan-safety",
|
"kaptan-safety",
|
||||||
"kaptan-time",
|
"kaptan-time",
|
||||||
|
"log",
|
||||||
|
"mavlink",
|
||||||
|
"minicbor",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -258,6 +368,12 @@ version = "0.1.0"
|
|||||||
name = "kaptan-tx"
|
name = "kaptan-tx"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mavlink"
|
name = "mavlink"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
@@ -303,6 +419,26 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minicbor"
|
||||||
|
version = "0.25.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0"
|
||||||
|
dependencies = [
|
||||||
|
"minicbor-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minicbor-derive"
|
||||||
|
version = "0.15.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nb"
|
name = "nb"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -355,6 +491,8 @@ dependencies = [
|
|||||||
"kaptan-dma",
|
"kaptan-dma",
|
||||||
"kaptan-drivers",
|
"kaptan-drivers",
|
||||||
"kaptan-i2c",
|
"kaptan-i2c",
|
||||||
|
"kaptan-log",
|
||||||
|
"kaptan-log-mavlink",
|
||||||
"kaptan-mavlink",
|
"kaptan-mavlink",
|
||||||
"kaptan-sensor",
|
"kaptan-sensor",
|
||||||
"kaptan-spi",
|
"kaptan-spi",
|
||||||
@@ -481,6 +619,57 @@ version = "0.7.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
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]]
|
[[package]]
|
||||||
name = "stm32f7"
|
name = "stm32f7"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -514,6 +703,21 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.19"
|
version = "2.0.19"
|
||||||
@@ -534,6 +738,60 @@ dependencies = [
|
|||||||
"syn 3.0.0",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -560,3 +818,39 @@ checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"vcell",
|
"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"
|
||||||
@@ -11,6 +11,9 @@ members = [
|
|||||||
"libs/kaptan-i2c",
|
"libs/kaptan-i2c",
|
||||||
"libs/kaptan-math",
|
"libs/kaptan-math",
|
||||||
"libs/kaptan-mavlink",
|
"libs/kaptan-mavlink",
|
||||||
|
"libs/kaptan-log",
|
||||||
|
"libs/kaptan-log-derive",
|
||||||
|
"libs/kaptan-log-mavlink",
|
||||||
"libs/kaptan-navigation",
|
"libs/kaptan-navigation",
|
||||||
"libs/kaptan-safety",
|
"libs/kaptan-safety",
|
||||||
"libs/kaptan-sensor",
|
"libs/kaptan-sensor",
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ version.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-math = { path = "../kaptan-math" }
|
kaptan-math = { path = "../kaptan-math" }
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
use kaptan_math::{Frd, Ned, UnitQuaternion, Vector3, sqrt};
|
use kaptan_math::{Frd, Ned, UnitQuaternion, Vector3, sqrt};
|
||||||
|
|
||||||
/// Body-frame angular rate in radians per second.
|
/// Body-frame angular rate in radians per second.
|
||||||
@@ -53,7 +54,7 @@ impl HeadingNed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Force-to-attitude conversion failure.
|
/// Force-to-attitude conversion failure.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum AttitudeError {
|
pub enum AttitudeError {
|
||||||
/// A zero or non-finite force has no defined thrust direction.
|
/// A zero or non-finite force has no defined thrust direction.
|
||||||
ZeroForce,
|
ZeroForce,
|
||||||
@@ -101,7 +102,7 @@ pub struct PidConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Invalid PID input/configuration.
|
/// Invalid PID input/configuration.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum PidError {
|
pub enum PidError {
|
||||||
/// Static PID gains or limits were non-finite or contradictory.
|
/// Static PID gains or limits were non-finite or contradictory.
|
||||||
InvalidConfiguration,
|
InvalidConfiguration,
|
||||||
@@ -316,7 +317,7 @@ pub struct KinematicLimits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rejection returned instead of silently repairing a goal or time interval.
|
/// Rejection returned instead of silently repairing a goal or time interval.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum TrajectoryError {
|
pub enum TrajectoryError {
|
||||||
/// A static speed/acceleration constraint is invalid.
|
/// A static speed/acceleration constraint is invalid.
|
||||||
InvalidLimits,
|
InvalidLimits,
|
||||||
@@ -586,7 +587,7 @@ pub struct TranslationalControlConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Position-control configuration or input rejection.
|
/// Position-control configuration or input rejection.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum TranslationalControlError {
|
pub enum TranslationalControlError {
|
||||||
/// A gain or kinematic limit is invalid.
|
/// A gain or kinematic limit is invalid.
|
||||||
InvalidConfiguration,
|
InvalidConfiguration,
|
||||||
@@ -681,7 +682,7 @@ pub struct ThrustEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Thrust-envelope rejection.
|
/// Thrust-envelope rejection.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum ThrustEnvelopeError {
|
pub enum ThrustEnvelopeError {
|
||||||
/// A static physical parameter is invalid.
|
/// A static physical parameter is invalid.
|
||||||
InvalidConfiguration,
|
InvalidConfiguration,
|
||||||
@@ -821,7 +822,7 @@ pub struct BodyWrench {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Allocation construction failure.
|
/// Allocation construction failure.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum AllocatorError {
|
pub enum AllocatorError {
|
||||||
/// One static allocation output has non-finite or contradictory limits.
|
/// One static allocation output has non-finite or contradictory limits.
|
||||||
InvalidActuator,
|
InvalidActuator,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
kaptan-board = { path = "../kaptan-board" }
|
kaptan-board = { path = "../kaptan-board" }
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-dma = { path = "../kaptan-dma" }
|
kaptan-dma = { path = "../kaptan-dma" }
|
||||||
kaptan-mavlink = { path = "../kaptan-mavlink" }
|
kaptan-mavlink = { path = "../kaptan-mavlink" }
|
||||||
kaptan-sensor = { path = "../kaptan-sensor" }
|
kaptan-sensor = { path = "../kaptan-sensor" }
|
||||||
|
|||||||
@@ -9,11 +9,14 @@
|
|||||||
|
|
||||||
use kaptan_board::{BoardConfig, DataChannelRates};
|
use kaptan_board::{BoardConfig, DataChannelRates};
|
||||||
use kaptan_dma::TxCompletion;
|
use kaptan_dma::TxCompletion;
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
|
use kaptan_mavlink::message::MessageData;
|
||||||
use kaptan_mavlink::{
|
use kaptan_mavlink::{
|
||||||
MavlinkFrame,
|
MavlinkFrame,
|
||||||
encoder::{DistanceSensorConfig, MavlinkEncoder},
|
encoder::{DistanceSensorConfig, MavlinkEncoder},
|
||||||
message::{MavCmd, MavResult, MavState},
|
message::{MavCmd, MavResult, MavState},
|
||||||
transmitter::FrameClass,
|
transmitter::FrameClass,
|
||||||
|
transmitter::TransmitFaultKind,
|
||||||
};
|
};
|
||||||
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
|
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
|
||||||
|
|
||||||
@@ -27,10 +30,78 @@ pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox
|
|||||||
/// delivery class. The platform retains only DMA endpoint ownership.
|
/// delivery class. The platform retains only DMA endpoint ownership.
|
||||||
pub trait FrameSink {
|
pub trait FrameSink {
|
||||||
/// Admits one fully encoded frame to the platform's bounded transport.
|
/// Admits one fully encoded frame to the platform's bounded transport.
|
||||||
fn send(&mut self, class: FrameClass, frame: MavlinkFrame);
|
fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission;
|
||||||
|
|
||||||
/// Releases or faults the active DMA frame after a terminal interrupt.
|
/// Releases or faults the active DMA frame after a terminal interrupt.
|
||||||
fn on_dma_complete(&mut self, completion: TxCompletion);
|
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.
|
/// Mailboxes read by one data-channel worker.
|
||||||
@@ -56,7 +127,7 @@ pub struct VerticalDataConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Data-channel construction failure.
|
/// Data-channel construction failure.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum DataChannelError {
|
pub enum DataChannelError {
|
||||||
/// Rejected profiles must not construct a physical telemetry channel.
|
/// Rejected profiles must not construct a physical telemetry channel.
|
||||||
NotTelemetryProfile,
|
NotTelemetryProfile,
|
||||||
@@ -126,7 +197,7 @@ where
|
|||||||
match self.schedule.next_due() {
|
match self.schedule.next_due() {
|
||||||
Some(DueData::RawImu) => {
|
Some(DueData::RawImu) => {
|
||||||
if let Some(sample) = self.sources.imu.take_newer(&mut self.observed_imu) {
|
if let Some(sample) = self.sources.imu.take_newer(&mut self.observed_imu) {
|
||||||
self.sink.send(
|
let _ = self.sink.send(
|
||||||
FrameClass::BestEffort,
|
FrameClass::BestEffort,
|
||||||
self.encoder.raw_imu_snapshot(&sample),
|
self.encoder.raw_imu_snapshot(&sample),
|
||||||
);
|
);
|
||||||
@@ -134,7 +205,7 @@ where
|
|||||||
}
|
}
|
||||||
Some(DueData::Distance) if self.range_age_ticks <= self.vertical.maximum_age_ticks => {
|
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) {
|
if let (Some(range), Some(config)) = (self.last_range, self.vertical.distance) {
|
||||||
self.sink.send(
|
let _ = self.sink.send(
|
||||||
FrameClass::BestEffort,
|
FrameClass::BestEffort,
|
||||||
self.encoder.distance_sensor_snapshot(&range, config),
|
self.encoder.distance_sensor_snapshot(&range, config),
|
||||||
);
|
);
|
||||||
@@ -146,33 +217,58 @@ where
|
|||||||
if let (Some(flow), Some(sensor_id)) =
|
if let (Some(flow), Some(sensor_id)) =
|
||||||
(self.last_optical_flow, self.vertical.optical_flow_sensor_id)
|
(self.last_optical_flow, self.vertical.optical_flow_sensor_id)
|
||||||
{
|
{
|
||||||
self.sink.send(
|
let _ = self.sink.send(
|
||||||
FrameClass::BestEffort,
|
FrameClass::BestEffort,
|
||||||
self.encoder.optical_flow_snapshot(&flow, sensor_id),
|
self.encoder.optical_flow_snapshot(&flow, sensor_id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(DueData::Heartbeat) => self.sink.send(
|
Some(DueData::Heartbeat) => {
|
||||||
FrameClass::Critical,
|
let _ = self.sink.send(
|
||||||
self.encoder.heartbeat(system_status, motors_armed),
|
FrameClass::Vital,
|
||||||
),
|
self.encoder.heartbeat(system_status, motors_armed),
|
||||||
|
);
|
||||||
|
}
|
||||||
Some(DueData::Distance | DueData::OpticalFlow) | None => {}
|
Some(DueData::Distance | DueData::OpticalFlow) | None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completes the active DMA lifecycle for this channel's transport.
|
/// Completes the active DMA lifecycle for this channel's transport.
|
||||||
pub fn on_dma_complete(&mut self, completion: TxCompletion) {
|
pub fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion {
|
||||||
self.sink.on_dma_complete(completion);
|
self.sink.on_dma_complete(completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits a critical acknowledgement for a received MAVLink command.
|
/// Emits a critical acknowledgement for a received MAVLink command.
|
||||||
pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) {
|
pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) {
|
||||||
self.sink.send(
|
let _ = self.sink.send(
|
||||||
FrameClass::Critical,
|
FrameClass::Critical,
|
||||||
self.encoder.command_ack(command, result),
|
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) {
|
fn refresh_vertical_data(&mut self) {
|
||||||
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
|
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
|
||||||
self.last_range = Some(range);
|
self.last_range = Some(range);
|
||||||
@@ -275,11 +371,16 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
DataChannel, DataSources, FrameSink, LatestImuMailbox, LatestOpticalFlowMailbox,
|
DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox,
|
||||||
LatestRangeMailbox, VerticalDataConfig,
|
LatestOpticalFlowMailbox, LatestRangeMailbox, TransmitFaultKind, TxHealth,
|
||||||
|
VerticalDataConfig,
|
||||||
|
};
|
||||||
|
use kaptan_board::{
|
||||||
|
BoardConfig, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort,
|
||||||
|
VerticalSensingKind,
|
||||||
};
|
};
|
||||||
use kaptan_board::{BoardConfig, DataChannelRates, MavlinkEndpoint, VerticalSensingKind};
|
|
||||||
use kaptan_dma::TxCompletion;
|
use kaptan_dma::TxCompletion;
|
||||||
|
use kaptan_log::Severity;
|
||||||
use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass};
|
use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass};
|
||||||
use kaptan_sensor::{
|
use kaptan_sensor::{
|
||||||
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
|
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
|
||||||
@@ -293,15 +394,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FrameSink for Sink {
|
impl FrameSink for Sink {
|
||||||
fn send(&mut self, class: FrameClass, _: MavlinkFrame) {
|
fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission {
|
||||||
match class {
|
match class {
|
||||||
|
FrameClass::Vital => self.critical += 1,
|
||||||
FrameClass::BestEffort => self.best_effort += 1,
|
FrameClass::BestEffort => self.best_effort += 1,
|
||||||
FrameClass::Critical => self.critical += 1,
|
FrameClass::Critical => self.critical += 1,
|
||||||
}
|
}
|
||||||
|
FrameAdmission::Retained(kaptan_mavlink::transmitter::EnqueueResult::Enqueued)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_dma_complete(&mut self, _: TxCompletion) {
|
fn on_dma_complete(&mut self, _: TxCompletion) -> DmaCompletion {
|
||||||
self.completions += 1;
|
self.completions += 1;
|
||||||
|
DmaCompletion::Complete
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health(&self) -> TxHealth {
|
||||||
|
TxHealth::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_transport_fault(&mut self) -> Option<TransmitFaultKind> {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,8 +422,12 @@ mod tests {
|
|||||||
mavlink: MavlinkEndpoint {
|
mavlink: MavlinkEndpoint {
|
||||||
system_id: 1,
|
system_id: 1,
|
||||||
component_id: 1,
|
component_id: 1,
|
||||||
|
port: MavlinkPort::Serial1,
|
||||||
|
baud: 115_200,
|
||||||
},
|
},
|
||||||
profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::TelemetryProfile {
|
profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::TelemetryProfile {
|
||||||
|
imu: ImuDevice::Mpu6000,
|
||||||
|
imu_mount: ImuMount::BodyAligned,
|
||||||
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
|
||||||
data_channel: DataChannelRates {
|
data_channel: DataChannelRates {
|
||||||
service_hz: 100,
|
service_hz: 100,
|
||||||
@@ -320,6 +436,7 @@ mod tests {
|
|||||||
optical_flow_hz: Some(20),
|
optical_flow_hz: Some(20),
|
||||||
heartbeat_hz: 2,
|
heartbeat_hz: 2,
|
||||||
},
|
},
|
||||||
|
maximum_log_level: Severity::Info,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ version.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-sensor = { path = "../kaptan-sensor" }
|
kaptan-sensor = { path = "../kaptan-sensor" }
|
||||||
kaptan-i2c = { path = "../kaptan-i2c" }
|
kaptan-i2c = { path = "../kaptan-i2c" }
|
||||||
kaptan-spi = { path = "../kaptan-spi" }
|
kaptan-spi = { path = "../kaptan-spi" }
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! ICM-42688-P operation errors.
|
//! ICM-42688-P operation errors.
|
||||||
|
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
/// ICM-42688-P operation failure.
|
/// ICM-42688-P operation failure.
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum Error<BusError> {
|
pub enum Error<BusError> {
|
||||||
/// The selected SPI route rejected a transfer submission.
|
/// The selected SPI route rejected a transfer submission.
|
||||||
Start(BusError),
|
Start(BusError),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! LIDAR-Lite v3 operation errors.
|
//! LIDAR-Lite v3 operation errors.
|
||||||
|
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
/// LIDAR-Lite v3 operation failure.
|
/// LIDAR-Lite v3 operation failure.
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum Error<BusError> {
|
pub enum Error<BusError> {
|
||||||
/// The selected I²C route rejected a transaction submission.
|
/// The selected I²C route rejected a transaction submission.
|
||||||
Start(BusError),
|
Start(BusError),
|
||||||
@@ -22,7 +24,7 @@ pub enum Error<BusError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// LIDAR-Lite v3 startup lifecycle failure.
|
/// LIDAR-Lite v3 startup lifecycle failure.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum StartupError {
|
pub enum StartupError {
|
||||||
/// Startup completion was reported when the driver was not settling.
|
/// Startup completion was reported when the driver was not settling.
|
||||||
NotSettling,
|
NotSettling,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
//! MPU6000 operation errors.
|
//! MPU6000 operation errors.
|
||||||
|
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
/// MPU6000 operation failure.
|
/// MPU6000 operation failure.
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum Error<BusError> {
|
pub enum Error<BusError> {
|
||||||
/// The selected SPI route rejected a transfer submission.
|
/// The selected SPI route rejected a transfer submission.
|
||||||
Start(BusError),
|
Start(BusError),
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "kaptan-log-derive"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.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]
|
||||||
|
trybuild = "1"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Derive support for static Kaptan runtime-log messages.
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::quote;
|
||||||
|
use syn::{Data, DeriveInput, Error, Fields, parse_macro_input, parse_quote};
|
||||||
|
|
||||||
|
/// Implements [`kaptan_log::ErrorReport`], [`core::fmt::Display`], and
|
||||||
|
/// [`core::error::Error`] for enums.
|
||||||
|
///
|
||||||
|
/// Each enum variant receives one static message in the form
|
||||||
|
/// `module::Type::Variant`. The derive deliberately accepts no attributes:
|
||||||
|
/// text logging has no user-provided numeric catalog allocation.
|
||||||
|
#[proc_macro_derive(KaptanError)]
|
||||||
|
pub fn derive_kaptan_error(input: TokenStream) -> TokenStream {
|
||||||
|
let input = parse_macro_input!(input as DeriveInput);
|
||||||
|
match expand(input) {
|
||||||
|
Ok(tokens) => tokens.into(),
|
||||||
|
Err(error) => error.into_compile_error().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand(input: DeriveInput) -> Result<proc_macro2::TokenStream, Error> {
|
||||||
|
let DeriveInput {
|
||||||
|
ident,
|
||||||
|
generics,
|
||||||
|
data,
|
||||||
|
..
|
||||||
|
} = input;
|
||||||
|
let Data::Enum(data) = data else {
|
||||||
|
let kind = match data {
|
||||||
|
Data::Struct(_) => "structs",
|
||||||
|
Data::Union(_) => "unions",
|
||||||
|
Data::Enum(_) => unreachable!("enum handled above"),
|
||||||
|
};
|
||||||
|
return Err(Error::new_spanned(
|
||||||
|
ident,
|
||||||
|
format!("KaptanError can only be derived for enums, not {kind}"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let arms = data.variants.iter().map(|variant| {
|
||||||
|
let variant_ident = &variant.ident;
|
||||||
|
let pattern = match &variant.fields {
|
||||||
|
Fields::Unit => quote!(Self::#variant_ident),
|
||||||
|
Fields::Unnamed(_) => quote!(Self::#variant_ident(..)),
|
||||||
|
Fields::Named(_) => quote!(Self::#variant_ident { .. }),
|
||||||
|
};
|
||||||
|
quote! {
|
||||||
|
#pattern => {
|
||||||
|
static MESSAGE: ::kaptan_log::StaticErrorMessage =
|
||||||
|
::kaptan_log::StaticErrorMessage::new(concat!(
|
||||||
|
module_path!(),
|
||||||
|
"::",
|
||||||
|
stringify!(#ident),
|
||||||
|
"::",
|
||||||
|
stringify!(#variant_ident),
|
||||||
|
));
|
||||||
|
&MESSAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (impl_generics, type_generics, where_clause) = generics.split_for_impl();
|
||||||
|
let mut error_generics = generics.clone();
|
||||||
|
error_generics
|
||||||
|
.make_where_clause()
|
||||||
|
.predicates
|
||||||
|
.push(parse_quote!(#ident #type_generics: ::core::fmt::Debug));
|
||||||
|
let (error_impl_generics, error_type_generics, error_where_clause) =
|
||||||
|
error_generics.split_for_impl();
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
impl #impl_generics ::kaptan_log::ErrorReport for #ident #type_generics #where_clause {
|
||||||
|
fn error_message(&self) -> &'static ::kaptan_log::StaticErrorMessage {
|
||||||
|
match self {
|
||||||
|
#(#arms),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #impl_generics ::core::fmt::Display for #ident #type_generics #where_clause {
|
||||||
|
fn fmt(
|
||||||
|
&self,
|
||||||
|
formatter: &mut ::core::fmt::Formatter<'_>,
|
||||||
|
) -> ::core::fmt::Result {
|
||||||
|
formatter.write_str(
|
||||||
|
<Self as ::kaptan_log::ErrorReport>::error_message(self).as_str(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #error_impl_generics ::core::error::Error
|
||||||
|
for #ident #error_type_generics #error_where_clause {}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#[test]
|
||||||
|
fn kaptan_error_expands_for_enum_shapes_only() {
|
||||||
|
let tests = trybuild::TestCases::new();
|
||||||
|
tests.pass("tests/ui/enum.rs");
|
||||||
|
tests.compile_fail("tests/ui/struct.rs");
|
||||||
|
tests.compile_fail("tests/ui/union.rs");
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
extern crate self as kaptan_log;
|
||||||
|
|
||||||
|
use kaptan_log_derive::KaptanError;
|
||||||
|
|
||||||
|
pub struct StaticErrorMessage(&'static str);
|
||||||
|
|
||||||
|
impl StaticErrorMessage {
|
||||||
|
pub const fn new(text: &'static str) -> Self {
|
||||||
|
Self(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ErrorReport {
|
||||||
|
fn error_message(&self) -> &'static StaticErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, KaptanError)]
|
||||||
|
enum Generic<'a, T>
|
||||||
|
where
|
||||||
|
T: Copy,
|
||||||
|
{
|
||||||
|
Unit,
|
||||||
|
Tuple(T, u8),
|
||||||
|
Named { label: &'a str },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
fn assert_error<T: core::error::Error>() {}
|
||||||
|
assert_error::<Generic<'_, u8>>();
|
||||||
|
assert_eq!(
|
||||||
|
Generic::<u8>::Unit.error_message().as_str(),
|
||||||
|
concat!(module_path!(), "::Generic::Unit"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Generic::Tuple(1_u8, 2).error_message().as_str(),
|
||||||
|
concat!(module_path!(), "::Generic::Tuple"),
|
||||||
|
);
|
||||||
|
let named: Generic<'_, u8> = Generic::Named { label: "test" };
|
||||||
|
assert_eq!(
|
||||||
|
named.to_string(),
|
||||||
|
concat!(module_path!(), "::Generic::Named"),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use kaptan_log_derive::KaptanError;
|
||||||
|
|
||||||
|
#[derive(KaptanError)]
|
||||||
|
struct NotAnError {
|
||||||
|
code: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: KaptanError can only be derived for enums, not structs
|
||||||
|
--> tests/ui/struct.rs:4:8
|
||||||
|
|
|
||||||
|
4 | struct NotAnError {
|
||||||
|
| ^^^^^^^^^^
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use kaptan_log_derive::KaptanError;
|
||||||
|
|
||||||
|
#[derive(KaptanError)]
|
||||||
|
union NotAnError {
|
||||||
|
code: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
error: KaptanError can only be derived for enums, not unions
|
||||||
|
--> tests/ui/union.rs:4:7
|
||||||
|
|
|
||||||
|
4 | union NotAnError {
|
||||||
|
| ^^^^^^^^^^
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "kaptan-log-mavlink"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
|
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log", features = ["facade"] }
|
||||||
|
mavlink = { version = "0.18", default-features = false, features = ["embedded"] }
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! MAVLink projection for bounded Kaptan diagnostics.
|
||||||
|
//!
|
||||||
|
//! This adapter creates generated payload data only. A platform-owned link is
|
||||||
|
//! solely responsible for MAVLink header sequence, bounded admission, DMA, and
|
||||||
|
//! retry policy.
|
||||||
|
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
extern crate std;
|
||||||
|
|
||||||
|
use kaptan_log::{LogRecord, RecordChannel, ShutdownReason, ShutdownReport};
|
||||||
|
use kaptan_mavlink::message::kaptan::{
|
||||||
|
KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags,
|
||||||
|
KaptanShutdownReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`].
|
||||||
|
pub const LOG_MESSAGE_BYTES: usize = 160;
|
||||||
|
|
||||||
|
/// One generated diagnostic payload. A platform-owned link remains the sole
|
||||||
|
/// MAVLink framing, admission, DMA, and retry owner.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum DiagnosticPayload {
|
||||||
|
/// Ordinary severity-and-text record.
|
||||||
|
Log(KAPTAN_LOG_DATA),
|
||||||
|
/// Error-only bounded display identity.
|
||||||
|
Error(KAPTAN_ERROR_DATA),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one bounded diagnostic record.
|
||||||
|
///
|
||||||
|
/// The adapter produces payload data only; it neither frames nor transmits
|
||||||
|
/// MAVLink. `RecordChannel` is the only routing decision: ordinary records
|
||||||
|
/// become `KAPTAN_LOG`, while error-or-higher records become `KAPTAN_ERROR`.
|
||||||
|
pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload {
|
||||||
|
let message = record.message();
|
||||||
|
debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES);
|
||||||
|
match record.channel() {
|
||||||
|
RecordChannel::Log => DiagnosticPayload::Log(KAPTAN_LOG_DATA {
|
||||||
|
level: record.severity() as u8,
|
||||||
|
flags: KaptanLogFlags::from_bits_retain(record.flags().bits()),
|
||||||
|
message_length: message.len(),
|
||||||
|
message: *message.bytes(),
|
||||||
|
}),
|
||||||
|
RecordChannel::Error => DiagnosticPayload::Error(KAPTAN_ERROR_DATA {
|
||||||
|
flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()),
|
||||||
|
message_length: message.len(),
|
||||||
|
message: *message.bytes(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes one typed shutdown event. The caller owns when a shutdown is safe
|
||||||
|
/// to report; this pure adapter cannot run a shutdown state machine.
|
||||||
|
pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA {
|
||||||
|
KAPTAN_SHUTDOWN_DATA {
|
||||||
|
reason: match report.reason() {
|
||||||
|
ShutdownReason::Operator => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_OPERATOR,
|
||||||
|
ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Once;
|
||||||
|
|
||||||
|
use super::{DiagnosticPayload, LOG_MESSAGE_BYTES, encode_diagnostic, encode_shutdown};
|
||||||
|
use kaptan_log::{
|
||||||
|
EventHub, FacadeIngress, GlobalLogger, LogFilter, LogFlags, LogRecord, LogText, Severity,
|
||||||
|
ShutdownReason, ShutdownReport,
|
||||||
|
};
|
||||||
|
use kaptan_mavlink::{
|
||||||
|
encoder::MavlinkEncoder,
|
||||||
|
message::{
|
||||||
|
Message,
|
||||||
|
kaptan::{KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, MavMessage},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
||||||
|
static ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden);
|
||||||
|
static INSTALL_ERROR_LOGGER: Once = Once::new();
|
||||||
|
|
||||||
|
const fn never_forbidden() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_record_preserves_level_flags_and_bounded_text() {
|
||||||
|
let record = LogRecord::new(
|
||||||
|
Severity::Info,
|
||||||
|
LogFlags::TRUNCATED,
|
||||||
|
LogText::from_message("imu unavailable"),
|
||||||
|
);
|
||||||
|
let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else {
|
||||||
|
panic!("info record must use KAPTAN_LOG");
|
||||||
|
};
|
||||||
|
assert_eq!(data.level, Severity::Info as u8);
|
||||||
|
assert_eq!(data.message_length, 15);
|
||||||
|
assert_eq!(&data.message[..15], b"imu unavailable");
|
||||||
|
assert!(data.message[15..].iter().all(|byte| *byte == 0));
|
||||||
|
assert_eq!(data.flags.bits(), LogFlags::TRUNCATED.bits());
|
||||||
|
assert_eq!(KAPTAN_LOG_DATA::ENCODED_LEN, 163);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_error_macro_reaches_error_channel_after_optional_ingress_is_full() {
|
||||||
|
INSTALL_ERROR_LOGGER.call_once(|| {
|
||||||
|
ERROR_LOGGER
|
||||||
|
.install()
|
||||||
|
.expect("the test owns the process-global log facade");
|
||||||
|
});
|
||||||
|
|
||||||
|
kaptan_log::log::info!("optional ingress is occupied");
|
||||||
|
kaptan_log::log::error!("critical error survives");
|
||||||
|
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
||||||
|
assert!(ERROR_INGRESS.drain_one_into(&mut hub).is_some());
|
||||||
|
let record = hub.peek().expect("critical macro record retained");
|
||||||
|
let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else {
|
||||||
|
panic!("log::error! must not produce KAPTAN_LOG");
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
payload.message[..usize::from(payload.message_length)]
|
||||||
|
.ends_with(b"critical error survives")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_log_and_error_messages_round_trip_through_the_sole_encoder() {
|
||||||
|
let record = LogRecord::new(
|
||||||
|
Severity::Info,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message(&"x".repeat(LOG_MESSAGE_BYTES)),
|
||||||
|
);
|
||||||
|
let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else {
|
||||||
|
panic!("info record must use KAPTAN_LOG");
|
||||||
|
};
|
||||||
|
let mut encoder = MavlinkEncoder::new(1, 1);
|
||||||
|
let frame = encoder.custom(&data);
|
||||||
|
assert!(frame.has_valid_crc::<MavMessage>());
|
||||||
|
assert_eq!(frame.message_id(), 51_020);
|
||||||
|
assert_eq!(frame.raw_bytes().len(), 175);
|
||||||
|
match MavMessage::parse(
|
||||||
|
mavlink::MavlinkVersion::V2,
|
||||||
|
frame.message_id(),
|
||||||
|
frame.payload(),
|
||||||
|
) {
|
||||||
|
Ok(MavMessage::KAPTAN_LOG(decoded)) => assert_eq!(decoded, data),
|
||||||
|
other => panic!("unexpected decoded KAPTAN_LOG payload: {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(data.message.len(), LOG_MESSAGE_BYTES);
|
||||||
|
|
||||||
|
let error = LogRecord::new(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::TRACE.with(LogFlags::COALESCED),
|
||||||
|
LogText::from_message("driver::Error::Transfer"),
|
||||||
|
);
|
||||||
|
let DiagnosticPayload::Error(error_data) = encode_diagnostic(&error) else {
|
||||||
|
panic!("error record must use KAPTAN_ERROR");
|
||||||
|
};
|
||||||
|
assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits());
|
||||||
|
assert_eq!(error_data.message_length, 23);
|
||||||
|
assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 162);
|
||||||
|
let error_frame = encoder.custom(&error_data);
|
||||||
|
assert_eq!(error_frame.message_id(), 51_021);
|
||||||
|
assert!(matches!(
|
||||||
|
MavMessage::parse(
|
||||||
|
mavlink::MavlinkVersion::V2,
|
||||||
|
error_frame.message_id(),
|
||||||
|
error_frame.payload(),
|
||||||
|
),
|
||||||
|
Ok(MavMessage::KAPTAN_ERROR(decoded)) if decoded == error_data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_shutdown_maps_to_its_sole_generated_message() {
|
||||||
|
let data = encode_shutdown(ShutdownReport::new(ShutdownReason::Safety));
|
||||||
|
assert_eq!(KAPTAN_SHUTDOWN_DATA::ENCODED_LEN, 1);
|
||||||
|
let mut encoder = MavlinkEncoder::new(1, 1);
|
||||||
|
let frame = encoder.custom(&data);
|
||||||
|
assert_eq!(frame.message_id(), 51_022);
|
||||||
|
assert!(matches!(
|
||||||
|
MavMessage::parse(
|
||||||
|
mavlink::MavlinkVersion::V2,
|
||||||
|
frame.message_id(),
|
||||||
|
frame.payload(),
|
||||||
|
),
|
||||||
|
Ok(MavMessage::KAPTAN_SHUTDOWN(decoded)) if decoded == data
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "kaptan-log"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# Optional allocation-free standard-log ingress. It uses one bounded atomic
|
||||||
|
# attempt rather than a critical section, so enabling it cannot select std.
|
||||||
|
facade = ["dep:log"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
kaptan-log-derive = { path = "../kaptan-log-derive" }
|
||||||
|
log = { version = "0.4.33", default-features = false, optional = true }
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Retained crash-record codec primitives.
|
||||||
|
//!
|
||||||
|
//! This module defines and validates a fixed record only. It does not install a
|
||||||
|
//! panic handler, write retention memory, access flash, or transmit MAVLink.
|
||||||
|
|
||||||
|
use crate::KaptanError;
|
||||||
|
|
||||||
|
const MAGIC: u32 = u32::from_le_bytes(*b"KLOG");
|
||||||
|
const VERSION: u16 = 1;
|
||||||
|
const COMMITTED: u32 = u32::MAX;
|
||||||
|
/// Fixed endianness-independent retained record size.
|
||||||
|
pub const CRASH_RECORD_BYTES: usize = 32;
|
||||||
|
|
||||||
|
/// Stored reason for a previous abnormal or orderly runtime end.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum CrashKind {
|
||||||
|
/// A panic was captured by a future target-specific handler.
|
||||||
|
Panic = 1,
|
||||||
|
/// A hard fault was captured by a future target-specific handler.
|
||||||
|
HardFault = 2,
|
||||||
|
/// A requested shutdown sequence was captured.
|
||||||
|
Shutdown = 3,
|
||||||
|
/// A platform reset cause was captured.
|
||||||
|
Reset = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrashKind {
|
||||||
|
const fn as_u8(self) -> u8 {
|
||||||
|
self as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn from_u8(value: u8) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
1 => Some(Self::Panic),
|
||||||
|
2 => Some(Self::HardFault),
|
||||||
|
3 => Some(Self::Shutdown),
|
||||||
|
4 => Some(Self::Reset),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validation failure for a retained record.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
|
pub enum CrashRecordError {
|
||||||
|
/// Magic/version/commit marker did not identify a completed record.
|
||||||
|
InvalidHeader,
|
||||||
|
/// Record payload did not match its checksum.
|
||||||
|
BadChecksum,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed, commit-last retained record value.
|
||||||
|
///
|
||||||
|
/// A board integration writes every field except `commit`, writes `checksum`,
|
||||||
|
/// then writes `commit` last. This pure type only validates that protocol.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CrashRecord {
|
||||||
|
kind: CrashKind,
|
||||||
|
boot_id: u64,
|
||||||
|
detail: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CrashRecord {
|
||||||
|
/// Creates one completed record for tests or a future retention backend.
|
||||||
|
pub fn new(kind: CrashKind, boot_id: u64, detail: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
boot_id,
|
||||||
|
detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes an endian-independent record. A target writes the final four
|
||||||
|
/// commit-marker bytes last; this function itself performs no I/O.
|
||||||
|
pub fn encode(self) -> [u8; CRASH_RECORD_BYTES] {
|
||||||
|
let mut bytes = [0_u8; CRASH_RECORD_BYTES];
|
||||||
|
bytes[0..4].copy_from_slice(&MAGIC.to_le_bytes());
|
||||||
|
bytes[4..6].copy_from_slice(&VERSION.to_le_bytes());
|
||||||
|
bytes[6] = self.kind.as_u8();
|
||||||
|
bytes[7] = 0;
|
||||||
|
bytes[8..16].copy_from_slice(&self.boot_id.to_le_bytes());
|
||||||
|
bytes[16..24].copy_from_slice(&self.detail.to_le_bytes());
|
||||||
|
let record_checksum = checksum(&bytes[..24]);
|
||||||
|
bytes[24..28].copy_from_slice(&record_checksum.to_le_bytes());
|
||||||
|
bytes[28..32].copy_from_slice(&COMMITTED.to_le_bytes());
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes and validates an explicitly little-endian retained record.
|
||||||
|
/// Arbitrary retained bytes are never reinterpreted as Rust enums.
|
||||||
|
pub fn decode(bytes: [u8; CRASH_RECORD_BYTES]) -> Result<Self, CrashRecordError> {
|
||||||
|
if u32::from_le_bytes(bytes[0..4].try_into().unwrap()) != MAGIC
|
||||||
|
|| u16::from_le_bytes(bytes[4..6].try_into().unwrap()) != VERSION
|
||||||
|
|| bytes[7] != 0
|
||||||
|
|| u32::from_le_bytes(bytes[28..32].try_into().unwrap()) != COMMITTED
|
||||||
|
{
|
||||||
|
return Err(CrashRecordError::InvalidHeader);
|
||||||
|
}
|
||||||
|
if u32::from_le_bytes(bytes[24..28].try_into().unwrap()) != checksum(&bytes[..24]) {
|
||||||
|
return Err(CrashRecordError::BadChecksum);
|
||||||
|
}
|
||||||
|
let Some(kind) = CrashKind::from_u8(bytes[6]) else {
|
||||||
|
return Err(CrashRecordError::InvalidHeader);
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
kind,
|
||||||
|
boot_id: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
||||||
|
detail: u64::from_le_bytes(bytes[16..24].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captured end reason.
|
||||||
|
pub const fn kind(self) -> CrashKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board-local boot identity.
|
||||||
|
pub const fn boot_id(self) -> u64 {
|
||||||
|
self.boot_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Target-defined compact panic/fault/reset detail.
|
||||||
|
pub const fn detail(self) -> u64 {
|
||||||
|
self.detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn checksum(bytes: &[u8]) -> u32 {
|
||||||
|
let mut state = 0xffff_ffff_u32;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
state = crc32c_byte(state, bytes[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
!state
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn crc32c_byte(mut state: u32, byte: u8) -> u32 {
|
||||||
|
state ^= byte as u32;
|
||||||
|
let mut index = 0;
|
||||||
|
while index != 8 {
|
||||||
|
state = if state & 1 != 0 {
|
||||||
|
(state >> 1) ^ 0x82f6_3b78
|
||||||
|
} else {
|
||||||
|
state >> 1
|
||||||
|
};
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CrashKind, CrashRecord, CrashRecordError};
|
||||||
|
use crate::ErrorReport;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retained_record_rejects_torn_or_corrupt_payloads() {
|
||||||
|
let record = CrashRecord::new(CrashKind::Panic, 7, 11);
|
||||||
|
let bytes = record.encode();
|
||||||
|
assert_eq!(CrashRecord::decode(bytes), Ok(record));
|
||||||
|
let mut corrupt = bytes;
|
||||||
|
corrupt[24] ^= 1;
|
||||||
|
assert_eq!(
|
||||||
|
CrashRecord::decode(corrupt),
|
||||||
|
Err(CrashRecordError::BadChecksum)
|
||||||
|
);
|
||||||
|
let mut torn = bytes;
|
||||||
|
torn[28..32].copy_from_slice(&0_u32.to_le_bytes());
|
||||||
|
assert_eq!(
|
||||||
|
CrashRecord::decode(torn),
|
||||||
|
Err(CrashRecordError::InvalidHeader)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_error_has_a_static_derived_message() {
|
||||||
|
assert_eq!(
|
||||||
|
CrashRecordError::BadChecksum.error_message().as_str(),
|
||||||
|
"kaptan_log::crash::CrashRecordError::BadChecksum"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
//! Optional standard [`log`] facade backed by single-attempt atomic ingresses.
|
||||||
|
//!
|
||||||
|
//! The ingress never allocates, masks interrupts, locks, spins, encodes
|
||||||
|
//! MAVLink, or accesses DMA. Flight images keep this feature disabled; a
|
||||||
|
//! future normal-task image must reject ISR/panic paths before formatting.
|
||||||
|
|
||||||
|
use core::{
|
||||||
|
cell::UnsafeCell,
|
||||||
|
sync::atomic::{AtomicBool, AtomicU8, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
|
||||||
|
|
||||||
|
use crate::{EventHub, LogFilter, LogFlags, LogText, PublishOutcome, Severity};
|
||||||
|
|
||||||
|
/// Saturating, process-local accounting for the optional global facade.
|
||||||
|
///
|
||||||
|
/// It is deliberately not serialized in a Kaptan diagnostic message; the
|
||||||
|
/// facade remains a small text ingress rather than a metrics protocol.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct FacadeHealth {
|
||||||
|
/// Calls rejected before formatting because their context is forbidden.
|
||||||
|
pub forbidden_context: u32,
|
||||||
|
/// Optional records excluded by the active verbosity cap.
|
||||||
|
pub filtered: u32,
|
||||||
|
/// Calls that lost the one-attempt ingress ownership race.
|
||||||
|
pub contended: u32,
|
||||||
|
/// Calls dropped because their bounded ingress lane was full.
|
||||||
|
pub full: u32,
|
||||||
|
/// Formatted messages truncated to the fixed text capacity.
|
||||||
|
pub truncated: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FacadeCounters {
|
||||||
|
forbidden_context: core::sync::atomic::AtomicU32,
|
||||||
|
filtered: core::sync::atomic::AtomicU32,
|
||||||
|
contended: core::sync::atomic::AtomicU32,
|
||||||
|
full: core::sync::atomic::AtomicU32,
|
||||||
|
truncated: core::sync::atomic::AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FacadeCounters {
|
||||||
|
const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
forbidden_context: core::sync::atomic::AtomicU32::new(0),
|
||||||
|
filtered: core::sync::atomic::AtomicU32::new(0),
|
||||||
|
contended: core::sync::atomic::AtomicU32::new(0),
|
||||||
|
full: core::sync::atomic::AtomicU32::new(0),
|
||||||
|
truncated: core::sync::atomic::AtomicU32::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single compare-exchange keeps this diagnostic counter non-wrapping
|
||||||
|
/// without a retry loop. Under simultaneous facade callers it may
|
||||||
|
/// undercount, but it never blocks or falsely wraps to zero.
|
||||||
|
fn increment(counter: &core::sync::atomic::AtomicU32) {
|
||||||
|
let current = counter.load(Ordering::Relaxed);
|
||||||
|
if current != u32::MAX {
|
||||||
|
let _ = counter.compare_exchange(
|
||||||
|
current,
|
||||||
|
current + 1,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> FacadeHealth {
|
||||||
|
FacadeHealth {
|
||||||
|
forbidden_context: self.forbidden_context.load(Ordering::Relaxed),
|
||||||
|
filtered: self.filtered.load(Ordering::Relaxed),
|
||||||
|
contended: self.contended.load(Ordering::Relaxed),
|
||||||
|
full: self.full.load(Ordering::Relaxed),
|
||||||
|
truncated: self.truncated.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct IngressRecord {
|
||||||
|
severity: Severity,
|
||||||
|
flags: LogFlags,
|
||||||
|
text: LogText,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IngressQueue<const N: usize> {
|
||||||
|
entries: [Option<IngressRecord>; N],
|
||||||
|
head: usize,
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const N: usize> IngressQueue<N> {
|
||||||
|
const fn new() -> Self {
|
||||||
|
assert!(N > 0, "facade ingress must be nonempty");
|
||||||
|
Self {
|
||||||
|
entries: [None; N],
|
||||||
|
head: 0,
|
||||||
|
len: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, record: IngressRecord) -> bool {
|
||||||
|
if self.len == N {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let index = (self.head + self.len) % N;
|
||||||
|
self.entries[index] = Some(record);
|
||||||
|
self.len += 1;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop(&mut self) -> Option<IngressRecord> {
|
||||||
|
if self.len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let record = self.entries[self.head].take();
|
||||||
|
self.head = (self.head + 1) % N;
|
||||||
|
self.len -= 1;
|
||||||
|
record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed global-facade ingress. The normal task/runtime owner drains it into
|
||||||
|
/// an [`EventHub`] after startup.
|
||||||
|
pub struct FacadeIngress<const N: usize> {
|
||||||
|
/// One dedicated critical slot keeps error records independent from the
|
||||||
|
/// bounded ordinary FIFO. It is deliberately one slot: producers never
|
||||||
|
/// spin, and a later error may be dropped only when an earlier error is
|
||||||
|
/// already retained.
|
||||||
|
critical: UnsafeCell<Option<IngressRecord>>,
|
||||||
|
critical_busy: AtomicBool,
|
||||||
|
queue: UnsafeCell<IngressQueue<N>>,
|
||||||
|
queue_busy: AtomicBool,
|
||||||
|
maximum_severity: AtomicU8,
|
||||||
|
counters: FacadeCounters,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: each interior-mutability cell is accessed only while its matching
|
||||||
|
// atomic busy bit is acquired by one compare-exchange. The operation never
|
||||||
|
// waits or retries; contention leaves the corresponding bounded lane intact.
|
||||||
|
unsafe impl<const N: usize> Sync for FacadeIngress<N> {}
|
||||||
|
|
||||||
|
impl<const N: usize> FacadeIngress<N> {
|
||||||
|
/// Creates empty fixed ingress with the boot-active verbosity cap.
|
||||||
|
pub const fn new(filter: LogFilter) -> Self {
|
||||||
|
Self {
|
||||||
|
critical: UnsafeCell::new(None),
|
||||||
|
critical_busy: AtomicBool::new(false),
|
||||||
|
queue: UnsafeCell::new(IngressQueue::new()),
|
||||||
|
queue_busy: AtomicBool::new(false),
|
||||||
|
maximum_severity: AtomicU8::new(filter.maximum_severity() as u8),
|
||||||
|
counters: FacadeCounters::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the runtime-selected maximum non-error verbosity.
|
||||||
|
pub fn set_filter(&self, filter: LogFilter) {
|
||||||
|
self.maximum_severity
|
||||||
|
.store(filter.maximum_severity() as u8, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allows(&self, severity: Severity) -> bool {
|
||||||
|
severity <= Severity::Error
|
||||||
|
|| (severity as u8) <= self.maximum_severity.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, record: IngressRecord) {
|
||||||
|
if record.severity <= Severity::Error {
|
||||||
|
self.push_critical(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.with_queue(|queue| queue.push(record)) {
|
||||||
|
Some(true) => {}
|
||||||
|
Some(false) => FacadeCounters::increment(&self.counters.full),
|
||||||
|
None => FacadeCounters::increment(&self.counters.contended),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_critical(&self, record: IngressRecord) {
|
||||||
|
match self.with_critical(|slot| {
|
||||||
|
if slot.is_none() {
|
||||||
|
*slot = Some(record);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Some(true) => {}
|
||||||
|
Some(false) => FacadeCounters::increment(&self.counters.full),
|
||||||
|
None => FacadeCounters::increment(&self.counters.contended),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-local bounded-ingress accounting.
|
||||||
|
pub fn health(&self) -> FacadeHealth {
|
||||||
|
self.counters.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves at most one preformatted facade record into the central hub.
|
||||||
|
pub fn drain_one_into<const CRITICAL: usize, const BEST_EFFORT: usize>(
|
||||||
|
&self,
|
||||||
|
hub: &mut EventHub<CRITICAL, BEST_EFFORT>,
|
||||||
|
) -> Option<PublishOutcome> {
|
||||||
|
let record = match self.with_critical(Option::take) {
|
||||||
|
Some(Some(record)) => record,
|
||||||
|
Some(None) => self.with_queue(IngressQueue::pop).flatten()?,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
Some(hub.publish_text(record.severity, record.flags, record.text))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_critical<R>(
|
||||||
|
&self,
|
||||||
|
operation: impl FnOnce(&mut Option<IngressRecord>) -> R,
|
||||||
|
) -> Option<R> {
|
||||||
|
if self
|
||||||
|
.critical_busy
|
||||||
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SAFETY: `critical_busy` grants exclusive access to the one fixed
|
||||||
|
// critical slot. No call to `operation` escapes its mutable reference.
|
||||||
|
let result = operation(unsafe { &mut *self.critical.get() });
|
||||||
|
self.critical_busy.store(false, Ordering::Release);
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_queue<R>(&self, operation: impl FnOnce(&mut IngressQueue<N>) -> R) -> Option<R> {
|
||||||
|
if self
|
||||||
|
.queue_busy
|
||||||
|
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SAFETY: `queue_busy` grants this caller exclusive access. No call
|
||||||
|
// to `operation` escapes the mutable reference and it only performs
|
||||||
|
// fixed-array queue work.
|
||||||
|
let result = operation(unsafe { &mut *self.queue.get() });
|
||||||
|
self.queue_busy.store(false, Ordering::Release);
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installed standard logger. The context probe must return true for
|
||||||
|
/// ISR/panic/hard-fault/shutdown contexts; those calls are rejected before
|
||||||
|
/// [`Record::args`] is formatted.
|
||||||
|
pub struct GlobalLogger<const N: usize> {
|
||||||
|
ingress: &'static FacadeIngress<N>,
|
||||||
|
forbidden_context: fn() -> bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const N: usize> GlobalLogger<N> {
|
||||||
|
/// Creates a logger tied to one static bounded ingress.
|
||||||
|
pub const fn new(ingress: &'static FacadeIngress<N>, forbidden_context: fn() -> bool) -> Self {
|
||||||
|
Self {
|
||||||
|
ingress,
|
||||||
|
forbidden_context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs this logger once for the process/image and enables all Rust
|
||||||
|
/// levels compiled into the image. Kaptan's own filter applies afterward.
|
||||||
|
pub fn install(&'static self) -> Result<(), SetLoggerError> {
|
||||||
|
log::set_logger(self)?;
|
||||||
|
log::set_max_level(LevelFilter::Trace);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const N: usize> Log for GlobalLogger<N> {
|
||||||
|
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
|
||||||
|
if (self.forbidden_context)() {
|
||||||
|
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(severity) = severity(metadata.level()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !self.ingress.allows(severity) {
|
||||||
|
FacadeCounters::increment(&self.ingress.counters.filtered);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record<'_>) {
|
||||||
|
if (self.forbidden_context)() {
|
||||||
|
FacadeCounters::increment(&self.ingress.counters.forbidden_context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(severity) = severity(record.level()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !self.ingress.allows(severity) {
|
||||||
|
FacadeCounters::increment(&self.ingress.counters.filtered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let flags = if record.level() == Level::Trace {
|
||||||
|
LogFlags::TRACE
|
||||||
|
} else {
|
||||||
|
LogFlags::NONE
|
||||||
|
};
|
||||||
|
let text = LogText::from_arguments(format_args!("[{}] {}", record.target(), record.args()));
|
||||||
|
if text.is_truncated() {
|
||||||
|
FacadeCounters::increment(&self.ingress.counters.truncated);
|
||||||
|
}
|
||||||
|
self.ingress.push(IngressRecord {
|
||||||
|
severity,
|
||||||
|
flags,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn severity(level: Level) -> Option<Severity> {
|
||||||
|
match level {
|
||||||
|
Level::Error => Some(Severity::Error),
|
||||||
|
Level::Warn => Some(Severity::Warning),
|
||||||
|
Level::Info => Some(Severity::Info),
|
||||||
|
Level::Debug | Level::Trace => Some(Severity::Debug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use core::fmt;
|
||||||
|
use std::sync::Once;
|
||||||
|
|
||||||
|
use super::{FacadeIngress, GlobalLogger};
|
||||||
|
use crate::{EventHub, LogFilter, RecordChannel, Severity};
|
||||||
|
use log::{Level, Log, Metadata, Record};
|
||||||
|
|
||||||
|
static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
||||||
|
static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden);
|
||||||
|
static INSTALL_GLOBAL: Once = Once::new();
|
||||||
|
|
||||||
|
fn never_forbidden() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn always_forbidden() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_forbidden_context_before_formatting() {
|
||||||
|
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
||||||
|
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
|
||||||
|
let metadata = Metadata::builder()
|
||||||
|
.level(Level::Error)
|
||||||
|
.target("test")
|
||||||
|
.build();
|
||||||
|
assert!(!logger.enabled(&metadata));
|
||||||
|
assert_eq!(INGRESS.health().forbidden_context, 1);
|
||||||
|
|
||||||
|
static FILTERED: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
||||||
|
let filtered_logger = GlobalLogger::new(&FILTERED, never_forbidden);
|
||||||
|
let debug = Metadata::builder()
|
||||||
|
.level(Level::Debug)
|
||||||
|
.target("test")
|
||||||
|
.build();
|
||||||
|
assert!(!filtered_logger.enabled(&debug));
|
||||||
|
assert_eq!(FILTERED.health().filtered, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_once_then_drains_into_priority_hub() {
|
||||||
|
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
||||||
|
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
||||||
|
let record = Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Error)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("link failed"))
|
||||||
|
.build();
|
||||||
|
logger.log(&record);
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
||||||
|
assert!(INGRESS.drain_one_into(&mut hub).is_some());
|
||||||
|
let pending = hub.peek().unwrap();
|
||||||
|
assert_eq!(pending.message().as_str(), "[test] link failed");
|
||||||
|
assert_eq!(pending.severity(), Severity::Error);
|
||||||
|
assert_eq!(pending.channel(), RecordChannel::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MustNotFormat;
|
||||||
|
|
||||||
|
impl fmt::Display for MustNotFormat {
|
||||||
|
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
panic!("forbidden-context log arguments must not format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standard_log_macros_enqueue_bounded_targeted_text() {
|
||||||
|
INSTALL_GLOBAL.call_once(|| GLOBAL_LOGGER.install().expect("one global test logger"));
|
||||||
|
log::info!("armed configuration loaded");
|
||||||
|
let mut hub = EventHub::<1, 2>::new(LogFilter::default());
|
||||||
|
assert!(GLOBAL_INGRESS.drain_one_into(&mut hub).is_some());
|
||||||
|
assert!(
|
||||||
|
hub.peek()
|
||||||
|
.unwrap()
|
||||||
|
.message()
|
||||||
|
.as_str()
|
||||||
|
.ends_with("armed configuration loaded")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forbidden_context_skips_argument_formatting() {
|
||||||
|
static INGRESS: FacadeIngress<2> = FacadeIngress::new(LogFilter::new(Severity::Debug));
|
||||||
|
let logger = GlobalLogger::new(&INGRESS, always_forbidden);
|
||||||
|
logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Error)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("{}", MustNotFormat))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
assert_eq!(INGRESS.health().forbidden_context, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_ingress_exposes_local_drop_and_truncation_accounting() {
|
||||||
|
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
||||||
|
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
||||||
|
logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("x"))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("y"))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
let long = "z".repeat(crate::MAXIMUM_LOG_TEXT_BYTES + 1);
|
||||||
|
logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("{long}"))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
let health = INGRESS.health();
|
||||||
|
assert_eq!(health.full, 2);
|
||||||
|
assert_eq!(health.truncated, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_optional_ingress_retains_an_error_for_the_error_channel() {
|
||||||
|
static INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info));
|
||||||
|
let logger = GlobalLogger::new(&INGRESS, never_forbidden);
|
||||||
|
logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Info)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("optional"))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
logger.log(
|
||||||
|
&Record::builder()
|
||||||
|
.metadata(
|
||||||
|
Metadata::builder()
|
||||||
|
.level(Level::Error)
|
||||||
|
.target("test")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.args(format_args!("critical"))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
||||||
|
assert!(INGRESS.drain_one_into(&mut hub).is_some());
|
||||||
|
let error = hub.peek().expect("error retained ahead of optional text");
|
||||||
|
assert_eq!(error.channel(), RecordChannel::Error);
|
||||||
|
assert_eq!(error.message().as_str(), "[test] critical");
|
||||||
|
assert_eq!(INGRESS.health().full, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//! Bounded log filtering policy.
|
||||||
|
|
||||||
|
use crate::Severity;
|
||||||
|
|
||||||
|
/// Immutable policy for best-effort log admission.
|
||||||
|
///
|
||||||
|
/// Error/critical/vital records are never filtered by this type; callers use
|
||||||
|
/// their dedicated delivery lanes for those records.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct LogFilter {
|
||||||
|
maximum_severity: Severity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogFilter {
|
||||||
|
/// Creates a best-effort verbosity cap.
|
||||||
|
pub const fn new(maximum_severity: Severity) -> Self {
|
||||||
|
Self { maximum_severity }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selected MAVLink-compatible severity cap.
|
||||||
|
///
|
||||||
|
/// The value is exact on the wire: in particular, `NOTICE` does not
|
||||||
|
/// silently permit `INFO` records.
|
||||||
|
pub const fn maximum_severity(self) -> Severity {
|
||||||
|
self.maximum_severity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a best-effort record should be retained.
|
||||||
|
pub const fn allows(self, severity: Severity) -> bool {
|
||||||
|
(severity as u8) <= (self.maximum_severity as u8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(Severity::Info)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
//! Bounded priority handling for fixed human-readable diagnostic records.
|
||||||
|
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
DeliveryClass, ErrorReport, LogFilter, LogFlags, LogRecord, LogText, Severity,
|
||||||
|
StaticErrorMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Outcome from one bounded diagnostic admission attempt.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PublishOutcome {
|
||||||
|
/// The record was retained.
|
||||||
|
Queued,
|
||||||
|
/// An adjacent equivalent record absorbed this occurrence.
|
||||||
|
Coalesced,
|
||||||
|
/// An ordinary log record was excluded by the configured verbosity cap.
|
||||||
|
Filtered,
|
||||||
|
/// A best-effort record was replaced by newer optional information.
|
||||||
|
ReplacedOldest,
|
||||||
|
/// The critical lane kept its earlier record.
|
||||||
|
RejectedFull,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result returned by the sole telemetry owner after attempting to admit a
|
||||||
|
/// record to its lower transport.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TransportAdmission {
|
||||||
|
/// The lower transport retained the record, so it may leave the hub.
|
||||||
|
Accepted,
|
||||||
|
/// The lower transport did not accept it yet; retain it for retry.
|
||||||
|
Retry,
|
||||||
|
/// The lower transport will not accept optional detail. This can only
|
||||||
|
/// discard a best-effort record; critical records remain queued.
|
||||||
|
DropBestEffort,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saturating, process-local accounting for bounded diagnostic admission.
|
||||||
|
///
|
||||||
|
/// These counters intentionally are not part of `KAPTAN_LOG` or
|
||||||
|
/// `KAPTAN_ERROR`: neither wire message is a metrics protocol.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct HubHealth {
|
||||||
|
/// Optional records rejected by the active verbosity cap.
|
||||||
|
pub filtered: u32,
|
||||||
|
/// Adjacent records folded into an existing entry.
|
||||||
|
pub coalesced: u32,
|
||||||
|
/// Optional entries replaced because their bounded lane was full.
|
||||||
|
pub best_effort_replaced: u32,
|
||||||
|
/// Error entries rejected because their bounded critical lane was full.
|
||||||
|
pub critical_rejected: u32,
|
||||||
|
/// Optional entries the lower transport refused permanently.
|
||||||
|
pub best_effort_transport_dropped: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HubHealth {
|
||||||
|
fn increment(value: &mut u32) {
|
||||||
|
*value = value.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lane<const N: usize> {
|
||||||
|
entries: [Option<LogRecord>; N],
|
||||||
|
head: usize,
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const N: usize> Lane<N> {
|
||||||
|
const fn new() -> Self {
|
||||||
|
assert!(N > 0, "diagnostic lanes must be nonempty");
|
||||||
|
Self {
|
||||||
|
entries: [None; N],
|
||||||
|
head: 0,
|
||||||
|
len: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
|
||||||
|
if self.len != 0 {
|
||||||
|
let tail = (self.head + self.len - 1) % N;
|
||||||
|
if let Some(previous) = self.entries[tail].as_mut()
|
||||||
|
&& previous.is_equivalent_to(&record)
|
||||||
|
{
|
||||||
|
previous.increment_occurrences();
|
||||||
|
return PublishOutcome::Coalesced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.len == N {
|
||||||
|
if replace_oldest {
|
||||||
|
self.entries[self.head] = Some(record);
|
||||||
|
self.head = (self.head + 1) % N;
|
||||||
|
return PublishOutcome::ReplacedOldest;
|
||||||
|
}
|
||||||
|
return PublishOutcome::RejectedFull;
|
||||||
|
}
|
||||||
|
let index = (self.head + self.len) % N;
|
||||||
|
self.entries[index] = Some(record);
|
||||||
|
self.len += 1;
|
||||||
|
PublishOutcome::Queued
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(&self) -> Option<LogRecord> {
|
||||||
|
self.entries.get(self.head).copied().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop(&mut self) -> Option<LogRecord> {
|
||||||
|
if self.len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let record = self.entries[self.head].take();
|
||||||
|
self.head = (self.head + 1) % N;
|
||||||
|
self.len -= 1;
|
||||||
|
record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA.
|
||||||
|
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> {
|
||||||
|
filter: LogFilter,
|
||||||
|
critical: Lane<CRITICAL>,
|
||||||
|
best_effort: Lane<BEST_EFFORT>,
|
||||||
|
health: HubHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> {
|
||||||
|
/// Creates one hub with its immutable boot-active verbosity cap.
|
||||||
|
pub const fn new(filter: LogFilter) -> Self {
|
||||||
|
Self {
|
||||||
|
filter,
|
||||||
|
critical: Lane::new(),
|
||||||
|
best_effort: Lane::new(),
|
||||||
|
health: HubHealth {
|
||||||
|
filtered: 0,
|
||||||
|
coalesced: 0,
|
||||||
|
best_effort_replaced: 0,
|
||||||
|
critical_rejected: 0,
|
||||||
|
best_effort_transport_dropped: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable best-effort verbosity policy.
|
||||||
|
pub const fn filter(&self) -> LogFilter {
|
||||||
|
self.filter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-local bounded-admission accounting.
|
||||||
|
pub const fn health(&self) -> HubHealth {
|
||||||
|
self.health
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats and retains one ordinary task/worker diagnostic. Error-level
|
||||||
|
/// severities are emitted through `KAPTAN_ERROR`; lower severities use
|
||||||
|
/// `KAPTAN_LOG`.
|
||||||
|
pub fn publish_arguments(
|
||||||
|
&mut self,
|
||||||
|
severity: Severity,
|
||||||
|
flags: LogFlags,
|
||||||
|
arguments: fmt::Arguments<'_>,
|
||||||
|
) -> PublishOutcome {
|
||||||
|
self.publish_text(severity, flags, LogText::from_arguments(arguments))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retains one static derived error without formatting or exposing source
|
||||||
|
/// values. The adapter maps it to `KAPTAN_ERROR`.
|
||||||
|
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
|
||||||
|
self.publish_error_static(flags, error.error_message())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retains one static mailbox error. This is the ISR-safe counterpart to
|
||||||
|
/// [`Self::publish_error`].
|
||||||
|
pub fn publish_error_static(
|
||||||
|
&mut self,
|
||||||
|
flags: LogFlags,
|
||||||
|
message: &'static StaticErrorMessage,
|
||||||
|
) -> PublishOutcome {
|
||||||
|
self.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
flags,
|
||||||
|
LogText::from_message(message.text()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retains one preformatted bounded text payload. Error-or-higher
|
||||||
|
/// severities are independently retained and sent as `KAPTAN_ERROR`.
|
||||||
|
pub fn publish_text(
|
||||||
|
&mut self,
|
||||||
|
severity: Severity,
|
||||||
|
flags: LogFlags,
|
||||||
|
text: LogText,
|
||||||
|
) -> PublishOutcome {
|
||||||
|
let delivery = if severity <= Severity::Error {
|
||||||
|
DeliveryClass::Critical
|
||||||
|
} else {
|
||||||
|
DeliveryClass::BestEffort
|
||||||
|
};
|
||||||
|
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
|
||||||
|
HubHealth::increment(&mut self.health.filtered);
|
||||||
|
return PublishOutcome::Filtered;
|
||||||
|
}
|
||||||
|
let record = LogRecord::new(severity, flags, text);
|
||||||
|
let outcome = match delivery {
|
||||||
|
DeliveryClass::Critical => self.critical.publish(record, false),
|
||||||
|
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
|
||||||
|
};
|
||||||
|
match outcome {
|
||||||
|
PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced),
|
||||||
|
PublishOutcome::ReplacedOldest => {
|
||||||
|
HubHealth::increment(&mut self.health.best_effort_replaced);
|
||||||
|
}
|
||||||
|
PublishOutcome::RejectedFull => {
|
||||||
|
HubHealth::increment(&mut self.health.critical_rejected);
|
||||||
|
}
|
||||||
|
PublishOutcome::Queued | PublishOutcome::Filtered => {}
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest-priority record without relinquishing it.
|
||||||
|
pub fn peek(&self) -> Option<LogRecord> {
|
||||||
|
self.critical.peek().or_else(|| self.best_effort.peek())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submits the current record through the sole telemetry owner. The hub
|
||||||
|
/// removes it only after `Accepted`, and only permits `DropBestEffort` for
|
||||||
|
/// optional detail. This keeps encoder/admission retry semantics local.
|
||||||
|
pub fn submit_next<R>(
|
||||||
|
&mut self,
|
||||||
|
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
|
||||||
|
) -> Option<R> {
|
||||||
|
let record = self.peek()?;
|
||||||
|
let (admission, result) = submit(&record);
|
||||||
|
match admission {
|
||||||
|
TransportAdmission::Accepted => {
|
||||||
|
let _ = self.pop();
|
||||||
|
}
|
||||||
|
TransportAdmission::DropBestEffort
|
||||||
|
if record.delivery() == DeliveryClass::BestEffort =>
|
||||||
|
{
|
||||||
|
let _ = self.best_effort.pop();
|
||||||
|
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
|
||||||
|
}
|
||||||
|
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop(&mut self) -> Option<LogRecord> {
|
||||||
|
self.critical.pop().or_else(|| self.best_effort.pop())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{EventHub, PublishOutcome, TransportAdmission};
|
||||||
|
use crate::{
|
||||||
|
DeliveryClass, LogFilter, LogFlags, LogText, RecordChannel, Severity, StaticErrorMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_bypass_filter_but_debug_does_not() {
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
|
||||||
|
assert_eq!(
|
||||||
|
hub.publish_text(
|
||||||
|
Severity::Debug,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("debug")
|
||||||
|
),
|
||||||
|
PublishOutcome::Filtered
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hub.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("error")
|
||||||
|
),
|
||||||
|
PublishOutcome::Queued
|
||||||
|
);
|
||||||
|
let record = hub.peek().unwrap();
|
||||||
|
assert_eq!(record.message().as_str(), "error");
|
||||||
|
assert_eq!(record.channel(), RecordChannel::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_messages_and_repeats_are_bounded() {
|
||||||
|
static FAULT: StaticErrorMessage = StaticErrorMessage::new("driver fault");
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
||||||
|
assert_eq!(
|
||||||
|
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
||||||
|
PublishOutcome::Queued
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hub.publish_error_static(LogFlags::NONE, &FAULT),
|
||||||
|
PublishOutcome::Coalesced
|
||||||
|
);
|
||||||
|
let record = hub.peek().unwrap();
|
||||||
|
assert_eq!(record.occurrences, 2);
|
||||||
|
assert!(record.flags().contains(LogFlags::COALESCED));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_retains_critical_and_only_best_effort_can_be_discarded() {
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("fault"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
|
||||||
|
assert_eq!(
|
||||||
|
hub.submit_next(|_| (TransportAdmission::Accepted, ())),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert!(hub.peek().is_none());
|
||||||
|
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Info,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("detail"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
|
||||||
|
Some(())
|
||||||
|
);
|
||||||
|
assert!(hub.peek().is_none());
|
||||||
|
assert_eq!(hub.health().best_effort_transport_dropped, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_losses_are_retained_as_local_saturating_accounting() {
|
||||||
|
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Debug,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("filtered"),
|
||||||
|
);
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Info,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("first optional"),
|
||||||
|
);
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Info,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("newer optional"),
|
||||||
|
);
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("first error"),
|
||||||
|
);
|
||||||
|
let _ = hub.publish_text(
|
||||||
|
Severity::Error,
|
||||||
|
LogFlags::NONE,
|
||||||
|
LogText::from_message("second error"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hub.health(),
|
||||||
|
super::HubHealth {
|
||||||
|
filtered: 1,
|
||||||
|
coalesced: 0,
|
||||||
|
best_effort_replaced: 1,
|
||||||
|
critical_rejected: 1,
|
||||||
|
best_effort_transport_dropped: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Standard log levels and their MAVLink severity projection.
|
||||||
|
|
||||||
|
/// MAVLink-compatible severity. Numeric values deliberately match
|
||||||
|
/// `MAV_SEVERITY`.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum Severity {
|
||||||
|
/// System is unusable.
|
||||||
|
Emergency = 0,
|
||||||
|
/// Immediate action is required.
|
||||||
|
Alert = 1,
|
||||||
|
/// Critical safety or runtime failure.
|
||||||
|
Critical = 2,
|
||||||
|
/// Error-level failure.
|
||||||
|
Error = 3,
|
||||||
|
/// Recoverable warning.
|
||||||
|
Warning = 4,
|
||||||
|
/// Significant normal operation.
|
||||||
|
Notice = 5,
|
||||||
|
/// Informational operation.
|
||||||
|
Info = 6,
|
||||||
|
/// Diagnostic output.
|
||||||
|
Debug = 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Severity {
|
||||||
|
/// Converts the stable MAVLink numeric representation.
|
||||||
|
pub const fn try_from_wire(value: u8) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
0 => Some(Self::Emergency),
|
||||||
|
1 => Some(Self::Alert),
|
||||||
|
2 => Some(Self::Critical),
|
||||||
|
3 => Some(Self::Error),
|
||||||
|
4 => Some(Self::Warning),
|
||||||
|
5 => Some(Self::Notice),
|
||||||
|
6 => Some(Self::Info),
|
||||||
|
7 => Some(Self::Debug),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Bounded, transport-neutral runtime diagnostics for Kaptan.
|
||||||
|
//!
|
||||||
|
//! This crate deliberately contains no MAVLink, board, filesystem, clock, or
|
||||||
|
//! configuration dependency. Flight code reports bounded text records to a
|
||||||
|
//! hub; a platform-owned adapter may later encode them for a link.
|
||||||
|
//! Reporting is observational and must never influence flight control or
|
||||||
|
//! actuator authority.
|
||||||
|
|
||||||
|
#![no_std]
|
||||||
|
|
||||||
|
extern crate self as kaptan_log;
|
||||||
|
#[cfg(test)]
|
||||||
|
extern crate std;
|
||||||
|
|
||||||
|
pub mod crash;
|
||||||
|
#[cfg(feature = "facade")]
|
||||||
|
pub mod facade;
|
||||||
|
pub mod filter;
|
||||||
|
pub mod hub;
|
||||||
|
pub mod level;
|
||||||
|
pub mod mailbox;
|
||||||
|
pub mod report;
|
||||||
|
pub mod shutdown;
|
||||||
|
pub mod text;
|
||||||
|
|
||||||
|
pub use crash::{CrashKind, CrashRecord, CrashRecordError};
|
||||||
|
pub use filter::LogFilter;
|
||||||
|
pub use hub::{EventHub, HubHealth, PublishOutcome, TransportAdmission};
|
||||||
|
pub use kaptan_log_derive::KaptanError;
|
||||||
|
pub use level::Severity;
|
||||||
|
pub use mailbox::StaticErrorMailbox;
|
||||||
|
pub use report::{
|
||||||
|
DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticErrorMessage,
|
||||||
|
};
|
||||||
|
pub use shutdown::{ShutdownReason, ShutdownReport};
|
||||||
|
pub use text::{LogText, MAXIMUM_LOG_TEXT_BYTES};
|
||||||
|
|
||||||
|
#[cfg(feature = "facade")]
|
||||||
|
pub use facade::FacadeHealth;
|
||||||
|
#[cfg(feature = "facade")]
|
||||||
|
pub use facade::{FacadeIngress, GlobalLogger};
|
||||||
|
/// Re-export of the standard Rust logging facade when an image opts into the
|
||||||
|
/// bounded atomic ingress.
|
||||||
|
#[cfg(feature = "facade")]
|
||||||
|
pub use log;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! Fixed atomic handoff for static derived error messages.
|
||||||
|
|
||||||
|
use core::{
|
||||||
|
ptr,
|
||||||
|
sync::atomic::{AtomicBool, AtomicPtr, AtomicU32, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::StaticErrorMessage;
|
||||||
|
|
||||||
|
/// One source-local, single-attempt static-fault mailbox.
|
||||||
|
///
|
||||||
|
/// Producer contexts retain the first pending message and mark later ones as
|
||||||
|
/// coalesced. They never loop, format, allocate, lock, or encode a frame. A
|
||||||
|
/// worker drains the exact message pointer and flag later.
|
||||||
|
pub struct StaticErrorMailbox {
|
||||||
|
message: AtomicPtr<StaticErrorMessage>,
|
||||||
|
coalesced: AtomicBool,
|
||||||
|
coalesced_total: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticErrorMailbox {
|
||||||
|
/// Creates an empty mailbox.
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
message: AtomicPtr::new(ptr::null_mut()),
|
||||||
|
coalesced: AtomicBool::new(false),
|
||||||
|
coalesced_total: AtomicU32::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records one static message with one bounded atomic attempt.
|
||||||
|
pub fn record(&self, message: &'static StaticErrorMessage) {
|
||||||
|
if self
|
||||||
|
.message
|
||||||
|
.compare_exchange(
|
||||||
|
ptr::null_mut(),
|
||||||
|
core::ptr::from_ref(message).cast_mut(),
|
||||||
|
Ordering::AcqRel,
|
||||||
|
Ordering::Acquire,
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
self.coalesced.store(true, Ordering::Relaxed);
|
||||||
|
increment_saturating(&self.coalesced_total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the retained first message and its coalesced marker.
|
||||||
|
pub fn take(&self) -> (Option<&'static StaticErrorMessage>, bool) {
|
||||||
|
let message = self.message.swap(ptr::null_mut(), Ordering::AcqRel);
|
||||||
|
let coalesced = self.coalesced.swap(false, Ordering::AcqRel);
|
||||||
|
// SAFETY: `record` stores only pointers to static messages and the
|
||||||
|
// atomic swap transfers the exact pointer to this one worker.
|
||||||
|
let message = unsafe { message.as_ref() };
|
||||||
|
(message, coalesced)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total later-fault coalesces since startup. This is local producer
|
||||||
|
/// accounting; wire diagnostics remain human-facing envelopes.
|
||||||
|
pub fn coalesced_total(&self) -> u32 {
|
||||||
|
self.coalesced_total.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bounded atomic increment: diagnostics may undercount a simultaneous
|
||||||
|
/// producer collision but never loop, block, or wrap back to zero.
|
||||||
|
fn increment_saturating(counter: &AtomicU32) {
|
||||||
|
let current = counter.load(Ordering::Relaxed);
|
||||||
|
if current != u32::MAX {
|
||||||
|
let _ =
|
||||||
|
counter.compare_exchange(current, current + 1, Ordering::Relaxed, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StaticErrorMailbox {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::StaticErrorMailbox;
|
||||||
|
use crate::StaticErrorMessage;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_mailbox_keeps_the_first_static_message_and_marks_later_faults() {
|
||||||
|
static FIRST: StaticErrorMessage = StaticErrorMessage::new("first");
|
||||||
|
static SECOND: StaticErrorMessage = StaticErrorMessage::new("second");
|
||||||
|
let mailbox = StaticErrorMailbox::new();
|
||||||
|
mailbox.record(&FIRST);
|
||||||
|
mailbox.record(&SECOND);
|
||||||
|
assert_eq!(mailbox.take(), (Some(&FIRST), true));
|
||||||
|
assert_eq!(mailbox.coalesced_total(), 1);
|
||||||
|
assert_eq!(mailbox.take(), (None, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! Bounded human-readable diagnostic records.
|
||||||
|
|
||||||
|
use crate::{LogText, Severity};
|
||||||
|
|
||||||
|
/// Static message generated for one error type/variant.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct StaticErrorMessage {
|
||||||
|
text: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaticErrorMessage {
|
||||||
|
/// Creates one compile-time error message.
|
||||||
|
pub const fn new(text: &'static str) -> Self {
|
||||||
|
Self { text }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable human-readable message text.
|
||||||
|
pub const fn text(self) -> &'static str {
|
||||||
|
self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alias used by the error derive's [`core::fmt::Display`] implementation.
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
self.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implemented by [`crate::KaptanError`] derives.
|
||||||
|
///
|
||||||
|
/// This trait is deliberately pure: obtaining a message never allocates,
|
||||||
|
/// publishes telemetry, changes safety state, or serializes generic sources.
|
||||||
|
pub trait ErrorReport {
|
||||||
|
/// Returns the static type/variant message for this error value.
|
||||||
|
fn error_message(&self) -> &'static StaticErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded MAVLink log flags.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(transparent)]
|
||||||
|
pub struct LogFlags(u8);
|
||||||
|
|
||||||
|
impl LogFlags {
|
||||||
|
/// No optional flags.
|
||||||
|
pub const NONE: Self = Self(0);
|
||||||
|
/// Rust trace level, encoded as MAVLink DEBUG.
|
||||||
|
pub const TRACE: Self = Self(1 << 0);
|
||||||
|
/// Text did not fit the fixed record buffer.
|
||||||
|
pub const TRUNCATED: Self = Self(1 << 1);
|
||||||
|
/// Adjacent equivalent records were coalesced.
|
||||||
|
pub const COALESCED: Self = Self(1 << 2);
|
||||||
|
|
||||||
|
/// Raw MAVLink bitmask.
|
||||||
|
pub const fn bits(self) -> u8 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bits valid for an error-only record. Trace is a property of ordinary
|
||||||
|
/// `KAPTAN_LOG` diagnostics and is intentionally never emitted on
|
||||||
|
/// `KAPTAN_ERROR`.
|
||||||
|
pub const fn error_bits(self) -> u8 {
|
||||||
|
self.0 & (Self::TRUNCATED.0 | Self::COALESCED.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a bit.
|
||||||
|
pub const fn with(self, other: Self) -> Self {
|
||||||
|
Self(self.0 | other.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests whether a bit is present.
|
||||||
|
pub const fn contains(self, other: Self) -> bool {
|
||||||
|
self.0 & other.0 == other.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire family selected for one diagnostic record.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum RecordChannel {
|
||||||
|
/// An ordinary severity-and-text `KAPTAN_LOG` record.
|
||||||
|
Log,
|
||||||
|
/// An error-only `KAPTAN_ERROR` record.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Priority selected internally from a diagnostic level.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DeliveryClass {
|
||||||
|
/// Error logs retained independently from optional log verbosity.
|
||||||
|
Critical,
|
||||||
|
/// Warning/info/debug/trace logs subject to the configured filter.
|
||||||
|
BestEffort,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fully formatted bounded diagnostic waiting for MAVLink admission.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct LogRecord {
|
||||||
|
severity: Severity,
|
||||||
|
flags: LogFlags,
|
||||||
|
message: LogText,
|
||||||
|
channel: RecordChannel,
|
||||||
|
/// Adjacent identical messages retained as one record.
|
||||||
|
pub occurrences: u32,
|
||||||
|
delivery: DeliveryClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogRecord {
|
||||||
|
/// Creates one bounded record. Error-or-higher severities use
|
||||||
|
/// [`RecordChannel::Error`]; ordinary severity records use
|
||||||
|
/// [`RecordChannel::Log`].
|
||||||
|
pub fn new(severity: Severity, mut flags: LogFlags, message: LogText) -> Self {
|
||||||
|
if message.is_truncated() {
|
||||||
|
flags = flags.with(LogFlags::TRUNCATED);
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
severity,
|
||||||
|
flags,
|
||||||
|
message,
|
||||||
|
channel: if severity <= Severity::Error {
|
||||||
|
RecordChannel::Error
|
||||||
|
} else {
|
||||||
|
RecordChannel::Log
|
||||||
|
},
|
||||||
|
occurrences: 1,
|
||||||
|
delivery: if severity <= Severity::Error {
|
||||||
|
DeliveryClass::Critical
|
||||||
|
} else {
|
||||||
|
DeliveryClass::BestEffort
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire-compatible severity.
|
||||||
|
pub const fn severity(&self) -> Severity {
|
||||||
|
self.severity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire flag bitmask.
|
||||||
|
pub const fn flags(&self) -> LogFlags {
|
||||||
|
self.flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed formatted text.
|
||||||
|
pub const fn message(&self) -> &LogText {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom MAVLink diagnostic family selected for this record.
|
||||||
|
pub const fn channel(&self) -> RecordChannel {
|
||||||
|
self.channel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal admission priority.
|
||||||
|
pub const fn delivery(&self) -> DeliveryClass {
|
||||||
|
self.delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tests whether two pending records can coalesce without hiding a new
|
||||||
|
/// message, severity, or delivery class.
|
||||||
|
pub fn is_equivalent_to(&self, other: &Self) -> bool {
|
||||||
|
self.channel == other.channel
|
||||||
|
&& self.severity == other.severity
|
||||||
|
&& self.flags == other.flags
|
||||||
|
&& self.message == other.message
|
||||||
|
&& self.delivery == other.delivery
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn increment_occurrences(&mut self) {
|
||||||
|
self.occurrences = self.occurrences.saturating_add(1);
|
||||||
|
self.flags = self.flags.with(LogFlags::COALESCED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Explicit shutdown taxonomy for a future platform-owned shutdown state machine.
|
||||||
|
|
||||||
|
/// Why an orderly output-inhibited shutdown completed.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum ShutdownReason {
|
||||||
|
/// An operator initiated the controlled stop.
|
||||||
|
Operator = 1,
|
||||||
|
/// Safety initiated the controlled stop.
|
||||||
|
Safety = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One completed orderly firmware shutdown report.
|
||||||
|
///
|
||||||
|
/// This is a typed terminal-state observation from a future platform-owned
|
||||||
|
/// shutdown coordinator. It is emitted only after that coordinator has
|
||||||
|
/// inhibited outputs. It is deliberately not a shutdown request or a panic,
|
||||||
|
/// hard-fault, or reset report.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ShutdownReport {
|
||||||
|
reason: ShutdownReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShutdownReport {
|
||||||
|
/// Creates one completed orderly shutdown report.
|
||||||
|
pub const fn new(reason: ShutdownReason) -> Self {
|
||||||
|
Self { reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completed orderly shutdown reason.
|
||||||
|
pub const fn reason(self) -> ShutdownReason {
|
||||||
|
self.reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! Fixed-capacity UTF-8 log text.
|
||||||
|
|
||||||
|
use core::fmt::{self, Write};
|
||||||
|
|
||||||
|
/// Maximum UTF-8 bytes carried by one Kaptan log record.
|
||||||
|
pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
|
||||||
|
|
||||||
|
/// Bounded formatted text with an explicit truncation marker.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct LogText {
|
||||||
|
bytes: [u8; MAXIMUM_LOG_TEXT_BYTES],
|
||||||
|
length: u8,
|
||||||
|
truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogText {
|
||||||
|
/// Creates an empty bounded log message.
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: [0; MAXIMUM_LOG_TEXT_BYTES],
|
||||||
|
length: 0,
|
||||||
|
truncated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats one message into fixed storage without allocation.
|
||||||
|
pub fn from_arguments(arguments: fmt::Arguments<'_>) -> Self {
|
||||||
|
let mut text = Self::new();
|
||||||
|
let _ = fmt::write(&mut text, arguments);
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies one UTF-8 message into fixed storage without splitting a code
|
||||||
|
/// point at the capacity boundary.
|
||||||
|
pub fn from_message(message: &str) -> Self {
|
||||||
|
let mut text = Self::new();
|
||||||
|
let _ = text.write_str(message);
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw bounded wire bytes.
|
||||||
|
pub const fn bytes(&self) -> &[u8; MAXIMUM_LOG_TEXT_BYTES] {
|
||||||
|
&self.bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of meaningful UTF-8 bytes.
|
||||||
|
pub const fn len(&self) -> u8 {
|
||||||
|
self.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this record contains no message bytes.
|
||||||
|
pub const fn is_empty(&self) -> bool {
|
||||||
|
self.length == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the original message exceeded bounded storage.
|
||||||
|
pub const fn is_truncated(&self) -> bool {
|
||||||
|
self.truncated
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the valid UTF-8 prefix.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
// `write_str` copies only valid UTF-8 code-point boundaries.
|
||||||
|
// SAFETY: all populated bytes originated from an input `&str`.
|
||||||
|
unsafe { core::str::from_utf8_unchecked(&self.bytes[..usize::from(self.length)]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LogText {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Write for LogText {
|
||||||
|
fn write_str(&mut self, message: &str) -> fmt::Result {
|
||||||
|
let used = usize::from(self.length);
|
||||||
|
let remaining = MAXIMUM_LOG_TEXT_BYTES - used;
|
||||||
|
if message.len() <= remaining {
|
||||||
|
self.bytes[used..used + message.len()].copy_from_slice(message.as_bytes());
|
||||||
|
self.length = (used + message.len()) as u8;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix = message
|
||||||
|
.char_indices()
|
||||||
|
.take_while(|(index, character)| index + character.len_utf8() <= remaining)
|
||||||
|
.map(|(index, character)| index + character.len_utf8())
|
||||||
|
.last()
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.bytes[used..used + prefix].copy_from_slice(&message.as_bytes()[..prefix]);
|
||||||
|
self.length = (used + prefix) as u8;
|
||||||
|
self.truncated = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{LogText, MAXIMUM_LOG_TEXT_BYTES};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncates_only_at_utf8_boundaries() {
|
||||||
|
let input = "x".repeat(MAXIMUM_LOG_TEXT_BYTES - 1) + "é";
|
||||||
|
let text = LogText::from_message(&input);
|
||||||
|
assert!(text.is_truncated());
|
||||||
|
assert_eq!(text.as_str().len(), MAXIMUM_LOG_TEXT_BYTES - 1);
|
||||||
|
assert!(core::str::from_utf8(&text.bytes()[..usize::from(text.len())]).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,6 @@ version.workspace = true
|
|||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
@@ -7,13 +7,14 @@
|
|||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
use core::marker::PhantomData;
|
use core::marker::PhantomData;
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
|
|
||||||
/// Failure reported by a checked mathematical operation.
|
/// Failure reported by a checked mathematical operation.
|
||||||
///
|
///
|
||||||
/// The flight path must treat one of these as a rejected input or an estimator
|
/// The flight path must treat one of these as a rejected input or an estimator
|
||||||
/// failure. In particular, no quaternion constructor converts an error into
|
/// failure. In particular, no quaternion constructor converts an error into
|
||||||
/// the identity rotation.
|
/// the identity rotation.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum MathError {
|
pub enum MathError {
|
||||||
/// An operand or intermediate value was not finite.
|
/// An operand or intermediate value was not finite.
|
||||||
NonFinite,
|
NonFinite,
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ sensor-telemetry = ["dep:kaptan-sensor"]
|
|||||||
dialect-kaptan = []
|
dialect-kaptan = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-dma = { path = "../kaptan-dma" }
|
kaptan-dma = { path = "../kaptan-dma" }
|
||||||
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
|
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
|
||||||
kaptan-tx = { path = "../kaptan-tx" }
|
kaptan-tx = { path = "../kaptan-tx" }
|
||||||
mavlink = { version = "0.18", default-features = false, features = ["embedded", "dialect-common"] }
|
mavlink = { version = "0.18", default-features = false, features = ["embedded", "dialect-common"] }
|
||||||
mavlink-core = { version = "0.18", default-features = false, features = ["embedded"] }
|
mavlink-core = { version = "0.18", default-features = false, features = ["embedded"] }
|
||||||
|
bitflags = "2"
|
||||||
|
num-derive = "0.4"
|
||||||
|
num-traits = { version = "0.2", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
kaptan-time = { path = "../kaptan-time" }
|
kaptan-time = { path = "../kaptan-time" }
|
||||||
|
|||||||
@@ -3,17 +3,23 @@ use std::path::PathBuf;
|
|||||||
fn main() {
|
fn main() {
|
||||||
let manifest_directory = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
let manifest_directory = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||||
let dialect = manifest_directory.join("definitions/kaptan.xml");
|
let dialect = manifest_directory.join("definitions/kaptan.xml");
|
||||||
let output_directory = std::env::var("OUT_DIR").unwrap();
|
let log = manifest_directory.join("definitions/kaptan/log.xml");
|
||||||
let dialect_source = std::fs::read_to_string(&dialect).unwrap();
|
for definition in [&dialect, &log] {
|
||||||
let generated = mavlink_bindgen::generate(
|
println!("cargo::rerun-if-changed={}", definition.display());
|
||||||
mavlink_bindgen::XmlDefinitions::Files(vec![dialect]),
|
}
|
||||||
output_directory,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
mavlink_bindgen::emit_cargo_build_messages(&generated);
|
|
||||||
println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)");
|
println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)");
|
||||||
if dialect_source.contains("<message ") {
|
// The upstream generator conditionally emits derives for these optional
|
||||||
|
// ecosystem crates. Kaptan deliberately does not expose those incomplete
|
||||||
|
// features, so tell rustc the generated inactive cfgs are intentional.
|
||||||
|
println!("cargo::rustc-check-cfg=cfg(feature, values(\"serde\", \"arbitrary\", \"ts-rs\"))");
|
||||||
|
if std::env::var_os("CARGO_FEATURE_DIALECT_KAPTAN").is_some() {
|
||||||
|
let output_directory = std::env::var("OUT_DIR").unwrap();
|
||||||
|
let generated = mavlink_bindgen::generate(
|
||||||
|
mavlink_bindgen::XmlDefinitions::Files(vec![dialect]),
|
||||||
|
output_directory,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
mavlink_bindgen::emit_cargo_build_messages(&generated);
|
||||||
println!("cargo::rustc-cfg=kaptan_has_messages");
|
println!("cargo::rustc-cfg=kaptan_has_messages");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<mavlink>
|
<mavlink>
|
||||||
<version>3</version>
|
<!-- The root is the only bindgen input. Feature domains remain independent
|
||||||
<dialect>0</dialect>
|
fragments so their message catalogs can evolve without a monolith. -->
|
||||||
|
<include>kaptan/log.xml</include>
|
||||||
<!-- Special enums and messages for Kaptan. -->
|
|
||||||
<enums/>
|
|
||||||
<messages/>
|
|
||||||
</mavlink>
|
</mavlink>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<mavlink>
|
||||||
|
<version>3</version>
|
||||||
|
<dialect>0</dialect>
|
||||||
|
<!-- Kaptan-reserved development block. Register these IDs before release. -->
|
||||||
|
<enums>
|
||||||
|
<enum name="KAPTAN_LOG_FLAGS" bitmask="true">
|
||||||
|
<entry value="1" name="KAPTAN_LOG_FLAG_TRACE">
|
||||||
|
<description>Record originated at Rust trace level and uses MAVLink DEBUG severity.</description>
|
||||||
|
</entry>
|
||||||
|
<entry value="2" name="KAPTAN_LOG_FLAG_TRUNCATED">
|
||||||
|
<description>Message text was truncated to the bounded on-wire buffer.</description>
|
||||||
|
</entry>
|
||||||
|
<entry value="4" name="KAPTAN_LOG_FLAG_COALESCED">
|
||||||
|
<description>Message represents repeated adjacent observations.</description>
|
||||||
|
</entry>
|
||||||
|
</enum>
|
||||||
|
<enum name="KAPTAN_ERROR_FLAGS" bitmask="true">
|
||||||
|
<entry value="2" name="KAPTAN_ERROR_FLAG_TRUNCATED">
|
||||||
|
<description>Error display text was truncated to the bounded on-wire buffer.</description>
|
||||||
|
</entry>
|
||||||
|
<entry value="4" name="KAPTAN_ERROR_FLAG_COALESCED">
|
||||||
|
<description>Error represents repeated adjacent observations.</description>
|
||||||
|
</entry>
|
||||||
|
</enum>
|
||||||
|
<enum name="KAPTAN_SHUTDOWN_REASON">
|
||||||
|
<entry value="1" name="KAPTAN_SHUTDOWN_REASON_OPERATOR">
|
||||||
|
<description>An operator initiated the completed controlled firmware shutdown.</description>
|
||||||
|
</entry>
|
||||||
|
<entry value="2" name="KAPTAN_SHUTDOWN_REASON_SAFETY">
|
||||||
|
<description>The safety owner initiated the completed controlled firmware shutdown.</description>
|
||||||
|
</entry>
|
||||||
|
</enum>
|
||||||
|
</enums>
|
||||||
|
<messages>
|
||||||
|
<message id="51020" name="KAPTAN_LOG">
|
||||||
|
<description>Bounded human-readable runtime log. The receiver needs only level, flags, and the valid UTF-8 text prefix. MAVLink framing, bounded admission, DMA, retries, and local loss accounting remain platform-owned.</description>
|
||||||
|
<field type="uint8_t" name="level">MAV_SEVERITY-compatible level. Rust TRACE uses DEBUG plus the TRACE flag.</field>
|
||||||
|
<field type="uint8_t" name="flags" enum="KAPTAN_LOG_FLAGS">Known bits are TRACE=1, TRUNCATED=2, and COALESCED=4. Receivers ignore unknown future bits.</field>
|
||||||
|
<field type="uint8_t" name="message_length">Number of valid UTF-8 bytes in message; at most 160.</field>
|
||||||
|
<field type="uint8_t[160]" name="message">Bounded UTF-8 message bytes. Bytes after message_length are zero.</field>
|
||||||
|
</message>
|
||||||
|
<message id="51021" name="KAPTAN_ERROR">
|
||||||
|
<description>Bounded derived or explicit runtime error. This is distinct from ordinary KAPTAN_LOG records. The display identity is generated from the Rust error type and variant when a KaptanError-derived value is available; structured configuration failures remain authoritative in KAPTAN_CONFIG_RESULT.</description>
|
||||||
|
<field type="uint8_t" name="flags" enum="KAPTAN_ERROR_FLAGS">Known bits are TRUNCATED=2 and COALESCED=4. Receivers ignore unknown future bits.</field>
|
||||||
|
<field type="uint8_t" name="message_length">Number of valid UTF-8 bytes in message; at most 160.</field>
|
||||||
|
<field type="uint8_t[160]" name="message">Bounded UTF-8 error display identity. Bytes after message_length are zero.</field>
|
||||||
|
</message>
|
||||||
|
<message id="51022" name="KAPTAN_SHUTDOWN">
|
||||||
|
<description>Typed completed orderly firmware shutdown observation. It is emitted only after a platform coordinator has inhibited outputs. It is not a log record, a shutdown request, or a panic/hard-fault/reset report.</description>
|
||||||
|
<field type="uint8_t" name="reason" enum="KAPTAN_SHUTDOWN_REASON">Typed shutdown reason.</field>
|
||||||
|
</message>
|
||||||
|
</messages>
|
||||||
|
</mavlink>
|
||||||
@@ -56,6 +56,13 @@ impl MavlinkEncoder {
|
|||||||
raw
|
raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encodes one generated MAVLink data record through this endpoint's sole
|
||||||
|
/// header-sequence owner. Runtime-log/config adapters return data records
|
||||||
|
/// and must use this method rather than create a competing encoder.
|
||||||
|
pub fn custom<M: MessageData>(&mut self, message: &M) -> MavlinkFrame {
|
||||||
|
self.serialize(message)
|
||||||
|
}
|
||||||
|
|
||||||
/// Encodes a board-I/O heartbeat with its current arming state.
|
/// Encodes a board-I/O heartbeat with its current arming state.
|
||||||
pub fn heartbeat(&mut self, system_status: message::MavState, armed: bool) -> MavlinkFrame {
|
pub fn heartbeat(&mut self, system_status: message::MavState, armed: bool) -> MavlinkFrame {
|
||||||
let heartbeat = message::HEARTBEAT_DATA {
|
let heartbeat = message::HEARTBEAT_DATA {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ pub type MavlinkFrame = message::MAVLinkV2MessageRaw;
|
|||||||
pub mod message {
|
pub mod message {
|
||||||
pub use mavlink::MAVLinkV2MessageRaw;
|
pub use mavlink::MAVLinkV2MessageRaw;
|
||||||
pub use mavlink::MavHeader;
|
pub use mavlink::MavHeader;
|
||||||
|
pub use mavlink::Message;
|
||||||
|
pub use mavlink::MessageData;
|
||||||
pub use mavlink::dialects::common::*;
|
pub use mavlink::dialects::common::*;
|
||||||
|
|
||||||
#[cfg(kaptan_has_messages)]
|
#[cfg(kaptan_has_messages)]
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ impl<const N: usize> MavlinkReceiver<N> {
|
|||||||
let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new());
|
let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new());
|
||||||
self.received = 0;
|
self.received = 0;
|
||||||
self.expected = 0;
|
self.expected = 0;
|
||||||
if !frame.has_valid_crc::<message::MavMessage>() {
|
// The production receiver still drops every inbound frame until
|
||||||
|
// signing/replay verification exists. Validate either known
|
||||||
|
// dialect here so a future verifier receives no CRC-corrupt
|
||||||
|
// custom configuration message.
|
||||||
|
if !has_valid_known_crc(&frame) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +114,18 @@ impl<const N: usize> MavlinkReceiver<N> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_valid_known_crc(frame: &MavlinkFrame) -> bool {
|
||||||
|
let common_valid = frame.has_valid_crc::<message::MavMessage>();
|
||||||
|
#[cfg(feature = "dialect-kaptan")]
|
||||||
|
{
|
||||||
|
common_valid || frame.has_valid_crc::<message::kaptan::MavMessage>()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "dialect-kaptan"))]
|
||||||
|
{
|
||||||
|
common_valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::MavlinkReceiver;
|
use super::MavlinkReceiver;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! MAVLink frame encoding and UART transmission API.
|
//! MAVLink frame encoding and UART transmission API.
|
||||||
|
|
||||||
use kaptan_dma::{DmaTxBufferFactory, DmaTxEngine, TxCompletion};
|
use kaptan_dma::{DmaTxBufferFactory, DmaTxEngine, TxCompletion};
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
use kaptan_tx::{PendingFrame, TransmitQueue};
|
use kaptan_tx::{PendingFrame, TransmitQueue};
|
||||||
|
|
||||||
use crate::MavlinkFrame;
|
use crate::MavlinkFrame;
|
||||||
@@ -9,7 +10,7 @@ use crate::MavlinkFrame;
|
|||||||
pub use kaptan_tx::{EnqueueResult, FrameClass};
|
pub use kaptan_tx::{EnqueueResult, FrameClass};
|
||||||
|
|
||||||
/// Failure while preparing or starting a MAVLink frame transfer.
|
/// Failure while preparing or starting a MAVLink frame transfer.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, KaptanError)]
|
||||||
pub enum MavlinkTransmitFault<MemoryError, EngineError> {
|
pub enum MavlinkTransmitFault<MemoryError, EngineError> {
|
||||||
/// DMA-visible frame preparation failed.
|
/// DMA-visible frame preparation failed.
|
||||||
Memory(MemoryError),
|
Memory(MemoryError),
|
||||||
@@ -17,6 +18,40 @@ pub enum MavlinkTransmitFault<MemoryError, EngineError> {
|
|||||||
Engine(EngineError),
|
Engine(EngineError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transport-neutral class of a terminal link fault.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TransmitFaultKind {
|
||||||
|
/// DMA-visible frame preparation failed.
|
||||||
|
Memory,
|
||||||
|
/// The UART DMA engine rejected a transfer start.
|
||||||
|
Engine,
|
||||||
|
/// An active DMA transfer ended with a terminal fault or halt.
|
||||||
|
Terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<MemoryError, EngineError> MavlinkTransmitFault<MemoryError, EngineError> {
|
||||||
|
/// Returns the bounded category a platform may convert to a static log
|
||||||
|
/// message without exposing a generic HAL error value on the wire.
|
||||||
|
pub const fn kind(&self) -> TransmitFaultKind {
|
||||||
|
match self {
|
||||||
|
Self::Memory(_) => TransmitFaultKind::Memory,
|
||||||
|
Self::Engine(_) => TransmitFaultKind::Engine,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined bounded-admission and DMA-start outcome.
|
||||||
|
///
|
||||||
|
/// The two outcomes stay distinct so a platform can account for a failed DMA
|
||||||
|
/// start without falsely treating a queue-full source frame as retained.
|
||||||
|
#[must_use]
|
||||||
|
pub struct AdmissionStart<MemoryError, EngineError> {
|
||||||
|
/// Queue admission for the source frame.
|
||||||
|
pub admission: EnqueueResult,
|
||||||
|
/// One bounded attempt to start pending DMA work.
|
||||||
|
pub start: Result<(), MavlinkTransmitFault<MemoryError, EngineError>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Owns MAVLink frame admission and UART DMA lifecycle.
|
/// Owns MAVLink frame admission and UART DMA lifecycle.
|
||||||
///
|
///
|
||||||
/// Exactly one worker invokes this type. It accepts local encoded frames and
|
/// Exactly one worker invokes this type. It accepts local encoded frames and
|
||||||
@@ -54,8 +89,23 @@ where
|
|||||||
class: FrameClass,
|
class: FrameClass,
|
||||||
frame: MavlinkFrame,
|
frame: MavlinkFrame,
|
||||||
) -> Result<EnqueueResult, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
|
) -> Result<EnqueueResult, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
|
||||||
|
let outcome = self.admit_and_start(class, frame);
|
||||||
|
outcome.start.map(|()| outcome.admission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admits one frame and attempts one bounded DMA start while preserving
|
||||||
|
/// both outcomes. Platform policy uses this to account for a failed DMA
|
||||||
|
/// start without mistaking an admission rejection for a retained frame.
|
||||||
|
/// A start failure retains the selected frame in the queue reservation for
|
||||||
|
/// a later service-turn retry.
|
||||||
|
pub fn admit_and_start(
|
||||||
|
&mut self,
|
||||||
|
class: FrameClass,
|
||||||
|
frame: MavlinkFrame,
|
||||||
|
) -> AdmissionStart<Memory::Error, Engine::StartError> {
|
||||||
let admission = self.enqueue_frame(class, frame);
|
let admission = self.enqueue_frame(class, frame);
|
||||||
self.start_pending().map(|()| admission)
|
let start = self.start_pending();
|
||||||
|
AdmissionStart { admission, start }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admits a frame without attempting to start the DMA engine.
|
/// Admits a frame without attempting to start the DMA engine.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ default = []
|
|||||||
# executable. It is not part of a board build.
|
# executable. It is not part of a board build.
|
||||||
sitl = ["dep:kaptan-sensor"]
|
sitl = ["dep:kaptan-sensor"]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-math = { path = "../kaptan-math" }
|
kaptan-math = { path = "../kaptan-math" }
|
||||||
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
|
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
|
||||||
kaptan-time = { path = "../kaptan-time" }
|
kaptan-time = { path = "../kaptan-time" }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Fixed-size multiplicative error-state Kalman filter.
|
//! Fixed-size multiplicative error-state Kalman filter.
|
||||||
|
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
use kaptan_math::{Frd, MathError, Ned, Sensor, UnitQuaternion, Vector3, sqrt};
|
use kaptan_math::{Frd, MathError, Ned, Sensor, UnitQuaternion, Vector3, sqrt};
|
||||||
use kaptan_time::Timestamp;
|
use kaptan_time::Timestamp;
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ pub struct EstimatorHealth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Propagation failure that leaves the nominal state unchanged.
|
/// Propagation failure that leaves the nominal state unchanged.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum PropagationError {
|
pub enum PropagationError {
|
||||||
/// An IMU observation was invalid or was not calibrated into FRD.
|
/// An IMU observation was invalid or was not calibrated into FRD.
|
||||||
InvalidImu,
|
InvalidImu,
|
||||||
@@ -228,7 +229,7 @@ pub enum PropagationError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Aiding fusion refusal with no nominal-state update.
|
/// Aiding fusion refusal with no nominal-state update.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum FusionError {
|
pub enum FusionError {
|
||||||
/// Observation is not at this filter's current history time.
|
/// Observation is not at this filter's current history time.
|
||||||
TimestampMismatch,
|
TimestampMismatch,
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ version.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
kaptan-log = { path = "../kaptan-log" }
|
||||||
kaptan-time = { path = "../kaptan-time" }
|
kaptan-time = { path = "../kaptan-time" }
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#![no_std]
|
#![no_std]
|
||||||
|
|
||||||
|
use kaptan_log::KaptanError;
|
||||||
use kaptan_time::Timestamp;
|
use kaptan_time::Timestamp;
|
||||||
|
|
||||||
/// Supervisor arming state.
|
/// Supervisor arming state.
|
||||||
@@ -23,7 +24,7 @@ pub enum SafetyState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Typed reason for refusing or ending actuator authorization.
|
/// Typed reason for refusing or ending actuator authorization.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum SafetyFailure {
|
pub enum SafetyFailure {
|
||||||
/// Estimator is absent, stale, or non-finite.
|
/// Estimator is absent, stale, or non-finite.
|
||||||
EstimatorUnhealthy,
|
EstimatorUnhealthy,
|
||||||
@@ -42,7 +43,7 @@ pub enum SafetyFailure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Invalid safety configuration.
|
/// Invalid safety configuration.
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
|
||||||
pub enum SafetyConfigError {
|
pub enum SafetyConfigError {
|
||||||
/// A zero dwell would permit two evaluations at one timestamp to arm.
|
/// A zero dwell would permit two evaluations at one timestamp to arm.
|
||||||
ZeroPrearmDwell,
|
ZeroPrearmDwell,
|
||||||
|
|||||||
+108
-8
@@ -8,7 +8,11 @@
|
|||||||
/// Delivery importance used for bounded admission and selection.
|
/// Delivery importance used for bounded admission and selection.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
pub enum FrameClass {
|
pub enum FrameClass {
|
||||||
/// Liveness or status traffic that best-effort samples cannot displace.
|
/// Heartbeat and current safety/health status. A pending vital frame may
|
||||||
|
/// displace any pending non-vital frame; active DMA storage is untouched.
|
||||||
|
Vital,
|
||||||
|
/// Fault transitions and authoritative replies that best-effort samples
|
||||||
|
/// cannot displace.
|
||||||
Critical,
|
Critical,
|
||||||
/// Replaceable sample traffic where newest pending data is most useful.
|
/// Replaceable sample traffic where newest pending data is most useful.
|
||||||
BestEffort,
|
BestEffort,
|
||||||
@@ -30,6 +34,10 @@ pub enum EnqueueResult {
|
|||||||
Enqueued,
|
Enqueued,
|
||||||
/// The frame displaced one pending best-effort frame.
|
/// The frame displaced one pending best-effort frame.
|
||||||
ReplacedBestEffort,
|
ReplacedBestEffort,
|
||||||
|
/// A vital frame displaced one pending critical frame.
|
||||||
|
ReplacedCritical,
|
||||||
|
/// Vital admission failed because every pending slot was vital.
|
||||||
|
VitalQueueFull,
|
||||||
/// Critical admission failed because no best-effort pending frame existed.
|
/// Critical admission failed because no best-effort pending frame existed.
|
||||||
CriticalQueueFull,
|
CriticalQueueFull,
|
||||||
/// Best-effort admission failed because no replaceable pending frame existed.
|
/// Best-effort admission failed because no replaceable pending frame existed.
|
||||||
@@ -75,16 +83,30 @@ impl<Frame: Copy, const N: usize> TransmitQueue<Frame, N> {
|
|||||||
return EnqueueResult::Enqueued;
|
return EnqueueResult::Enqueued;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(slot) = self
|
let best_effort = self
|
||||||
.pending
|
.pending
|
||||||
.iter_mut()
|
.iter()
|
||||||
.find(|slot| slot.is_some_and(|pending| pending.class == FrameClass::BestEffort))
|
.position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::BestEffort));
|
||||||
{
|
let critical = self
|
||||||
*slot = Some(frame);
|
.pending
|
||||||
return EnqueueResult::ReplacedBestEffort;
|
.iter()
|
||||||
|
.position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::Critical));
|
||||||
|
let replacement = match frame.class {
|
||||||
|
FrameClass::Vital => best_effort
|
||||||
|
.map(|index| (index, EnqueueResult::ReplacedBestEffort))
|
||||||
|
.or_else(|| critical.map(|index| (index, EnqueueResult::ReplacedCritical))),
|
||||||
|
FrameClass::Critical => {
|
||||||
|
best_effort.map(|index| (index, EnqueueResult::ReplacedBestEffort))
|
||||||
|
}
|
||||||
|
FrameClass::BestEffort => None,
|
||||||
|
};
|
||||||
|
if let Some((index, result)) = replacement {
|
||||||
|
self.pending[index] = Some(frame);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
match frame.class {
|
match frame.class {
|
||||||
|
FrameClass::Vital => EnqueueResult::VitalQueueFull,
|
||||||
FrameClass::Critical => EnqueueResult::CriticalQueueFull,
|
FrameClass::Critical => EnqueueResult::CriticalQueueFull,
|
||||||
FrameClass::BestEffort => EnqueueResult::BestEffortDropped,
|
FrameClass::BestEffort => EnqueueResult::BestEffortDropped,
|
||||||
}
|
}
|
||||||
@@ -131,11 +153,17 @@ impl<Frame: Copy, const N: usize> TransmitQueue<Frame, N> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn take_next(&mut self) -> Option<PendingFrame<Frame>> {
|
fn take_next(&mut self) -> Option<PendingFrame<Frame>> {
|
||||||
|
let vital = self
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::Vital));
|
||||||
let critical = self
|
let critical = self
|
||||||
.pending
|
.pending
|
||||||
.iter()
|
.iter()
|
||||||
.position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::Critical));
|
.position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::Critical));
|
||||||
let index = critical.or_else(|| self.pending.iter().position(Option::is_some));
|
let index = vital
|
||||||
|
.or(critical)
|
||||||
|
.or_else(|| self.pending.iter().position(Option::is_some));
|
||||||
index.and_then(|index| self.pending[index].take())
|
index.and_then(|index| self.pending[index].take())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +259,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vital_status_displaces_pending_critical_and_wins_selection() {
|
||||||
|
let mut queue = TransmitQueue::<u8, 2>::new();
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::Critical,
|
||||||
|
frame: 1,
|
||||||
|
});
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::BestEffort,
|
||||||
|
frame: 2,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::Vital,
|
||||||
|
frame: 3,
|
||||||
|
}),
|
||||||
|
EnqueueResult::ReplacedBestEffort
|
||||||
|
);
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::Vital,
|
||||||
|
frame: 4,
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
queue.start_with(|frame| {
|
||||||
|
assert_eq!(*frame, 4);
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
}),
|
||||||
|
Ok(StartOutcome::Started)
|
||||||
|
);
|
||||||
|
queue.complete();
|
||||||
|
assert_eq!(
|
||||||
|
queue.start_with(|frame| {
|
||||||
|
assert_eq!(*frame, 3);
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
}),
|
||||||
|
Ok(StartOutcome::Started)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admitted_vital_metadata_cannot_be_displaced_by_diagnostics() {
|
||||||
|
let mut queue = TransmitQueue::<u8, 1>::new();
|
||||||
|
assert_eq!(
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::Vital,
|
||||||
|
frame: 11,
|
||||||
|
}),
|
||||||
|
EnqueueResult::Enqueued
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::Critical,
|
||||||
|
frame: 12,
|
||||||
|
}),
|
||||||
|
EnqueueResult::CriticalQueueFull
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queue.enqueue(PendingFrame {
|
||||||
|
class: FrameClass::BestEffort,
|
||||||
|
frame: 13,
|
||||||
|
}),
|
||||||
|
EnqueueResult::BestEffortDropped
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
queue.start_with(|frame| {
|
||||||
|
assert_eq!(*frame, 11);
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
}),
|
||||||
|
Ok(StartOutcome::Started)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_start_without_claiming_active_ownership() {
|
fn rejects_start_without_claiming_active_ownership() {
|
||||||
let mut queue = TransmitQueue::<u8, 1>::new();
|
let mut queue = TransmitQueue::<u8, 1>::new();
|
||||||
|
|||||||
Reference in new issue
Block a user