From 0a758608413dc975927fbdf9092f24e17eb95f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sun, 9 Aug 2026 22:04:06 +0300 Subject: [PATCH] feat(log): add bounded diagnostic transport --- Cargo.lock | 294 ++++++++++ Cargo.toml | 3 + libs/kaptan-control/Cargo.toml | 1 + libs/kaptan-control/src/lib.rs | 13 +- libs/kaptan-data-channel/Cargo.toml | 1 + libs/kaptan-data-channel/src/lib.rs | 153 +++++- libs/kaptan-drivers/Cargo.toml | 1 + libs/kaptan-drivers/src/icm42688/error.rs | 4 +- .../kaptan-drivers/src/lidar_lite_v3/error.rs | 6 +- libs/kaptan-drivers/src/mpu6000/error.rs | 4 +- libs/kaptan-log-derive/Cargo.toml | 21 + libs/kaptan-log-derive/src/lib.rs | 94 ++++ libs/kaptan-log-derive/tests/ui.rs | 7 + libs/kaptan-log-derive/tests/ui/enum.rs | 47 ++ libs/kaptan-log-derive/tests/ui/struct.rs | 8 + libs/kaptan-log-derive/tests/ui/struct.stderr | 5 + libs/kaptan-log-derive/tests/ui/union.rs | 8 + libs/kaptan-log-derive/tests/ui/union.stderr | 5 + libs/kaptan-log-mavlink/Cargo.toml | 18 + libs/kaptan-log-mavlink/src/lib.rs | 195 +++++++ libs/kaptan-log/Cargo.toml | 20 + libs/kaptan-log/src/crash.rs | 184 +++++++ libs/kaptan-log/src/facade.rs | 511 ++++++++++++++++++ libs/kaptan-log/src/filter.rs | 38 ++ libs/kaptan-log/src/hub.rs | 372 +++++++++++++ libs/kaptan-log/src/level.rs | 41 ++ libs/kaptan-log/src/lib.rs | 45 ++ libs/kaptan-log/src/mailbox.rs | 97 ++++ libs/kaptan-log/src/report.rs | 170 ++++++ libs/kaptan-log/src/shutdown.rs | 34 ++ libs/kaptan-log/src/text.rs | 110 ++++ libs/kaptan-math/Cargo.toml | 3 + libs/kaptan-math/src/lib.rs | 3 +- libs/kaptan-mavlink/Cargo.toml | 4 + libs/kaptan-mavlink/build.rs | 26 +- libs/kaptan-mavlink/definitions/kaptan.xml | 9 +- .../kaptan-mavlink/definitions/kaptan/log.xml | 54 ++ libs/kaptan-mavlink/src/encoder.rs | 7 + libs/kaptan-mavlink/src/lib.rs | 2 + libs/kaptan-mavlink/src/receiver.rs | 18 +- libs/kaptan-mavlink/src/transmitter.rs | 54 +- libs/kaptan-navigation/Cargo.toml | 1 + libs/kaptan-navigation/src/eskf.rs | 5 +- libs/kaptan-safety/Cargo.toml | 1 + libs/kaptan-safety/src/lib.rs | 5 +- libs/kaptan-tx/src/lib.rs | 116 +++- 46 files changed, 2758 insertions(+), 60 deletions(-) create mode 100644 libs/kaptan-log-derive/Cargo.toml create mode 100644 libs/kaptan-log-derive/src/lib.rs create mode 100644 libs/kaptan-log-derive/tests/ui.rs create mode 100644 libs/kaptan-log-derive/tests/ui/enum.rs create mode 100644 libs/kaptan-log-derive/tests/ui/struct.rs create mode 100644 libs/kaptan-log-derive/tests/ui/struct.stderr create mode 100644 libs/kaptan-log-derive/tests/ui/union.rs create mode 100644 libs/kaptan-log-derive/tests/ui/union.stderr create mode 100644 libs/kaptan-log-mavlink/Cargo.toml create mode 100644 libs/kaptan-log-mavlink/src/lib.rs create mode 100644 libs/kaptan-log/Cargo.toml create mode 100644 libs/kaptan-log/src/crash.rs create mode 100644 libs/kaptan-log/src/facade.rs create mode 100644 libs/kaptan-log/src/filter.rs create mode 100644 libs/kaptan-log/src/hub.rs create mode 100644 libs/kaptan-log/src/level.rs create mode 100644 libs/kaptan-log/src/lib.rs create mode 100644 libs/kaptan-log/src/mailbox.rs create mode 100644 libs/kaptan-log/src/report.rs create mode 100644 libs/kaptan-log/src/shutdown.rs create mode 100644 libs/kaptan-log/src/text.rs create mode 100644 libs/kaptan-mavlink/definitions/kaptan/log.xml diff --git a/Cargo.lock b/Cargo.lock index da05a3e..6195958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.17.1" @@ -135,6 +141,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "kaptan-actuator" version = "0.1.0" @@ -142,11 +154,43 @@ version = "0.1.0" [[package]] name = "kaptan-board" version = "0.1.0" +dependencies = [ + "kaptan-log", +] + +[[package]] +name = "kaptan-config" +version = "0.1.0" +dependencies = [ + "kaptan-config-derive", + "kaptan-log", +] + +[[package]] +name = "kaptan-config-derive" +version = "0.1.0" +dependencies = [ + "kaptan-config", + "proc-macro2", + "quote", + "syn 2.0.119", + "trybuild", +] + +[[package]] +name = "kaptan-config-mavlink" +version = "0.1.0" +dependencies = [ + "kaptan-config", + "kaptan-log", + "kaptan-mavlink", +] [[package]] name = "kaptan-control" version = "0.1.0" dependencies = [ + "kaptan-log", "kaptan-math", ] @@ -156,6 +200,7 @@ version = "0.1.0" dependencies = [ "kaptan-board", "kaptan-dma", + "kaptan-log", "kaptan-mavlink", "kaptan-sensor", "kaptan-time", @@ -170,11 +215,30 @@ name = "kaptan-drivers" version = "0.1.0" dependencies = [ "kaptan-i2c", + "kaptan-log", "kaptan-sensor", "kaptan-spi", "kaptan-time", ] +[[package]] +name = "kaptan-flight-config" +version = "0.1.0" +dependencies = [ + "kaptan-config", + "kaptan-log", + "minicbor", +] + +[[package]] +name = "kaptan-flight-runtime" +version = "0.1.0" +dependencies = [ + "kaptan-config", + "kaptan-flight-config", + "kaptan-log", +] + [[package]] name = "kaptan-geprc-f722-v2" version = "0.1.0" @@ -195,27 +259,62 @@ dependencies = [ "platform-stm32f7", ] +[[package]] +name = "kaptan-log" +version = "0.1.0" +dependencies = [ + "kaptan-log-derive", + "log", +] + +[[package]] +name = "kaptan-log-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "trybuild", +] + +[[package]] +name = "kaptan-log-mavlink" +version = "0.1.0" +dependencies = [ + "kaptan-log", + "kaptan-mavlink", + "mavlink", +] + [[package]] name = "kaptan-math" version = "0.1.0" +dependencies = [ + "kaptan-log", +] [[package]] name = "kaptan-mavlink" version = "0.1.0" dependencies = [ + "bitflags", "kaptan-dma", + "kaptan-log", "kaptan-sensor", "kaptan-time", "kaptan-tx", "mavlink", "mavlink-bindgen", "mavlink-core", + "num-derive", + "num-traits", ] [[package]] name = "kaptan-navigation" version = "0.1.0" dependencies = [ + "kaptan-log", "kaptan-math", "kaptan-sensor", "kaptan-time", @@ -225,6 +324,7 @@ dependencies = [ name = "kaptan-safety" version = "0.1.0" dependencies = [ + "kaptan-log", "kaptan-time", ] @@ -239,11 +339,21 @@ dependencies = [ name = "kaptan-sitl" version = "0.1.0" dependencies = [ + "kaptan-config", + "kaptan-config-mavlink", "kaptan-control", + "kaptan-flight-config", + "kaptan-flight-runtime", + "kaptan-log", + "kaptan-log-mavlink", "kaptan-math", + "kaptan-mavlink", "kaptan-navigation", "kaptan-safety", "kaptan-time", + "log", + "mavlink", + "minicbor", ] [[package]] @@ -258,6 +368,12 @@ version = "0.1.0" name = "kaptan-tx" version = "0.1.0" +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "mavlink" version = "0.18.0" @@ -303,6 +419,26 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minicbor" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0452a60c1863c1f50b5f77cd295e8d2786849f35883f0b9e18e7e6e1b5691b0" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "nb" version = "0.1.3" @@ -355,6 +491,8 @@ dependencies = [ "kaptan-dma", "kaptan-drivers", "kaptan-i2c", + "kaptan-log", + "kaptan-log-mavlink", "kaptan-mavlink", "kaptan-sensor", "kaptan-spi", @@ -481,6 +619,57 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.0", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "stm32f7" version = "0.16.0" @@ -514,6 +703,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "2.0.19" @@ -534,6 +738,60 @@ dependencies = [ "syn 3.0.0", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "trybuild" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e605bf6b39357663d8ba4e984f8be8da8df6bb32e81031d6889024ea8fd68e4" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -560,3 +818,39 @@ checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" dependencies = [ "vcell", ] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 72ac351..b5e498b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,9 @@ members = [ "libs/kaptan-i2c", "libs/kaptan-math", "libs/kaptan-mavlink", + "libs/kaptan-log", + "libs/kaptan-log-derive", + "libs/kaptan-log-mavlink", "libs/kaptan-navigation", "libs/kaptan-safety", "libs/kaptan-sensor", diff --git a/libs/kaptan-control/Cargo.toml b/libs/kaptan-control/Cargo.toml index 51dcd4f..3896b35 100644 --- a/libs/kaptan-control/Cargo.toml +++ b/libs/kaptan-control/Cargo.toml @@ -10,4 +10,5 @@ version.workspace = true workspace = true [dependencies] +kaptan-log = { path = "../kaptan-log" } kaptan-math = { path = "../kaptan-math" } diff --git a/libs/kaptan-control/src/lib.rs b/libs/kaptan-control/src/lib.rs index a23005b..434ee1b 100644 --- a/libs/kaptan-control/src/lib.rs +++ b/libs/kaptan-control/src/lib.rs @@ -7,6 +7,7 @@ #![no_std] +use kaptan_log::KaptanError; use kaptan_math::{Frd, Ned, UnitQuaternion, Vector3, sqrt}; /// Body-frame angular rate in radians per second. @@ -53,7 +54,7 @@ impl HeadingNed { } /// Force-to-attitude conversion failure. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum AttitudeError { /// A zero or non-finite force has no defined thrust direction. ZeroForce, @@ -101,7 +102,7 @@ pub struct PidConfig { } /// Invalid PID input/configuration. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum PidError { /// Static PID gains or limits were non-finite or contradictory. InvalidConfiguration, @@ -316,7 +317,7 @@ pub struct KinematicLimits { } /// 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 { /// A static speed/acceleration constraint is invalid. InvalidLimits, @@ -586,7 +587,7 @@ pub struct TranslationalControlConfig { } /// Position-control configuration or input rejection. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum TranslationalControlError { /// A gain or kinematic limit is invalid. InvalidConfiguration, @@ -681,7 +682,7 @@ pub struct ThrustEnvelope { } /// Thrust-envelope rejection. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum ThrustEnvelopeError { /// A static physical parameter is invalid. InvalidConfiguration, @@ -821,7 +822,7 @@ pub struct BodyWrench { } /// Allocation construction failure. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum AllocatorError { /// One static allocation output has non-finite or contradictory limits. InvalidActuator, diff --git a/libs/kaptan-data-channel/Cargo.toml b/libs/kaptan-data-channel/Cargo.toml index d67b370..ca6152a 100644 --- a/libs/kaptan-data-channel/Cargo.toml +++ b/libs/kaptan-data-channel/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] kaptan-board = { path = "../kaptan-board" } +kaptan-log = { path = "../kaptan-log" } kaptan-dma = { path = "../kaptan-dma" } kaptan-mavlink = { path = "../kaptan-mavlink" } kaptan-sensor = { path = "../kaptan-sensor" } diff --git a/libs/kaptan-data-channel/src/lib.rs b/libs/kaptan-data-channel/src/lib.rs index ab0b83c..0d2d994 100644 --- a/libs/kaptan-data-channel/src/lib.rs +++ b/libs/kaptan-data-channel/src/lib.rs @@ -9,11 +9,14 @@ use kaptan_board::{BoardConfig, DataChannelRates}; use kaptan_dma::TxCompletion; +use kaptan_log::KaptanError; +use kaptan_mavlink::message::MessageData; use kaptan_mavlink::{ MavlinkFrame, encoder::{DistanceSensorConfig, MavlinkEncoder}, message::{MavCmd, MavResult, MavState}, transmitter::FrameClass, + transmitter::TransmitFaultKind, }; use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot}; @@ -27,10 +30,78 @@ pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox /// delivery class. The platform retains only DMA endpoint ownership. pub trait FrameSink { /// Admits one fully encoded frame to the platform's bounded transport. - fn send(&mut self, class: FrameClass, frame: MavlinkFrame); + fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission; /// Releases or faults the active DMA frame after a terminal interrupt. - fn on_dma_complete(&mut self, completion: TxCompletion); + 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; +} + +/// 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. @@ -56,7 +127,7 @@ pub struct VerticalDataConfig { } /// Data-channel construction failure. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DataChannelError { /// Rejected profiles must not construct a physical telemetry channel. NotTelemetryProfile, @@ -126,7 +197,7 @@ where match self.schedule.next_due() { Some(DueData::RawImu) => { if let Some(sample) = self.sources.imu.take_newer(&mut self.observed_imu) { - self.sink.send( + let _ = self.sink.send( FrameClass::BestEffort, self.encoder.raw_imu_snapshot(&sample), ); @@ -134,7 +205,7 @@ where } 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) { - self.sink.send( + let _ = self.sink.send( FrameClass::BestEffort, self.encoder.distance_sensor_snapshot(&range, config), ); @@ -146,33 +217,58 @@ where if let (Some(flow), Some(sensor_id)) = (self.last_optical_flow, self.vertical.optical_flow_sensor_id) { - self.sink.send( + let _ = self.sink.send( FrameClass::BestEffort, self.encoder.optical_flow_snapshot(&flow, sensor_id), ); } } - Some(DueData::Heartbeat) => self.sink.send( - FrameClass::Critical, - self.encoder.heartbeat(system_status, motors_armed), - ), + Some(DueData::Heartbeat) => { + let _ = self.sink.send( + FrameClass::Vital, + self.encoder.heartbeat(system_status, motors_armed), + ); + } Some(DueData::Distance | DueData::OpticalFlow) | None => {} } } /// Completes the active DMA lifecycle for this channel's transport. - pub fn on_dma_complete(&mut self, completion: TxCompletion) { - self.sink.on_dma_complete(completion); + pub fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion { + self.sink.on_dma_complete(completion) } /// Emits a critical acknowledgement for a received MAVLink command. pub fn acknowledge_command(&mut self, command: MavCmd, result: MavResult) { - self.sink.send( + let _ = self.sink.send( FrameClass::Critical, self.encoder.command_ack(command, result), ); } + /// Encodes an adapter-owned generated MAVLink data record through the + /// channel's sole encoder and bounded transport sink. + pub fn send_custom( + &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 { + self.sink.take_transport_fault() + } + fn refresh_vertical_data(&mut self) { if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) { self.last_range = Some(range); @@ -275,11 +371,16 @@ impl Schedule { #[cfg(test)] mod tests { use super::{ - DataChannel, DataSources, FrameSink, LatestImuMailbox, LatestOpticalFlowMailbox, - LatestRangeMailbox, VerticalDataConfig, + DataChannel, DataSources, DmaCompletion, FrameAdmission, FrameSink, LatestImuMailbox, + 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_log::Severity; use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass}; use kaptan_sensor::{ imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot, @@ -293,15 +394,26 @@ mod tests { } impl FrameSink for Sink { - fn send(&mut self, class: FrameClass, _: MavlinkFrame) { + fn send(&mut self, class: FrameClass, _: MavlinkFrame) -> FrameAdmission { match class { + FrameClass::Vital => self.critical += 1, FrameClass::BestEffort => self.best_effort += 1, FrameClass::Critical => self.critical += 1, } + FrameAdmission::Retained(kaptan_mavlink::transmitter::EnqueueResult::Enqueued) } - fn on_dma_complete(&mut self, _: TxCompletion) { + fn on_dma_complete(&mut self, _: TxCompletion) -> DmaCompletion { self.completions += 1; + DmaCompletion::Complete + } + + fn health(&self) -> TxHealth { + TxHealth::default() + } + + fn take_transport_fault(&mut self) -> Option { + None } } @@ -310,8 +422,12 @@ mod tests { mavlink: MavlinkEndpoint { system_id: 1, component_id: 1, + port: MavlinkPort::Serial1, + baud: 115_200, }, profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::TelemetryProfile { + imu: ImuDevice::Mpu6000, + imu_mount: ImuMount::BodyAligned, vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, data_channel: DataChannelRates { service_hz: 100, @@ -320,6 +436,7 @@ mod tests { optical_flow_hz: Some(20), heartbeat_hz: 2, }, + maximum_log_level: Severity::Info, }), }; diff --git a/libs/kaptan-drivers/Cargo.toml b/libs/kaptan-drivers/Cargo.toml index 871ba91..9dd6199 100644 --- a/libs/kaptan-drivers/Cargo.toml +++ b/libs/kaptan-drivers/Cargo.toml @@ -10,6 +10,7 @@ version.workspace = true workspace = true [dependencies] +kaptan-log = { path = "../kaptan-log" } kaptan-sensor = { path = "../kaptan-sensor" } kaptan-i2c = { path = "../kaptan-i2c" } kaptan-spi = { path = "../kaptan-spi" } diff --git a/libs/kaptan-drivers/src/icm42688/error.rs b/libs/kaptan-drivers/src/icm42688/error.rs index bd7868c..4454f8f 100644 --- a/libs/kaptan-drivers/src/icm42688/error.rs +++ b/libs/kaptan-drivers/src/icm42688/error.rs @@ -1,7 +1,9 @@ //! ICM-42688-P operation errors. +use kaptan_log::KaptanError; + /// ICM-42688-P operation failure. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, KaptanError)] pub enum Error { /// The selected SPI route rejected a transfer submission. Start(BusError), diff --git a/libs/kaptan-drivers/src/lidar_lite_v3/error.rs b/libs/kaptan-drivers/src/lidar_lite_v3/error.rs index 46eb9b6..7cb87e0 100644 --- a/libs/kaptan-drivers/src/lidar_lite_v3/error.rs +++ b/libs/kaptan-drivers/src/lidar_lite_v3/error.rs @@ -1,7 +1,9 @@ //! LIDAR-Lite v3 operation errors. +use kaptan_log::KaptanError; + /// LIDAR-Lite v3 operation failure. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, KaptanError)] pub enum Error { /// The selected I²C route rejected a transaction submission. Start(BusError), @@ -22,7 +24,7 @@ pub enum Error { } /// LIDAR-Lite v3 startup lifecycle failure. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum StartupError { /// Startup completion was reported when the driver was not settling. NotSettling, diff --git a/libs/kaptan-drivers/src/mpu6000/error.rs b/libs/kaptan-drivers/src/mpu6000/error.rs index f263cff..934f57e 100644 --- a/libs/kaptan-drivers/src/mpu6000/error.rs +++ b/libs/kaptan-drivers/src/mpu6000/error.rs @@ -1,7 +1,9 @@ //! MPU6000 operation errors. +use kaptan_log::KaptanError; + /// MPU6000 operation failure. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Eq, PartialEq, KaptanError)] pub enum Error { /// The selected SPI route rejected a transfer submission. Start(BusError), diff --git a/libs/kaptan-log-derive/Cargo.toml b/libs/kaptan-log-derive/Cargo.toml new file mode 100644 index 0000000..98336cb --- /dev/null +++ b/libs/kaptan-log-derive/Cargo.toml @@ -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" diff --git a/libs/kaptan-log-derive/src/lib.rs b/libs/kaptan-log-derive/src/lib.rs new file mode 100644 index 0000000..d2f4b92 --- /dev/null +++ b/libs/kaptan-log-derive/src/lib.rs @@ -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 { + 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( + ::error_message(self).as_str(), + ) + } + } + + impl #error_impl_generics ::core::error::Error + for #ident #error_type_generics #error_where_clause {} + }) +} diff --git a/libs/kaptan-log-derive/tests/ui.rs b/libs/kaptan-log-derive/tests/ui.rs new file mode 100644 index 0000000..4fa8efd --- /dev/null +++ b/libs/kaptan-log-derive/tests/ui.rs @@ -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"); +} diff --git a/libs/kaptan-log-derive/tests/ui/enum.rs b/libs/kaptan-log-derive/tests/ui/enum.rs new file mode 100644 index 0000000..6d88687 --- /dev/null +++ b/libs/kaptan-log-derive/tests/ui/enum.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() {} + assert_error::>(); + assert_eq!( + Generic::::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"), + ); +} diff --git a/libs/kaptan-log-derive/tests/ui/struct.rs b/libs/kaptan-log-derive/tests/ui/struct.rs new file mode 100644 index 0000000..0d16e9c --- /dev/null +++ b/libs/kaptan-log-derive/tests/ui/struct.rs @@ -0,0 +1,8 @@ +use kaptan_log_derive::KaptanError; + +#[derive(KaptanError)] +struct NotAnError { + code: u8, +} + +fn main() {} diff --git a/libs/kaptan-log-derive/tests/ui/struct.stderr b/libs/kaptan-log-derive/tests/ui/struct.stderr new file mode 100644 index 0000000..bc44d33 --- /dev/null +++ b/libs/kaptan-log-derive/tests/ui/struct.stderr @@ -0,0 +1,5 @@ +error: KaptanError can only be derived for enums, not structs + --> tests/ui/struct.rs:4:8 + | +4 | struct NotAnError { + | ^^^^^^^^^^ diff --git a/libs/kaptan-log-derive/tests/ui/union.rs b/libs/kaptan-log-derive/tests/ui/union.rs new file mode 100644 index 0000000..dac9238 --- /dev/null +++ b/libs/kaptan-log-derive/tests/ui/union.rs @@ -0,0 +1,8 @@ +use kaptan_log_derive::KaptanError; + +#[derive(KaptanError)] +union NotAnError { + code: u8, +} + +fn main() {} diff --git a/libs/kaptan-log-derive/tests/ui/union.stderr b/libs/kaptan-log-derive/tests/ui/union.stderr new file mode 100644 index 0000000..9daf8d2 --- /dev/null +++ b/libs/kaptan-log-derive/tests/ui/union.stderr @@ -0,0 +1,5 @@ +error: KaptanError can only be derived for enums, not unions + --> tests/ui/union.rs:4:7 + | +4 | union NotAnError { + | ^^^^^^^^^^ diff --git a/libs/kaptan-log-mavlink/Cargo.toml b/libs/kaptan-log-mavlink/Cargo.toml new file mode 100644 index 0000000..e4a07cd --- /dev/null +++ b/libs/kaptan-log-mavlink/Cargo.toml @@ -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"] } diff --git a/libs/kaptan-log-mavlink/src/lib.rs b/libs/kaptan-log-mavlink/src/lib.rs new file mode 100644 index 0000000..e17c643 --- /dev/null +++ b/libs/kaptan-log-mavlink/src/lib.rs @@ -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::()); + 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 + )); + } +} diff --git a/libs/kaptan-log/Cargo.toml b/libs/kaptan-log/Cargo.toml new file mode 100644 index 0000000..d3c8c75 --- /dev/null +++ b/libs/kaptan-log/Cargo.toml @@ -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 } diff --git a/libs/kaptan-log/src/crash.rs b/libs/kaptan-log/src/crash.rs new file mode 100644 index 0000000..2df8db8 --- /dev/null +++ b/libs/kaptan-log/src/crash.rs @@ -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 { + 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 { + 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" + ); + } +} diff --git a/libs/kaptan-log/src/facade.rs b/libs/kaptan-log/src/facade.rs new file mode 100644 index 0000000..cb3bbff --- /dev/null +++ b/libs/kaptan-log/src/facade.rs @@ -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 { + entries: [Option; N], + head: usize, + len: usize, +} + +impl IngressQueue { + 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 { + 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 { + /// 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>, + critical_busy: AtomicBool, + queue: UnsafeCell>, + 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 Sync for FacadeIngress {} + +impl FacadeIngress { + /// 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( + &self, + hub: &mut EventHub, + ) -> Option { + 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( + &self, + operation: impl FnOnce(&mut Option) -> R, + ) -> Option { + 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(&self, operation: impl FnOnce(&mut IngressQueue) -> R) -> Option { + 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 { + ingress: &'static FacadeIngress, + forbidden_context: fn() -> bool, +} + +impl GlobalLogger { + /// Creates a logger tied to one static bounded ingress. + pub const fn new(ingress: &'static FacadeIngress, 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 Log for GlobalLogger { + 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 { + 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); + } +} diff --git a/libs/kaptan-log/src/filter.rs b/libs/kaptan-log/src/filter.rs new file mode 100644 index 0000000..0d408be --- /dev/null +++ b/libs/kaptan-log/src/filter.rs @@ -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) + } +} diff --git a/libs/kaptan-log/src/hub.rs b/libs/kaptan-log/src/hub.rs new file mode 100644 index 0000000..9b8c171 --- /dev/null +++ b/libs/kaptan-log/src/hub.rs @@ -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 { + entries: [Option; N], + head: usize, + len: usize, +} + +impl Lane { + 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 { + self.entries.get(self.head).copied().flatten() + } + + fn pop(&mut self) -> Option { + 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 { + filter: LogFilter, + critical: Lane, + best_effort: Lane, + health: HubHealth, +} + +impl EventHub { + /// 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(&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 { + 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( + &mut self, + submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R), + ) -> Option { + 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 { + 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, + } + ); + } +} diff --git a/libs/kaptan-log/src/level.rs b/libs/kaptan-log/src/level.rs new file mode 100644 index 0000000..1d30547 --- /dev/null +++ b/libs/kaptan-log/src/level.rs @@ -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 { + 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, + } + } +} diff --git a/libs/kaptan-log/src/lib.rs b/libs/kaptan-log/src/lib.rs new file mode 100644 index 0000000..023837e --- /dev/null +++ b/libs/kaptan-log/src/lib.rs @@ -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; diff --git a/libs/kaptan-log/src/mailbox.rs b/libs/kaptan-log/src/mailbox.rs new file mode 100644 index 0000000..aeb4d15 --- /dev/null +++ b/libs/kaptan-log/src/mailbox.rs @@ -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, + 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)); + } +} diff --git a/libs/kaptan-log/src/report.rs b/libs/kaptan-log/src/report.rs new file mode 100644 index 0000000..bc20a86 --- /dev/null +++ b/libs/kaptan-log/src/report.rs @@ -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); + } +} diff --git a/libs/kaptan-log/src/shutdown.rs b/libs/kaptan-log/src/shutdown.rs new file mode 100644 index 0000000..2ca0bfb --- /dev/null +++ b/libs/kaptan-log/src/shutdown.rs @@ -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 + } +} diff --git a/libs/kaptan-log/src/text.rs b/libs/kaptan-log/src/text.rs new file mode 100644 index 0000000..8df9305 --- /dev/null +++ b/libs/kaptan-log/src/text.rs @@ -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()); + } +} diff --git a/libs/kaptan-math/Cargo.toml b/libs/kaptan-math/Cargo.toml index 863bc31..cb36edf 100644 --- a/libs/kaptan-math/Cargo.toml +++ b/libs/kaptan-math/Cargo.toml @@ -8,3 +8,6 @@ version.workspace = true [lints] workspace = true + +[dependencies] +kaptan-log = { path = "../kaptan-log" } diff --git a/libs/kaptan-math/src/lib.rs b/libs/kaptan-math/src/lib.rs index 18b3ba0..5e71ed8 100644 --- a/libs/kaptan-math/src/lib.rs +++ b/libs/kaptan-math/src/lib.rs @@ -7,13 +7,14 @@ #![no_std] use core::marker::PhantomData; +use kaptan_log::KaptanError; /// Failure reported by a checked mathematical operation. /// /// 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 /// the identity rotation. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum MathError { /// An operand or intermediate value was not finite. NonFinite, diff --git a/libs/kaptan-mavlink/Cargo.toml b/libs/kaptan-mavlink/Cargo.toml index b902a37..dff2842 100644 --- a/libs/kaptan-mavlink/Cargo.toml +++ b/libs/kaptan-mavlink/Cargo.toml @@ -17,11 +17,15 @@ sensor-telemetry = ["dep:kaptan-sensor"] dialect-kaptan = [] [dependencies] +kaptan-log = { path = "../kaptan-log" } kaptan-dma = { path = "../kaptan-dma" } kaptan-sensor = { path = "../kaptan-sensor", optional = true } kaptan-tx = { path = "../kaptan-tx" } mavlink = { version = "0.18", default-features = false, features = ["embedded", "dialect-common"] } 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] kaptan-time = { path = "../kaptan-time" } diff --git a/libs/kaptan-mavlink/build.rs b/libs/kaptan-mavlink/build.rs index 95798db..b57bc29 100644 --- a/libs/kaptan-mavlink/build.rs +++ b/libs/kaptan-mavlink/build.rs @@ -3,17 +3,23 @@ use std::path::PathBuf; fn main() { let manifest_directory = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); let dialect = manifest_directory.join("definitions/kaptan.xml"); - let output_directory = std::env::var("OUT_DIR").unwrap(); - let dialect_source = std::fs::read_to_string(&dialect).unwrap(); - let generated = mavlink_bindgen::generate( - mavlink_bindgen::XmlDefinitions::Files(vec![dialect]), - output_directory, - ) - .unwrap(); - - mavlink_bindgen::emit_cargo_build_messages(&generated); + let log = manifest_directory.join("definitions/kaptan/log.xml"); + for definition in [&dialect, &log] { + println!("cargo::rerun-if-changed={}", definition.display()); + } println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)"); - if dialect_source.contains(" - 3 - 0 - - - - + + kaptan/log.xml diff --git a/libs/kaptan-mavlink/definitions/kaptan/log.xml b/libs/kaptan-mavlink/definitions/kaptan/log.xml new file mode 100644 index 0000000..063c7e6 --- /dev/null +++ b/libs/kaptan-mavlink/definitions/kaptan/log.xml @@ -0,0 +1,54 @@ + + + 3 + 0 + + + + + Record originated at Rust trace level and uses MAVLink DEBUG severity. + + + Message text was truncated to the bounded on-wire buffer. + + + Message represents repeated adjacent observations. + + + + + Error display text was truncated to the bounded on-wire buffer. + + + Error represents repeated adjacent observations. + + + + + An operator initiated the completed controlled firmware shutdown. + + + The safety owner initiated the completed controlled firmware shutdown. + + + + + + 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. + MAV_SEVERITY-compatible level. Rust TRACE uses DEBUG plus the TRACE flag. + Known bits are TRACE=1, TRUNCATED=2, and COALESCED=4. Receivers ignore unknown future bits. + Number of valid UTF-8 bytes in message; at most 160. + Bounded UTF-8 message bytes. Bytes after message_length are zero. + + + 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. + Known bits are TRUNCATED=2 and COALESCED=4. Receivers ignore unknown future bits. + Number of valid UTF-8 bytes in message; at most 160. + Bounded UTF-8 error display identity. Bytes after message_length are zero. + + + 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. + Typed shutdown reason. + + + diff --git a/libs/kaptan-mavlink/src/encoder.rs b/libs/kaptan-mavlink/src/encoder.rs index 01a393a..d111ab4 100644 --- a/libs/kaptan-mavlink/src/encoder.rs +++ b/libs/kaptan-mavlink/src/encoder.rs @@ -56,6 +56,13 @@ impl MavlinkEncoder { 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(&mut self, message: &M) -> MavlinkFrame { + self.serialize(message) + } + /// Encodes a board-I/O heartbeat with its current arming state. pub fn heartbeat(&mut self, system_status: message::MavState, armed: bool) -> MavlinkFrame { let heartbeat = message::HEARTBEAT_DATA { diff --git a/libs/kaptan-mavlink/src/lib.rs b/libs/kaptan-mavlink/src/lib.rs index 892cf17..77c0319 100644 --- a/libs/kaptan-mavlink/src/lib.rs +++ b/libs/kaptan-mavlink/src/lib.rs @@ -18,6 +18,8 @@ pub type MavlinkFrame = message::MAVLinkV2MessageRaw; pub mod message { pub use mavlink::MAVLinkV2MessageRaw; pub use mavlink::MavHeader; + pub use mavlink::Message; + pub use mavlink::MessageData; pub use mavlink::dialects::common::*; #[cfg(kaptan_has_messages)] diff --git a/libs/kaptan-mavlink/src/receiver.rs b/libs/kaptan-mavlink/src/receiver.rs index d3d1e05..4f77402 100644 --- a/libs/kaptan-mavlink/src/receiver.rs +++ b/libs/kaptan-mavlink/src/receiver.rs @@ -75,7 +75,11 @@ impl MavlinkReceiver { let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new()); self.received = 0; self.expected = 0; - if !frame.has_valid_crc::() { + // 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; } } @@ -110,6 +114,18 @@ impl MavlinkReceiver { } } +fn has_valid_known_crc(frame: &MavlinkFrame) -> bool { + let common_valid = frame.has_valid_crc::(); + #[cfg(feature = "dialect-kaptan")] + { + common_valid || frame.has_valid_crc::() + } + #[cfg(not(feature = "dialect-kaptan"))] + { + common_valid + } +} + #[cfg(test)] mod tests { use super::MavlinkReceiver; diff --git a/libs/kaptan-mavlink/src/transmitter.rs b/libs/kaptan-mavlink/src/transmitter.rs index 08ae589..c13d56a 100644 --- a/libs/kaptan-mavlink/src/transmitter.rs +++ b/libs/kaptan-mavlink/src/transmitter.rs @@ -1,6 +1,7 @@ //! MAVLink frame encoding and UART transmission API. use kaptan_dma::{DmaTxBufferFactory, DmaTxEngine, TxCompletion}; +use kaptan_log::KaptanError; use kaptan_tx::{PendingFrame, TransmitQueue}; use crate::MavlinkFrame; @@ -9,7 +10,7 @@ use crate::MavlinkFrame; pub use kaptan_tx::{EnqueueResult, FrameClass}; /// Failure while preparing or starting a MAVLink frame transfer. -#[derive(Debug)] +#[derive(Debug, KaptanError)] pub enum MavlinkTransmitFault { /// DMA-visible frame preparation failed. Memory(MemoryError), @@ -17,6 +18,40 @@ pub enum MavlinkTransmitFault { 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 MavlinkTransmitFault { + /// 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 { + /// Queue admission for the source frame. + pub admission: EnqueueResult, + /// One bounded attempt to start pending DMA work. + pub start: Result<(), MavlinkTransmitFault>, +} + /// Owns MAVLink frame admission and UART DMA lifecycle. /// /// Exactly one worker invokes this type. It accepts local encoded frames and @@ -54,8 +89,23 @@ where class: FrameClass, frame: MavlinkFrame, ) -> Result> { + 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 { 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. diff --git a/libs/kaptan-navigation/Cargo.toml b/libs/kaptan-navigation/Cargo.toml index c0c91b6..1c1fb77 100644 --- a/libs/kaptan-navigation/Cargo.toml +++ b/libs/kaptan-navigation/Cargo.toml @@ -15,6 +15,7 @@ default = [] # executable. It is not part of a board build. sitl = ["dep:kaptan-sensor"] [dependencies] +kaptan-log = { path = "../kaptan-log" } kaptan-math = { path = "../kaptan-math" } kaptan-sensor = { path = "../kaptan-sensor", optional = true } kaptan-time = { path = "../kaptan-time" } diff --git a/libs/kaptan-navigation/src/eskf.rs b/libs/kaptan-navigation/src/eskf.rs index 52a4461..b097b09 100644 --- a/libs/kaptan-navigation/src/eskf.rs +++ b/libs/kaptan-navigation/src/eskf.rs @@ -1,5 +1,6 @@ //! Fixed-size multiplicative error-state Kalman filter. +use kaptan_log::KaptanError; use kaptan_math::{Frd, MathError, Ned, Sensor, UnitQuaternion, Vector3, sqrt}; use kaptan_time::Timestamp; @@ -205,7 +206,7 @@ pub struct EstimatorHealth { } /// Propagation failure that leaves the nominal state unchanged. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum PropagationError { /// An IMU observation was invalid or was not calibrated into FRD. InvalidImu, @@ -228,7 +229,7 @@ pub enum PropagationError { } /// Aiding fusion refusal with no nominal-state update. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum FusionError { /// Observation is not at this filter's current history time. TimestampMismatch, diff --git a/libs/kaptan-safety/Cargo.toml b/libs/kaptan-safety/Cargo.toml index 4f9112f..25d63c5 100644 --- a/libs/kaptan-safety/Cargo.toml +++ b/libs/kaptan-safety/Cargo.toml @@ -10,4 +10,5 @@ version.workspace = true workspace = true [dependencies] +kaptan-log = { path = "../kaptan-log" } kaptan-time = { path = "../kaptan-time" } diff --git a/libs/kaptan-safety/src/lib.rs b/libs/kaptan-safety/src/lib.rs index 281e63d..37b7867 100644 --- a/libs/kaptan-safety/src/lib.rs +++ b/libs/kaptan-safety/src/lib.rs @@ -7,6 +7,7 @@ #![no_std] +use kaptan_log::KaptanError; use kaptan_time::Timestamp; /// Supervisor arming state. @@ -23,7 +24,7 @@ pub enum SafetyState { } /// Typed reason for refusing or ending actuator authorization. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum SafetyFailure { /// Estimator is absent, stale, or non-finite. EstimatorUnhealthy, @@ -42,7 +43,7 @@ pub enum SafetyFailure { } /// Invalid safety configuration. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum SafetyConfigError { /// A zero dwell would permit two evaluations at one timestamp to arm. ZeroPrearmDwell, diff --git a/libs/kaptan-tx/src/lib.rs b/libs/kaptan-tx/src/lib.rs index 48c80a1..d9c2058 100644 --- a/libs/kaptan-tx/src/lib.rs +++ b/libs/kaptan-tx/src/lib.rs @@ -8,7 +8,11 @@ /// Delivery importance used for bounded admission and selection. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] 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, /// Replaceable sample traffic where newest pending data is most useful. BestEffort, @@ -30,6 +34,10 @@ pub enum EnqueueResult { Enqueued, /// The frame displaced one pending best-effort frame. 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. CriticalQueueFull, /// Best-effort admission failed because no replaceable pending frame existed. @@ -75,16 +83,30 @@ impl TransmitQueue { return EnqueueResult::Enqueued; } - if let Some(slot) = self + let best_effort = self .pending - .iter_mut() - .find(|slot| slot.is_some_and(|pending| pending.class == FrameClass::BestEffort)) - { - *slot = Some(frame); - return EnqueueResult::ReplacedBestEffort; + .iter() + .position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::BestEffort)); + let critical = self + .pending + .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 { + FrameClass::Vital => EnqueueResult::VitalQueueFull, FrameClass::Critical => EnqueueResult::CriticalQueueFull, FrameClass::BestEffort => EnqueueResult::BestEffortDropped, } @@ -131,11 +153,17 @@ impl TransmitQueue { } fn take_next(&mut self) -> Option> { + let vital = self + .pending + .iter() + .position(|slot| slot.is_some_and(|pending| pending.class == FrameClass::Vital)); let critical = self .pending .iter() .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()) } } @@ -231,6 +259,78 @@ mod tests { ); } + #[test] + fn vital_status_displaces_pending_critical_and_wins_selection() { + let mut queue = TransmitQueue::::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::::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] fn rejects_start_without_claiming_active_ownership() { let mut queue = TransmitQueue::::new();