diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 4a92a46..eaca7fb 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -156,6 +156,7 @@ name = "kaptan-config" version = "0.1.0" dependencies = [ "kaptan-config-derive", + "kaptan-err-propagate", "kaptan-log", ] @@ -175,7 +176,7 @@ name = "kaptan-config-mavlink" version = "0.1.0" dependencies = [ "kaptan-config", - "kaptan-log", + "kaptan-err-propagate", "kaptan-mavlink", ] @@ -185,6 +186,7 @@ version = "0.1.0" dependencies = [ "kaptan-config", "kaptan-config-mavlink", + "kaptan-log", "kaptan-mavlink", "libc", "mavlink-core", @@ -196,7 +198,7 @@ dependencies = [ name = "kaptan-control" version = "0.1.0" dependencies = [ - "kaptan-log", + "kaptan-err-propagate", "kaptan-math", ] @@ -205,12 +207,22 @@ name = "kaptan-data-channel" version = "0.1.0" dependencies = [ "kaptan-dma", - "kaptan-log", + "kaptan-err-propagate", "kaptan-mavlink", "kaptan-sensor", "kaptan-time", ] +[[package]] +name = "kaptan-diagnostics-mavlink" +version = "0.1.0" +dependencies = [ + "kaptan-err-propagate", + "kaptan-log", + "kaptan-mavlink", + "mavlink", +] + [[package]] name = "kaptan-dma" version = "0.1.0" @@ -219,18 +231,36 @@ version = "0.1.0" name = "kaptan-drivers" version = "0.1.0" dependencies = [ + "kaptan-err-propagate", "kaptan-i2c", - "kaptan-log", "kaptan-sensor", "kaptan-spi", "kaptan-time", ] +[[package]] +name = "kaptan-err-propagate" +version = "0.1.0" +dependencies = [ + "kaptan-err-propagate-macros", +] + +[[package]] +name = "kaptan-err-propagate-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "trybuild", +] + [[package]] name = "kaptan-flight-config" version = "0.1.0" dependencies = [ "kaptan-config", + "kaptan-err-propagate", "kaptan-log", "minicbor", ] @@ -271,34 +301,13 @@ name = "kaptan-log" version = "0.1.0" dependencies = [ "kaptan-config-derive", - "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", + "kaptan-err-propagate", ] [[package]] @@ -307,7 +316,7 @@ version = "0.1.0" dependencies = [ "bitflags", "kaptan-dma", - "kaptan-log", + "kaptan-err-propagate", "kaptan-sensor", "kaptan-time", "kaptan-tx", @@ -322,7 +331,7 @@ dependencies = [ name = "kaptan-navigation" version = "0.1.0" dependencies = [ - "kaptan-log", + "kaptan-err-propagate", "kaptan-math", "kaptan-sensor", "kaptan-time", @@ -332,7 +341,7 @@ dependencies = [ name = "kaptan-safety" version = "0.1.0" dependencies = [ - "kaptan-log", + "kaptan-err-propagate", "kaptan-time", ] @@ -350,16 +359,16 @@ dependencies = [ "kaptan-config", "kaptan-config-mavlink", "kaptan-control", + "kaptan-diagnostics-mavlink", + "kaptan-err-propagate", "kaptan-flight-config", "kaptan-flight-runtime", "kaptan-log", - "kaptan-log-mavlink", "kaptan-math", "kaptan-mavlink", "kaptan-navigation", "kaptan-safety", "kaptan-time", - "log", "mavlink", "minicbor", ] @@ -382,12 +391,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[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" @@ -503,11 +506,12 @@ dependencies = [ "kaptan-config", "kaptan-config-mavlink", "kaptan-data-channel", + "kaptan-diagnostics-mavlink", "kaptan-dma", "kaptan-drivers", + "kaptan-err-propagate", "kaptan-i2c", "kaptan-log", - "kaptan-log-mavlink", "kaptan-mavlink", "kaptan-sensor", "kaptan-spi", diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 29db59a..ed2aa65 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -10,14 +10,15 @@ members = [ "libs/kaptan-data-channel", "libs/kaptan-dma", "libs/kaptan-drivers", + "libs/kaptan-err-propagate", + "libs/kaptan-err-propagate-macros", "libs/kaptan-flight-config", "libs/kaptan-flight-runtime", "libs/kaptan-i2c", "libs/kaptan-math", "libs/kaptan-mavlink", "libs/kaptan-log", - "libs/kaptan-log-derive", - "libs/kaptan-log-mavlink", + "libs/kaptan-diagnostics-mavlink", "libs/kaptan-navigation", "libs/kaptan-safety", "libs/kaptan-sensor", diff --git a/firmware/boards/geprc-f722-v2/src/board.rs b/firmware/boards/geprc-f722-v2/src/board.rs index 4fba604..bebfdf2 100644 --- a/firmware/boards/geprc-f722-v2/src/board.rs +++ b/firmware/boards/geprc-f722-v2/src/board.rs @@ -1,36 +1,24 @@ //! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack. use platform_stm32f7::{ - F7Board, F722Hardware, F722ImmutableConfig as ImmutableConfig, Icm42688, ImuMount, Mavlink, - Pa13ActiveHigh, Pb0Pb1Pb4Pb5Low, Pc3, Severity, VerticalSensor, + F7Board, F722Hardware, F722ImmutableConfig as ImmutableConfig, Icm42688, ImuMount, LevelFilter, + Mavlink, Pa13ActiveHigh, Pb0Pb1Pb4Pb5Low, Pc3, VerticalSensor, }; /// User-writable MAVLink endpoint policy. #[derive(Clone, kaptan_config::Config)] struct MavlinkConfig { - #[config( - id = 101, - key = "mavlink.system_id", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 101, key = "mavlink.system_id", unit = "id", range(min = 1))] system_id: u8, - #[config( - id = 102, - key = "mavlink.component_id", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 102, key = "mavlink.component_id", unit = "id", range(min = 1))] component_id: u8, - #[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")] - logging_enabled: bool, #[config( enum, id = 105, - key = "mavlink.logging.maximum_severity", - unit = "mav_severity" + key = "mavlink.logging.level_filter", + unit = "log_level" )] - maximum_log_severity: Severity, + log_level_filter: LevelFilter, } /// User-writable telemetry publication policy. @@ -70,8 +58,7 @@ impl F7Board for GeprcF722V2 { mavlink: MavlinkConfig { system_id: 1, component_id: 1, - logging_enabled: true, - maximum_log_severity: Severity::Info, + log_level_filter: LevelFilter::Info, }, telemetry: TelemetryConfig { raw_imu_hz: 20, diff --git a/firmware/boards/kakute-f7-mini-v3/src/board.rs b/firmware/boards/kakute-f7-mini-v3/src/board.rs index 8f9a2c9..709074b 100644 --- a/firmware/boards/kakute-f7-mini-v3/src/board.rs +++ b/firmware/boards/kakute-f7-mini-v3/src/board.rs @@ -6,36 +6,24 @@ use platform_stm32f7::{ AccelerometerRange, DigitalLowPassFilter, F7Board, F722Hardware, - F722ImmutableConfig as ImmutableConfig, GyroscopeRange, ImuMount, Mavlink, Mpu6000, - Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, Severity, VerticalSensor, + F722ImmutableConfig as ImmutableConfig, GyroscopeRange, ImuMount, LevelFilter, Mavlink, + Mpu6000, Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, VerticalSensor, }; /// User-writable MAVLink endpoint policy. #[derive(Clone, kaptan_config::Config)] struct MavlinkConfig { - #[config( - id = 101, - key = "mavlink.system_id", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 101, key = "mavlink.system_id", unit = "id", range(min = 1))] system_id: u8, - #[config( - id = 102, - key = "mavlink.component_id", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 102, key = "mavlink.component_id", unit = "id", range(min = 1))] component_id: u8, - #[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")] - logging_enabled: bool, #[config( enum, id = 105, - key = "mavlink.logging.maximum_severity", - unit = "mav_severity" + key = "mavlink.logging.level_filter", + unit = "log_level" )] - maximum_log_severity: Severity, + log_level_filter: LevelFilter, } /// User-writable telemetry publication policy. @@ -89,8 +77,7 @@ impl F7Board for KakuteF7MiniV3 { mavlink: MavlinkConfig { system_id: 1, component_id: 1, - logging_enabled: true, - maximum_log_severity: Severity::Info, + log_level_filter: LevelFilter::Info, }, telemetry: TelemetryConfig { raw_imu_hz: 20, diff --git a/firmware/libs/kaptan-config-derive/tests/section.rs b/firmware/libs/kaptan-config-derive/tests/section.rs index a39ae65..7017775 100644 --- a/firmware/libs/kaptan-config-derive/tests/section.rs +++ b/firmware/libs/kaptan-config-derive/tests/section.rs @@ -73,12 +73,7 @@ enum TestMode { #[derive(Config)] struct CompactSection { - #[config( - id = 20, - key = "compact.endpoint", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 20, key = "compact.endpoint", unit = "id", range(min = 1))] endpoint: u8, #[config(enum, id = 21, key = "compact.mode", unit = "mode")] mode: TestMode, @@ -125,6 +120,11 @@ fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() { section.field_value(FieldId::new(20)), Some(ConfigValue::U32(7)) ); + assert_eq!( + CompactSection::DESCRIPTORS[0].minimum, + Some(ConfigValue::U32(1)) + ); + assert_eq!(CompactSection::DESCRIPTORS[0].maximum, None); assert_eq!( CompactSection::DESCRIPTORS[1].enum_options, TestMode::CONFIG_ENUM_OPTIONS diff --git a/firmware/libs/kaptan-config-mavlink/Cargo.toml b/firmware/libs/kaptan-config-mavlink/Cargo.toml index 22796f2..38ca220 100644 --- a/firmware/libs/kaptan-config-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-config-mavlink/Cargo.toml @@ -10,5 +10,5 @@ workspace = true [dependencies] kaptan-config = { path = "../kaptan-config" } -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] } diff --git a/firmware/libs/kaptan-config-mavlink/src/protocol.rs b/firmware/libs/kaptan-config-mavlink/src/protocol.rs index 06849a8..ccb7c3d 100644 --- a/firmware/libs/kaptan-config-mavlink/src/protocol.rs +++ b/firmware/libs/kaptan-config-mavlink/src/protocol.rs @@ -1,7 +1,7 @@ //! Stable typed outcomes for the Kaptan configuration microservice. use kaptan_config::{ConfigState, ConfigViolation, FieldId}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// Operation reported in an authoritative result record. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -36,28 +36,40 @@ pub enum ResultKind { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum ProtocolError { /// The request target did not match this component. + #[kaptan_error(id = 2001)] BadTarget, /// The configuration lease or single transaction slot is occupied. + #[kaptan_error(id = 2002)] SessionBusy, /// The session is unknown, expired, or belongs to a different principal. + #[kaptan_error(id = 2003)] InvalidSession, /// Reusing one request ID with different content is forbidden. + #[kaptan_error(id = 2004)] RequestConflict, /// The request was not based on the current transaction revision. + #[kaptan_error(id = 2005)] StaleRevision, /// A command is invalid in the current lifecycle state. + #[kaptan_error(id = 2006)] BadState, /// The field is not present in this schema. + #[kaptan_error(id = 2007)] UnknownField(FieldId), /// A query requested an empty or out-of-range descriptor span. + #[kaptan_error(id = 2008)] InvalidQuery, /// The typed setter does not match the field descriptor. + #[kaptan_error(id = 2009)] TypeMismatch(FieldId), /// A local or complete validation issue. + #[kaptan_error(id = 2010)] Violation(ConfigViolation), /// Persistence failed after a complete validation pass. + #[kaptan_error(id = 2011)] StorageFailure, /// The adapter reached an impossible internal state. + #[kaptan_error(id = 2012)] Internal, } diff --git a/firmware/libs/kaptan-config-mavlink/src/service/session.rs b/firmware/libs/kaptan-config-mavlink/src/service/session.rs index 9592986..068c014 100644 --- a/firmware/libs/kaptan-config-mavlink/src/service/session.rs +++ b/firmware/libs/kaptan-config-mavlink/src/service/session.rs @@ -23,6 +23,12 @@ pub(super) struct Session { pub(super) revision: u32, } +#[derive(Debug, kaptan_err_propagate::KaptanError)] +enum CommitTransactionError { + #[kaptan_error(id = 2201)] + Store(E), +} + /// Owns only the one GCS transaction lease and bounded protocol state. /// /// This protocol never accepts raw frames or interprets transport authority @@ -303,25 +309,11 @@ impl ConfigProtocol { } Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal), }, - ConfigCommand::Commit { request_id, .. } => match manager.commit() { - Ok(()) => { - self.session = None; - self.refresh(manager); - self.reply( - request_id, - 0, - session.revision, - Operation::Commit, - ResultKind::CommittedRestartRequired, - None, - ) - } - Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState), - Err(CommitError::InvalidCandidate(violation)) => { - self.rejected(command, ProtocolError::Violation(violation)) - } - Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::StorageFailure), - }, + ConfigCommand::Commit { request_id, .. } => self + .commit_transaction(manager, command, request_id, session) + .unwrap_or_else(|CommitTransactionError::Store(_)| { + self.rejected(command, ProtocolError::StorageFailure) + }), ConfigCommand::Abort { request_id, .. } => { manager.abort(); self.session = None; @@ -343,6 +335,43 @@ impl ConfigProtocol { } } + /// Classifies expected configuration rejection separately from a terminal + /// persistence failure. Only the latter leaves this boundary and becomes + /// a typed firmware error; the configuration result remains authoritative + /// for the requesting user. + #[kaptan_err_propagate::propagate] + fn commit_transaction( + &mut self, + manager: &mut ConfigManager, + command: ConfigCommand, + request_id: u32, + session: Session, + ) -> Result> + where + C: ConfigModel, + S: ConfigStore, + { + match manager.commit() { + Ok(()) => { + self.session = None; + self.refresh(manager); + Ok(self.reply( + request_id, + 0, + session.revision, + Operation::Commit, + ResultKind::CommittedRestartRequired, + None, + )) + } + Err(CommitError::NoTransaction) => Ok(self.rejected(command, ProtocolError::BadState)), + Err(CommitError::InvalidCandidate(violation)) => { + Ok(self.rejected(command, ProtocolError::Violation(violation))) + } + Err(CommitError::Store(error)) => Err(CommitTransactionError::Store(error)), + } + } + fn allocate_session_id(&mut self) -> Option { let id = self.next_session_id?; self.next_session_id = id.get().checked_add(1).and_then(NonZeroU32::new); @@ -646,6 +675,21 @@ mod tests { } } + struct FailingStore(Rc>); + + impl ConfigStore for FailingStore { + type Error = (); + + fn load(&mut self) -> Result, Self::Error> { + Ok(None) + } + + fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { + self.0.set(self.0.get() + 1); + Err(()) + } + } + /// Test-only composition root. Production code must retain these two /// objects separately; this wrapper keeps protocol examples compact while /// exercising the same externally-owned-manager dispatch API. @@ -1189,6 +1233,66 @@ mod tests { assert_eq!(next_owner.unwrap().result, ResultKind::Accepted); } + #[test] + fn failed_commit_reports_the_returned_error_only_once() { + while kaptan_err_propagate::take().is_some() {} + let persists = Rc::new(Cell::new(0)); + let store = FailingStore(Rc::clone(&persists)); + let mut manager = ConfigManager::new(Candidate(2), (), store).unwrap(); + let mut protocol = ConfigProtocol::new(Target::new(1, 1)); + let mut begin_reply = None; + protocol + .dispatch( + &mut manager, + Authorized::host( + 7, + ConfigCommand::Begin { + request_id: 1, + target: Target::new(1, 1), + }, + ), + |response| { + begin_reply = Some(result(response)); + Ok::<(), core::convert::Infallible>(()) + }, + ) + .unwrap(); + let commit = ConfigCommand::Commit { + request_id: 2, + session_id: begin_reply.unwrap().session_id, + expected_revision: 0, + target: Target::new(1, 1), + }; + + let mut first_reply = None; + protocol + .dispatch(&mut manager, Authorized::host(7, commit), |response| { + first_reply = Some(result(response)); + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert_eq!(persists.get(), 1); + assert_eq!( + first_reply.unwrap().error, + Some(ProtocolError::StorageFailure) + ); + let error = + kaptan_err_propagate::take().expect("failed commit must report its returned error"); + assert_eq!(error.id(), 2201); + while kaptan_err_propagate::take().is_some() {} + + protocol + .dispatch(&mut manager, Authorized::host(7, commit), |_| { + Ok::<(), core::convert::Infallible>(()) + }) + .unwrap(); + assert_eq!(persists.get(), 1); + assert!( + kaptan_err_propagate::take().is_none(), + "a cached command replay must not report the error again" + ); + } + #[test] fn configuration_result_remains_the_structured_failure_authority() { let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); diff --git a/firmware/libs/kaptan-config-mavlink/src/wire.rs b/firmware/libs/kaptan-config-mavlink/src/wire.rs index 88240d7..da8fb50 100644 --- a/firmware/libs/kaptan-config-mavlink/src/wire.rs +++ b/firmware/libs/kaptan-config-mavlink/src/wire.rs @@ -3,7 +3,7 @@ use kaptan_config::{ ConfigState, ConfigValue, FieldDescriptor, FieldId, ValidationRule, ValueKind, }; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_mavlink::message::Message; use kaptan_mavlink::message::kaptan::{ KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_INFO_DATA, @@ -67,14 +67,19 @@ where #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DecodeError { /// The message belongs to a response, telemetry, or another service. + #[kaptan_error(id = 2101)] UnsupportedMessage, /// A wire field identity used a reserved sentinel. + #[kaptan_error(id = 2102)] InvalidFieldId, /// The message used an unknown value-source discriminator. + #[kaptan_error(id = 2103)] InvalidValueSource, /// A boolean setter used a representation other than zero or one. + #[kaptan_error(id = 2104)] InvalidBoolean, /// A configuration request used a broadcast/zero target endpoint. + #[kaptan_error(id = 2105)] InvalidTarget, } diff --git a/firmware/libs/kaptan-config/Cargo.toml b/firmware/libs/kaptan-config/Cargo.toml index 6bbe80d..6727dc6 100644 --- a/firmware/libs/kaptan-config/Cargo.toml +++ b/firmware/libs/kaptan-config/Cargo.toml @@ -10,4 +10,5 @@ workspace = true [dependencies] kaptan-config-derive = { path = "../kaptan-config-derive" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-log = { path = "../kaptan-log" } diff --git a/firmware/libs/kaptan-config/src/field.rs b/firmware/libs/kaptan-config/src/field.rs index 535ddd5..90d0314 100644 --- a/firmware/libs/kaptan-config/src/field.rs +++ b/firmware/libs/kaptan-config/src/field.rs @@ -1,7 +1,7 @@ //! Transport-neutral typed configuration fields and descriptors. use crate::{ConfigViolation, ValidationRule}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// Maximum choices representable by one enum-backed field. /// @@ -234,8 +234,10 @@ pub struct FieldDescriptor { #[derive(Clone, Copy, Debug, PartialEq, KaptanError)] pub enum FieldWriteError { /// No descriptor exposes the requested identity. + #[kaptan_error(id = 1001)] UnknownField(FieldId), /// The supplied scalar type differs from the descriptor type. + #[kaptan_error(id = 1002)] WrongType { /// Requested field. field: FieldId, @@ -245,6 +247,7 @@ pub enum FieldWriteError { actual: ValueKind, }, /// A locally validated scalar violated a stable rule. + #[kaptan_error(id = 1003)] Violation(ConfigViolation), } diff --git a/firmware/libs/kaptan-config/src/lifecycle.rs b/firmware/libs/kaptan-config/src/lifecycle.rs index e7695e1..d8baa4c 100644 --- a/firmware/libs/kaptan-config/src/lifecycle.rs +++ b/firmware/libs/kaptan-config/src/lifecycle.rs @@ -4,7 +4,7 @@ use crate::{ ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, }; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// Observable state of the bounded configuration transaction slot. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -21,8 +21,10 @@ pub enum ConfigState { #[derive(Debug, KaptanError)] pub enum StartupError { /// The storage backend could not provide a committed record. + #[kaptan_error(id = 1101)] Store(E), /// A committed record did not validate against this firmware's schema. + #[kaptan_error(id = 1102)] InvalidPersisted(ConfigViolation), } @@ -30,6 +32,7 @@ pub enum StartupError { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BeginError { /// Exactly one transaction is already open. + #[kaptan_error(id = 1201)] TransactionActive, } @@ -37,8 +40,10 @@ pub enum BeginError { #[derive(Clone, Copy, Debug, PartialEq, KaptanError)] pub enum SetError { /// No transaction is open. + #[kaptan_error(id = 1301)] NoTransaction, /// The candidate rejected the local scalar without mutation. + #[kaptan_error(id = 1302)] Field(FieldWriteError), } @@ -46,10 +51,13 @@ pub enum SetError { #[derive(Debug, PartialEq, KaptanError)] pub enum CommitError { /// No transaction is open. + #[kaptan_error(id = 1401)] NoTransaction, /// The complete candidate violates a relational or capability invariant. + #[kaptan_error(id = 1402)] InvalidCandidate(ConfigViolation), /// The storage backend could not commit the snapshot. + #[kaptan_error(id = 1403)] Store(E), } @@ -197,6 +205,7 @@ where return Err(BeginError::TransactionActive); } self.transaction = Some(self.committed.clone()); + kaptan_log::debug!("edit transaction opened"); Ok(()) } @@ -219,10 +228,16 @@ where .transaction .as_ref() .ok_or(CommitError::NoTransaction)?; - candidate - .validate(&self.context) - .map(|_| ()) - .map_err(CommitError::InvalidCandidate) + match candidate.validate(&self.context) { + Ok(_) => { + kaptan_log::debug!("candidate validated"); + Ok(()) + } + Err(violation) => { + kaptan_log::warn!("candidate validation failed"); + Err(CommitError::InvalidCandidate(violation)) + } + } } /// Validates and durably commits the complete open transaction. @@ -231,19 +246,27 @@ where .transaction .as_ref() .ok_or(CommitError::NoTransaction)?; - candidate - .validate(&self.context) - .map_err(CommitError::InvalidCandidate)?; - self.store.persist(candidate).map_err(CommitError::Store)?; + if let Err(violation) = candidate.validate(&self.context) { + kaptan_log::warn!("commit validation failed"); + return Err(CommitError::InvalidCandidate(violation)); + } + if let Err(error) = self.store.persist(candidate) { + return Err(CommitError::Store(error)); + } self.committed = candidate.clone(); self.transaction = None; self.restart_required = true; + kaptan_log::info!("configuration saved; restart required"); Ok(()) } /// Drops the RAM transaction without changing active or committed state. pub fn abort(&mut self) -> bool { - self.transaction.take().is_some() + let aborted = self.transaction.take().is_some(); + if aborted { + kaptan_log::debug!("edit transaction aborted"); + } + aborted } } diff --git a/firmware/libs/kaptan-config/src/snapshot.rs b/firmware/libs/kaptan-config/src/snapshot.rs index 7c8baa7..b51ca87 100644 --- a/firmware/libs/kaptan-config/src/snapshot.rs +++ b/firmware/libs/kaptan-config/src/snapshot.rs @@ -16,7 +16,7 @@ //! field IDs and therefore also accepts a complete, uniquely identified set in //! another order. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use crate::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValueKind}; @@ -48,14 +48,19 @@ pub const fn scalar_snapshot_length(field_count: u16) -> usize { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum SnapshotModelError { /// `field_count` includes an index with no descriptor. + #[kaptan_error(id = 1501)] MissingDescriptor(u16), /// A descriptor exists at the first index after `field_count`. + #[kaptan_error(id = 1502)] ExtraDescriptor(u16), /// Two descriptor indexes expose the same stable field identity. + #[kaptan_error(id = 1503)] DuplicateDescriptor(FieldId), /// A descriptor has no readable value in the supplied candidate. + #[kaptan_error(id = 1504)] MissingValue(FieldId), /// A candidate returned a scalar type different from its descriptor. + #[kaptan_error(id = 1505)] WrongValueKind { /// Descriptor identity. field: FieldId, @@ -70,6 +75,7 @@ pub enum SnapshotModelError { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum ScalarSnapshotEncodeError { /// The destination cannot hold the complete canonical payload. + #[kaptan_error(id = 1601)] BufferTooSmall { /// Exact number of bytes required. required: usize, @@ -77,6 +83,7 @@ pub enum ScalarSnapshotEncodeError { available: usize, }, /// The model's descriptor/value contract is internally inconsistent. + #[kaptan_error(id = 1602)] InvalidModel(SnapshotModelError), } @@ -84,6 +91,7 @@ pub enum ScalarSnapshotEncodeError { #[derive(Clone, Copy, Debug, PartialEq, KaptanError)] pub enum ScalarSnapshotDecodeError { /// The input ends before the complete header or declared field set. + #[kaptan_error(id = 1701)] Truncated { /// Exact number of bytes required once it is known. required: usize, @@ -91,6 +99,7 @@ pub enum ScalarSnapshotDecodeError { available: usize, }, /// Bytes remain after the exact declared field set. + #[kaptan_error(id = 1702)] TrailingData { /// Exact number of bytes required. expected: usize, @@ -98,10 +107,13 @@ pub enum ScalarSnapshotDecodeError { actual: usize, }, /// The payload does not carry the scalar-snapshot magic. + #[kaptan_error(id = 1703)] InvalidMagic, /// The payload uses an unsupported scalar-snapshot format. + #[kaptan_error(id = 1704)] UnsupportedVersion(u8), /// The payload belongs to another configuration schema. + #[kaptan_error(id = 1705)] WrongSchema { /// Schema compiled into the target model. expected: [u8; 16], @@ -109,6 +121,7 @@ pub enum ScalarSnapshotDecodeError { actual: [u8; 16], }, /// The payload is not a complete snapshot for this schema. + #[kaptan_error(id = 1706)] WrongFieldCount { /// Exact number of fields compiled into the target model. expected: u16, @@ -116,18 +129,25 @@ pub enum ScalarSnapshotDecodeError { actual: u16, }, /// The target model's descriptor registry is internally inconsistent. + #[kaptan_error(id = 1707)] InvalidModel(SnapshotModelError), /// An entry used a reserved stable field identity. + #[kaptan_error(id = 1708)] InvalidFieldId(u16), /// An entry names no field in the target schema. + #[kaptan_error(id = 1709)] UnknownField(FieldId), /// The complete payload identifies one field more than once. + #[kaptan_error(id = 1710)] DuplicateField(FieldId), /// The complete payload does not identify a required schema field. + #[kaptan_error(id = 1711)] MissingField(FieldId), /// An entry contains no supported scalar-kind discriminator. + #[kaptan_error(id = 1712)] InvalidValueKind(u8), /// An entry's scalar kind differs from the target descriptor. + #[kaptan_error(id = 1713)] WrongValueKind { /// Entry identity. field: FieldId, @@ -137,6 +157,7 @@ pub enum ScalarSnapshotDecodeError { actual: ValueKind, }, /// Scalar bits are not canonical for the entry's declared kind. + #[kaptan_error(id = 1714)] InvalidValueBits { /// Entry identity. field: FieldId, @@ -144,6 +165,7 @@ pub enum ScalarSnapshotDecodeError { kind: ValueKind, }, /// The model rejected an otherwise structurally valid scalar write. + #[kaptan_error(id = 1715)] InvalidWrite(FieldWriteError), } diff --git a/firmware/libs/kaptan-control/Cargo.toml b/firmware/libs/kaptan-control/Cargo.toml index 0f99f39..253e86a 100644 --- a/firmware/libs/kaptan-control/Cargo.toml +++ b/firmware/libs/kaptan-control/Cargo.toml @@ -9,5 +9,5 @@ version.workspace = true workspace = true [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-math = { path = "../kaptan-math" } diff --git a/firmware/libs/kaptan-control/src/lib.rs b/firmware/libs/kaptan-control/src/lib.rs index a91e78f..29ff24a 100644 --- a/firmware/libs/kaptan-control/src/lib.rs +++ b/firmware/libs/kaptan-control/src/lib.rs @@ -7,7 +7,7 @@ #![no_std] -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_math::{Frd, Ned, UnitQuaternion, Vector3, sqrt}; /// Body-frame angular rate in radians per second. @@ -57,8 +57,10 @@ impl HeadingNed { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum AttitudeError { /// A zero or non-finite force has no defined thrust direction. + #[kaptan_error(id = 7001)] ZeroForce, /// Requested heading is parallel to the thrust axis. + #[kaptan_error(id = 7002)] SingularHeading, } @@ -105,10 +107,13 @@ pub struct PidConfig { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum PidError { /// Static PID gains or limits were non-finite or contradictory. + #[kaptan_error(id = 7101)] InvalidConfiguration, /// Time step was non-finite, non-positive, or exceeded the 100-ms integration bound. + #[kaptan_error(id = 7102)] InvalidTime, /// Error input was non-finite. + #[kaptan_error(id = 7103)] NonFinite, } @@ -320,12 +325,16 @@ pub struct KinematicLimits { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum TrajectoryError { /// A static speed/acceleration constraint is invalid. + #[kaptan_error(id = 7201)] InvalidLimits, /// A goal coordinate is non-finite. + #[kaptan_error(id = 7202)] NonFiniteGoal, /// The service interval was non-finite, non-positive, or exceeded the 100-ms integration bound. + #[kaptan_error(id = 7203)] InvalidTime, /// A finite input produced a non-finite intermediate or candidate state. + #[kaptan_error(id = 7204)] NumericalOverflow, } @@ -590,8 +599,10 @@ pub struct TranslationalControlConfig { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum TranslationalControlError { /// A gain or kinematic limit is invalid. + #[kaptan_error(id = 7301)] InvalidConfiguration, /// A reference or estimator value is non-finite. + #[kaptan_error(id = 7302)] NonFiniteState, } @@ -685,9 +696,11 @@ pub struct ThrustEnvelope { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum ThrustEnvelopeError { /// A static physical parameter is invalid. + #[kaptan_error(id = 7401)] InvalidConfiguration, /// A requested acceleration is non-finite or cannot be produced by /// positive multicopter thrust. + #[kaptan_error(id = 7402)] InvalidAcceleration, } @@ -825,14 +838,19 @@ pub struct BodyWrench { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum AllocatorError { /// One static allocation output has non-finite or contradictory limits. + #[kaptan_error(id = 7501)] InvalidActuator, /// Mixer contains non-finite values. + #[kaptan_error(id = 7502)] InvalidMixer, /// Time step was non-finite, non-positive, or exceeded the 100-ms integration bound. + #[kaptan_error(id = 7503)] InvalidTime, /// Requested wrench contains a non-finite component. + #[kaptan_error(id = 7504)] InvalidWrench, /// The requested actuator index is outside the static allocation map. + #[kaptan_error(id = 7505)] UnknownActuator, } diff --git a/firmware/libs/kaptan-data-channel/Cargo.toml b/firmware/libs/kaptan-data-channel/Cargo.toml index 025c70d..98d6dc1 100644 --- a/firmware/libs/kaptan-data-channel/Cargo.toml +++ b/firmware/libs/kaptan-data-channel/Cargo.toml @@ -9,7 +9,7 @@ version.workspace = true workspace = true [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-dma = { path = "../kaptan-dma" } kaptan-mavlink = { path = "../kaptan-mavlink" } kaptan-sensor = { path = "../kaptan-sensor" } diff --git a/firmware/libs/kaptan-data-channel/src/lib.rs b/firmware/libs/kaptan-data-channel/src/lib.rs index 52cc7e5..a40f27e 100644 --- a/firmware/libs/kaptan-data-channel/src/lib.rs +++ b/firmware/libs/kaptan-data-channel/src/lib.rs @@ -8,7 +8,7 @@ #![no_std] use kaptan_dma::TxCompletion; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_mavlink::message::MessageData; use kaptan_mavlink::{ MavlinkFrame, @@ -80,10 +80,13 @@ pub struct VerticalDataConfig { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DataChannelError { /// A configured channel must be due at least once per second. + #[kaptan_error(id = 4001)] ZeroRate, /// A configured channel exceeds the service frequency. + #[kaptan_error(id = 4002)] RateExceedsServiceFrequency, /// Two configured channels cannot share the one-frame-per-tick budget. + #[kaptan_error(id = 4003)] ScheduleFull, } diff --git a/firmware/libs/kaptan-log-mavlink/Cargo.toml b/firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml similarity index 80% rename from firmware/libs/kaptan-log-mavlink/Cargo.toml rename to firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml index 15ac1b1..28d5662 100644 --- a/firmware/libs/kaptan-log-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "kaptan-log-mavlink" +name = "kaptan-diagnostics-mavlink" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -9,9 +9,9 @@ version.workspace = true workspace = true [dependencies] +kaptan-err-propagate = { path = "../kaptan-err-propagate" } 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/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs b/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs new file mode 100644 index 0000000..9f7b43e --- /dev/null +++ b/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs @@ -0,0 +1,202 @@ +//! MAVLink projection for Kaptan logs and typed firmware errors. +//! +//! This adapter creates generated payload data only. A platform-owned link is +//! solely responsible for MAVLink framing, bounded admission, DMA, and retry +//! policy. + +#![no_std] + +#[cfg(test)] +extern crate std; + +use kaptan_err_propagate::ErrorRecord; +use kaptan_log::{Level, LogRecord, MAXIMUM_LOG_TEXT_BYTES}; +use kaptan_mavlink::message::kaptan::{KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KaptanLogFlags}; + +/// Exact capacity of the fixed log text field in [`KAPTAN_LOG_DATA`]. +pub const LOG_MESSAGE_BYTES: usize = MAXIMUM_LOG_TEXT_BYTES; +const ERROR_RAISED_AT_BYTES: usize = 96; + +/// Encodes one ordinary log record as its sole wire message. +pub fn encode_log(record: &LogRecord) -> KAPTAN_LOG_DATA { + let mut flags = KaptanLogFlags::from_bits_retain(record.flags().bits()); + if record.level() == Level::Trace { + flags.insert(KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE); + } + + KAPTAN_LOG_DATA { + level: mavlink_severity(record.level()), + flags, + message_length: record.message().len(), + message: *record.message().bytes(), + } +} + +/// Encodes one typed firmware error as its sole wire message. +pub fn encode_error(record: &ErrorRecord) -> KAPTAN_ERROR_DATA { + let source = record.raised_at().as_bytes(); + let mut length = source.len().min(ERROR_RAISED_AT_BYTES); + while !record.raised_at().is_char_boundary(length) { + length -= 1; + } + let mut raised_at = [0; ERROR_RAISED_AT_BYTES]; + raised_at[..length].copy_from_slice(&source[..length]); + + KAPTAN_ERROR_DATA { + error_id: record.id(), + raised_at, + } +} + +/// Projects the five conventional levels into MAVLink's wider severity +/// number space. This transport adapter is the only place that needs that +/// protocol-specific representation. +const fn mavlink_severity(level: Level) -> u8 { + match level { + Level::Error => 3, + Level::Warn => 4, + Level::Info => 6, + Level::Debug | Level::Trace => 7, + } +} + +#[cfg(test)] +mod tests { + use super::{LOG_MESSAGE_BYTES, encode_error, encode_log}; + use kaptan_err_propagate::KaptanError; + use kaptan_log::{Level, LogFlags, LogRecord, LogText}; + use kaptan_mavlink::{ + MavlinkVersion, + encoder::MavlinkEncoder, + message::{ + Message, MessageData, + kaptan::{KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KaptanLogFlags, MavMessage}, + }, + }; + + #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] + enum TestError { + #[kaptan_error(id = 65301)] + Transfer, + } + + #[kaptan_err_propagate::propagate] + fn report_typed_error() -> Result<(), TestError> { + Err(TestError::Transfer) + } + + #[test] + fn ordinary_record_preserves_level_flags_and_bounded_text() { + let record = LogRecord::new( + Level::Info, + LogFlags::TRUNCATED, + LogText::from_message("imu unavailable"), + ); + let data = encode_log(&record); + assert_eq!(data.level, 6); + 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 trace_level_owns_its_mavlink_projection() { + let record = LogRecord::new( + Level::Trace, + LogFlags::NONE, + LogText::from_message("scheduler tick"), + ); + let data = encode_log(&record); + assert_eq!(data.level, 7); + assert!(data.flags.contains(KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE)); + } + + #[test] + fn error_macro_remains_an_error_level_log() { + while kaptan_log::drain_one(|_| ()).is_some() {} + kaptan_log::error!("flight fault"); + + let payload = kaptan_log::drain_one(encode_log).expect("error-level log retained"); + assert_eq!(payload.level, 3); + assert_eq!( + &payload.message[..usize::from(payload.message_length)], + b"flight fault" + ); + } + + #[test] + fn generated_log_and_error_messages_round_trip() { + let record = LogRecord::new( + Level::Info, + LogFlags::NONE, + LogText::from_message(&"x".repeat(LOG_MESSAGE_BYTES)), + ); + let data = encode_log(&record); + 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); + assert!(matches!( + MavMessage::parse( + mavlink::MavlinkVersion::V2, + frame.message_id(), + frame.payload(), + ), + Ok(MavMessage::KAPTAN_LOG(decoded)) if decoded == data + )); + + while kaptan_err_propagate::take().is_some() {} + assert_eq!(report_typed_error(), Err(TestError::Transfer)); + let error = kaptan_err_propagate::take().expect("typed error retained"); + let error_data = encode_error(&error); + assert_eq!(error_data.error_id, error.id()); + assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 98); + let raised_at_length = error_data + .raised_at + .iter() + .position(|byte| *byte == 0) + .unwrap_or(error_data.raised_at.len()); + assert_eq!( + &error_data.raised_at[..raised_at_length], + error.raised_at().as_bytes() + ); + assert!( + error + .raised_at() + .contains("kaptan-diagnostics-mavlink/src/lib.rs:") + ); + let error_frame = encoder.custom(&error_data); + assert_eq!(error_frame.message_id(), 51_021); + assert!(error_frame.raw_bytes().len() <= 110); + 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 kaptan_error_wire_contract_is_stable() { + assert_eq!(KAPTAN_ERROR_DATA::ID, 51_021); + assert_eq!(KAPTAN_ERROR_DATA::EXTRA_CRC, 46); + assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 98); + + let data = KAPTAN_ERROR_DATA { + error_id: 1234, + raised_at: [b'A'; 96], + }; + assert_eq!(data.raised_at.len(), 96); + + let mut payload = [0_u8; KAPTAN_ERROR_DATA::ENCODED_LEN]; + let length = data.ser(MavlinkVersion::V2, &mut payload); + assert_eq!(length, KAPTAN_ERROR_DATA::ENCODED_LEN); + assert_eq!(&payload[..2], &[210, 4]); + assert_eq!(&payload[2..], &[b'A'; 96]); + } +} diff --git a/firmware/libs/kaptan-drivers/Cargo.toml b/firmware/libs/kaptan-drivers/Cargo.toml index 99e974d..9ebcc36 100644 --- a/firmware/libs/kaptan-drivers/Cargo.toml +++ b/firmware/libs/kaptan-drivers/Cargo.toml @@ -9,7 +9,7 @@ version.workspace = true workspace = true [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-sensor = { path = "../kaptan-sensor" } kaptan-i2c = { path = "../kaptan-i2c" } kaptan-spi = { path = "../kaptan-spi" } diff --git a/firmware/libs/kaptan-drivers/src/icm42688/error.rs b/firmware/libs/kaptan-drivers/src/icm42688/error.rs index 4454f8f..161ca2c 100644 --- a/firmware/libs/kaptan-drivers/src/icm42688/error.rs +++ b/firmware/libs/kaptan-drivers/src/icm42688/error.rs @@ -1,17 +1,21 @@ //! ICM-42688-P operation errors. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// ICM-42688-P operation failure. #[derive(Debug, Eq, PartialEq, KaptanError)] pub enum Error { /// The selected SPI route rejected a transfer submission. + #[kaptan_error(id = 5101)] Start(BusError), /// The responding device does not identify as an ICM-42688-P. + #[kaptan_error(id = 5102)] UnexpectedIdentity(u8), /// An acquisition request arrived before initialization completed or while /// another transfer still owned the selected route. + #[kaptan_error(id = 5103)] NotReady, /// The selected SPI route reported a terminal transfer fault. + #[kaptan_error(id = 5104)] TransferFault, } diff --git a/firmware/libs/kaptan-drivers/src/lidar_lite_v3/error.rs b/firmware/libs/kaptan-drivers/src/lidar_lite_v3/error.rs index 7cb87e0..feb9e4c 100644 --- a/firmware/libs/kaptan-drivers/src/lidar_lite_v3/error.rs +++ b/firmware/libs/kaptan-drivers/src/lidar_lite_v3/error.rs @@ -1,25 +1,33 @@ //! LIDAR-Lite v3 operation errors. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// LIDAR-Lite v3 operation failure. #[derive(Debug, Eq, PartialEq, KaptanError)] pub enum Error { /// The selected I²C route rejected a transaction submission. + #[kaptan_error(id = 5201)] Start(BusError), /// An acquisition request arrived while another measurement was active. + #[kaptan_error(id = 5202)] NotReady, /// The selected I²C route reported a terminal transaction fault. + #[kaptan_error(id = 5203)] TransferFault, /// The device reported a process error for the completed measurement. + #[kaptan_error(id = 5204)] ProcessError, /// The device reported that its reference or receiver bias is unhealthy. + #[kaptan_error(id = 5205)] Unhealthy, /// The device reported that no usable signal peak was detected. + #[kaptan_error(id = 5206)] InvalidSignal, /// A measurement remained busy beyond the driver's bounded acquisition interval. + #[kaptan_error(id = 5207)] MeasurementTimeout, /// The device reported a documented no-signal sentinel distance of zero or one centimetre. + #[kaptan_error(id = 5208)] NoSignal, } @@ -27,5 +35,6 @@ pub enum Error { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum StartupError { /// Startup completion was reported when the driver was not settling. + #[kaptan_error(id = 5301)] NotSettling, } diff --git a/firmware/libs/kaptan-drivers/src/lidar_lite_v3/service.rs b/firmware/libs/kaptan-drivers/src/lidar_lite_v3/service.rs index 421cb38..15d8073 100644 --- a/firmware/libs/kaptan-drivers/src/lidar_lite_v3/service.rs +++ b/firmware/libs/kaptan-drivers/src/lidar_lite_v3/service.rs @@ -55,11 +55,7 @@ impl LidarLiteV3Service { &mut self, completion: I2cCompletion, ) -> LidarLiteV3ServiceEvent { - match self.lidar.on_transaction_complete(completion) { - LidarLiteV3Event::Measurement(sample) => LidarLiteV3ServiceEvent::Measurement(sample), - LidarLiteV3Event::Fault(error) => LidarLiteV3ServiceEvent::Fault(error), - _ => LidarLiteV3ServiceEvent::Idle, - } + service_event(self.lidar.on_transaction_complete(completion)) } /// Acknowledges a board-controller interrupt and advances the acquisition. @@ -78,10 +74,21 @@ impl LidarLiteV3Service { } } +fn service_event(event: LidarLiteV3Event) -> LidarLiteV3ServiceEvent { + match event { + LidarLiteV3Event::Measurement(sample) => LidarLiteV3ServiceEvent::Measurement(sample), + LidarLiteV3Event::Fault(Error::InvalidSignal | Error::NoSignal) => { + LidarLiteV3ServiceEvent::Idle + } + LidarLiteV3Event::Fault(error) => LidarLiteV3ServiceEvent::Fault(error), + _ => LidarLiteV3ServiceEvent::Idle, + } +} + #[cfg(test)] mod tests { - use super::{LidarLiteV3Service, LidarLiteV3ServiceEvent}; - use crate::lidar_lite_v3::{AcquisitionMode, LidarLiteV3}; + use super::{LidarLiteV3Service, LidarLiteV3ServiceEvent, service_event}; + use crate::lidar_lite_v3::{AcquisitionMode, Error, LidarLiteV3, LidarLiteV3Event}; use kaptan_i2c::{I2cBus, I2cCompletion}; use kaptan_time::{MonotonicClock, Timestamp}; @@ -177,4 +184,20 @@ mod tests { && sample.distance_millimetres == 2_910 )); } + + #[test] + fn unavailable_measurements_are_not_terminal_service_faults() { + assert!(matches!( + service_event::<()>(LidarLiteV3Event::Fault(Error::InvalidSignal)), + LidarLiteV3ServiceEvent::Idle + )); + assert!(matches!( + service_event::<()>(LidarLiteV3Event::Fault(Error::NoSignal)), + LidarLiteV3ServiceEvent::Idle + )); + assert!(matches!( + service_event::<()>(LidarLiteV3Event::Fault(Error::Unhealthy)), + LidarLiteV3ServiceEvent::Fault(Error::Unhealthy) + )); + } } diff --git a/firmware/libs/kaptan-drivers/src/mpu6000/error.rs b/firmware/libs/kaptan-drivers/src/mpu6000/error.rs index 934f57e..20a3da5 100644 --- a/firmware/libs/kaptan-drivers/src/mpu6000/error.rs +++ b/firmware/libs/kaptan-drivers/src/mpu6000/error.rs @@ -1,17 +1,21 @@ //! MPU6000 operation errors. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// MPU6000 operation failure. #[derive(Debug, Eq, PartialEq, KaptanError)] pub enum Error { /// The selected SPI route rejected a transfer submission. + #[kaptan_error(id = 5001)] Start(BusError), /// The responding device does not identify as an MPU6000. + #[kaptan_error(id = 5002)] UnexpectedIdentity(u8), /// An acquisition request arrived before initialization completed or while /// another transfer still owned the selected route. + #[kaptan_error(id = 5003)] NotReady, /// The selected SPI route reported a terminal transfer fault. + #[kaptan_error(id = 5004)] TransferFault, } diff --git a/firmware/libs/kaptan-flight-config/Cargo.toml b/firmware/libs/kaptan-flight-config/Cargo.toml index f50d475..db9f36b 100644 --- a/firmware/libs/kaptan-flight-config/Cargo.toml +++ b/firmware/libs/kaptan-flight-config/Cargo.toml @@ -10,5 +10,6 @@ workspace = true [dependencies] kaptan-config = { path = "../kaptan-config" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-log = { path = "../kaptan-log" } minicbor = { version = "0.25", features = ["derive"] } diff --git a/firmware/libs/kaptan-flight-config/src/cbor.rs b/firmware/libs/kaptan-flight-config/src/cbor.rs index 870b219..76da9ff 100644 --- a/firmware/libs/kaptan-flight-config/src/cbor.rs +++ b/firmware/libs/kaptan-flight-config/src/cbor.rs @@ -3,7 +3,7 @@ use kaptan_config::{ ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind, }; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use minicbor::{Decoder, Encoder, encode::Write}; use crate::{CandidateProfile, FORMAT_VERSION, SCHEMA_ID, VehicleCapabilities}; @@ -17,12 +17,16 @@ const SCHEMA_KEY: u16 = u16::MAX; #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum CborError { /// The supplied payload exceeded the bounded configuration storage budget. + #[kaptan_error(id = 3001)] TooLarge, /// CBOR was malformed or did not match a supported versioned wire record. + #[kaptan_error(id = 3002)] Malformed, /// A second CBOR item followed the candidate snapshot. + #[kaptan_error(id = 3003)] TrailingData, /// The decoded candidate violated complete schema/capability validation. + #[kaptan_error(id = 3004)] InvalidProfile(ConfigViolation), } diff --git a/firmware/libs/kaptan-flight-config/src/profile/active.rs b/firmware/libs/kaptan-flight-config/src/profile/active.rs index 3ecc5f7..45c7d2d 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/active.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/active.rs @@ -201,7 +201,7 @@ pub struct VehicleProfile { pub(crate) thrust: ThrustSettings, pub(crate) rate_pid: PidSettings, pub(crate) actuator_limits: ActuatorLimits, - pub(crate) mavlink_logging: super::MavlinkLoggingSettings, + pub(crate) mavlink_logging: kaptan_log::LevelFilter, pub(crate) mixer: [[f32; 4]; 4], } @@ -228,7 +228,7 @@ impl VehicleProfile { self.actuator_limits } /// Immutable boot-active policy for the MAVLink diagnostic sink. - pub const fn mavlink_logging(self) -> super::MavlinkLoggingSettings { + pub const fn mavlink_logging(self) -> kaptan_log::LevelFilter { self.mavlink_logging } pub const fn mixer(self) -> [[f32; 4]; 4] { diff --git a/firmware/libs/kaptan-flight-config/src/profile/candidate.rs b/firmware/libs/kaptan-flight-config/src/profile/candidate.rs index 3235b07..98f97cc 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/candidate.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/candidate.rs @@ -4,11 +4,11 @@ use kaptan_config::{ Config, ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValidationRule, }; -use kaptan_log::Severity; +use kaptan_log::LevelFilter; use super::{ - ActuatorLimits, AidingSettings, ControlSettings, MavlinkLoggingSettings, PidSettings, - SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile, + ActuatorLimits, AidingSettings, ControlSettings, PidSettings, SafetySettings, ThrustSettings, + TimingSettings, VehicleCapabilities, VehicleProfile, control::{ ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, }, @@ -18,7 +18,7 @@ use super::{ }; /// Current persisted CBOR schema for the vehicle candidate. -pub const FORMAT_VERSION: u16 = 2; +pub const FORMAT_VERSION: u16 = 3; /// Complete editable vehicle candidate. /// @@ -83,8 +83,7 @@ impl CandidateProfile { slew_per_second: 8.0, }, mavlink_logging: MavlinkLoggingCandidate { - enabled: true, - maximum_severity: Severity::Info, + level_filter: LevelFilter::Info, }, } } @@ -147,8 +146,7 @@ impl CandidateProfile { slew_per_second: actuator_limits.slew_per_second(), }, mavlink_logging: MavlinkLoggingCandidate { - enabled: mavlink_logging.enabled(), - maximum_severity: mavlink_logging.maximum_severity(), + level_filter: mavlink_logging, }, } } @@ -203,10 +201,7 @@ impl CandidateProfile { maximum: self.actuator_limits.maximum, slew_per_second: self.actuator_limits.slew_per_second, }, - mavlink_logging: MavlinkLoggingSettings::new( - self.mavlink_logging.enabled, - self.mavlink_logging.maximum_severity, - ), + mavlink_logging: self.mavlink_logging.level_filter, mixer: capabilities.mixer(), }) } @@ -258,7 +253,7 @@ impl ConfigModel for CandidateProfile { mod tests { use kaptan_config::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValidationRule}; - use super::{CandidateProfile, Severity, VehicleCapabilities}; + use super::{CandidateProfile, LevelFilter, VehicleCapabilities}; use crate::profile::ids; const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x(); @@ -268,8 +263,8 @@ mod tests { assert_eq!( crate::SCHEMA_ID, [ - 0xc4, 0xf6, 0x36, 0xc0, 0x29, 0xd2, 0x3e, 0x29, 0xa3, 0x85, 0x5b, 0x36, 0xb7, 0xb3, - 0x59, 0x9c, + 0xa1, 0x4c, 0x28, 0xba, 0x08, 0xc0, 0xfd, 0xc3, 0xe2, 0x54, 0x7d, 0x77, 0x40, 0x3c, + 0xed, 0x94, ] ); } @@ -350,39 +345,35 @@ mod tests { #[test] fn mavlink_logging_policy_is_persisted_and_restart_only() { let mut candidate = CandidateProfile::factory(); - let severity_descriptor = CandidateProfile::descriptors() - .find(|descriptor| descriptor.display_key == "mavlink.logging.maximum_severity") + let level_descriptor = CandidateProfile::descriptors() + .find(|descriptor| descriptor.display_key == "mavlink.logging.level_filter") .unwrap(); assert_eq!( - severity_descriptor.enum_options, - Severity::CONFIG_ENUM_OPTIONS + level_descriptor.enum_options, + LevelFilter::CONFIG_ENUM_OPTIONS ); candidate .set_field( - descriptor_id("mavlink.logging.enabled"), - ConfigValue::Bool(false), + descriptor_id("mavlink.logging.level_filter"), + ConfigValue::U32(LevelFilter::Off.wire_value().into()), ) .unwrap(); let active = candidate.validate(&CAPABILITIES).unwrap(); - assert!(!active.mavlink_logging().enabled()); + assert_eq!(active.mavlink_logging(), LevelFilter::Off); candidate .set_field( - descriptor_id("mavlink.logging.maximum_severity"), - ConfigValue::U32(5), + descriptor_id("mavlink.logging.level_filter"), + ConfigValue::U32(LevelFilter::Trace.wire_value().into()), ) .unwrap(); assert_eq!( - candidate - .validate(&CAPABILITIES) - .unwrap() - .mavlink_logging() - .maximum_severity(), - Severity::Notice + candidate.validate(&CAPABILITIES).unwrap().mavlink_logging(), + LevelFilter::Trace ); assert!(matches!( candidate.set_field( - descriptor_id("mavlink.logging.maximum_severity"), - ConfigValue::U32(8), + descriptor_id("mavlink.logging.level_filter"), + ConfigValue::U32(6), ), Err(FieldWriteError::Violation(issue)) if issue.rule() == ValidationRule::InvalidEnumValue @@ -390,22 +381,21 @@ mod tests { } #[test] - fn every_persisted_log_severity_is_retained_exactly() { - for raw in 0..=7 { + fn every_persisted_log_filter_is_retained_exactly() { + for raw in 0..=5 { let mut candidate = CandidateProfile::factory(); candidate .set_field( - descriptor_id("mavlink.logging.maximum_severity"), + descriptor_id("mavlink.logging.level_filter"), ConfigValue::U32(raw), ) - .expect("descriptor range accepts the stable severity"); + .expect("descriptor accepts the stable level filter"); assert_eq!( candidate .validate(&CAPABILITIES) .expect("valid complete profile") - .mavlink_logging() - .maximum_severity(), - Severity::try_from_wire(u8::try_from(raw).unwrap()).unwrap() + .mavlink_logging(), + LevelFilter::try_from_wire(u8::try_from(raw).unwrap()).unwrap() ); } } diff --git a/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs b/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs index 552cf1b..12493bb 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/mavlink_logging.rs @@ -1,44 +1,16 @@ //! Persisted, restart-only MAVLink diagnostic-sink policy. use kaptan_config::Config; -use kaptan_log::Severity; +use kaptan_log::LevelFilter; /// Candidate policy for the MAVLink diagnostic sink. #[derive(Clone, Copy, Config)] pub(super) struct MavlinkLoggingCandidate { - #[config(id = 24, key = "mavlink.logging.enabled", unit = "bool")] - pub(super) enabled: bool, #[config( enum, id = 25, - key = "mavlink.logging.maximum_severity", - unit = "mav_severity" + key = "mavlink.logging.level_filter", + unit = "log_level" )] - pub(super) maximum_severity: Severity, -} - -/// Immutable boot-active policy for the MAVLink diagnostic sink. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct MavlinkLoggingSettings { - enabled: bool, - maximum_severity: Severity, -} - -impl MavlinkLoggingSettings { - pub(crate) const fn new(enabled: bool, maximum_severity: Severity) -> Self { - Self { - enabled, - maximum_severity, - } - } - - /// Whether the MAVLink diagnostic sink accepts records. - pub const fn enabled(self) -> bool { - self.enabled - } - - /// Maximum accepted MAVLink severity, from Emergency (0) through Debug (7). - pub const fn maximum_severity(self) -> Severity { - self.maximum_severity - } + pub(super) level_filter: LevelFilter, } diff --git a/firmware/libs/kaptan-flight-config/src/profile/mod.rs b/firmware/libs/kaptan-flight-config/src/profile/mod.rs index b199a56..b7617ce 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/mod.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/mod.rs @@ -13,4 +13,3 @@ pub use active::{ TimingSettings, VehicleCapabilities, VehicleProfile, }; pub use candidate::{CandidateProfile, FORMAT_VERSION}; -pub use mavlink_logging::MavlinkLoggingSettings; diff --git a/firmware/libs/kaptan-flight-runtime/src/lib.rs b/firmware/libs/kaptan-flight-runtime/src/lib.rs index 171cdec..bcc1cf3 100644 --- a/firmware/libs/kaptan-flight-runtime/src/lib.rs +++ b/firmware/libs/kaptan-flight-runtime/src/lib.rs @@ -33,13 +33,7 @@ where store: S, ) -> Result> { let configuration = ConfigManager::recover(factory, capabilities, store)?; - let logging = configuration.active().mavlink_logging(); - let filter = if logging.enabled() { - kaptan_log::LogFilter::up_to(logging.maximum_severity()) - } else { - kaptan_log::LogFilter::disabled() - }; - let mavlink_logs = RuntimeMavlinkLogQueue::new(filter); + let mavlink_logs = RuntimeMavlinkLogQueue::new(configuration.active().mavlink_logging()); Ok(Self { configuration, mavlink_logs, @@ -76,7 +70,7 @@ where } /// Immutable boot-active MAVLink log policy copied from the validated profile. - pub const fn mavlink_log_filter(&self) -> kaptan_log::LogFilter { + pub const fn mavlink_log_filter(&self) -> kaptan_log::LevelFilter { self.mavlink_logs.filter() } } @@ -84,7 +78,7 @@ where #[cfg(test)] mod tests { use kaptan_config::{ConfigModel, ConfigStore, ConfigValue}; - use kaptan_log::{LogFlags, LogRecord, LogText, Severity}; + use kaptan_log::{Level, LevelFilter, LogFlags, LogRecord, LogText}; use super::FlightRuntime; @@ -113,18 +107,11 @@ mod tests { ) .expect("factory configuration validates"); assert_eq!( - runtime - .configuration() - .active() - .mavlink_logging() - .maximum_severity(), - runtime - .mavlink_log_filter() - .maximum_severity() - .expect("factory enables MAVLink logs") + runtime.configuration().active().mavlink_logging(), + runtime.mavlink_log_filter() ); runtime.publish_log_event(LogRecord::new( - Severity::Info, + Level::Info, LogFlags::NONE, LogText::from_message("runtime ready"), )); @@ -132,13 +119,13 @@ mod tests { } #[test] - fn disabled_mavlink_sink_filters_every_severity() { + fn disabled_mavlink_sink_filters_every_level() { let mut factory = kaptan_flight_config::CandidateProfile::factory(); - let enabled = kaptan_flight_config::CandidateProfile::descriptors() - .find(|descriptor| descriptor.display_key == "mavlink.logging.enabled") - .expect("MAVLink logging enable descriptor"); + let level_filter = kaptan_flight_config::CandidateProfile::descriptors() + .find(|descriptor| descriptor.display_key == "mavlink.logging.level_filter") + .expect("MAVLink log-level descriptor"); factory - .set_field(enabled.id, ConfigValue::Bool(false)) + .set_field(level_filter.id, ConfigValue::U32(0)) .unwrap(); let mut runtime = FlightRuntime::recover( factory, @@ -147,9 +134,9 @@ mod tests { ) .expect("disabled MAVLink sink is valid"); - assert_eq!(runtime.mavlink_log_filter().maximum_severity(), None); + assert_eq!(runtime.mavlink_log_filter(), LevelFilter::Off); runtime.publish_log_event(LogRecord::new( - Severity::Error, + Level::Error, LogFlags::NONE, LogText::from_message("not routed to MAVLink"), )); diff --git a/firmware/libs/kaptan-log-derive/Cargo.toml b/firmware/libs/kaptan-log-derive/Cargo.toml deleted file mode 100644 index 8930604..0000000 --- a/firmware/libs/kaptan-log-derive/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "kaptan-log-derive" -edition.workspace = true -license.workspace = true -rust-version.workspace = true -version.workspace = true - -[lib] -proc-macro = true - -[lints] -workspace = true - -[dependencies] -proc-macro2 = "1" -quote = "1" -syn = { version = "2", features = ["full"] } - -[dev-dependencies] -trybuild = "1" diff --git a/firmware/libs/kaptan-log-derive/src/lib.rs b/firmware/libs/kaptan-log-derive/src/lib.rs deleted file mode 100644 index d94e695..0000000 --- a/firmware/libs/kaptan-log-derive/src/lib.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! 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 ERROR: ::kaptan_log::StaticError = - ::kaptan_log::StaticError::new(concat!( - module_path!(), - "::", - stringify!(#ident), - "::", - stringify!(#variant_ident), - )); - &ERROR - } - } - }); - 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 static_error(&self) -> &'static ::kaptan_log::StaticError { - 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( - ::static_error(self).path(), - ) - } - } - - impl #error_impl_generics ::core::error::Error - for #ident #error_type_generics #error_where_clause {} - }) -} diff --git a/firmware/libs/kaptan-log-derive/tests/ui.rs b/firmware/libs/kaptan-log-derive/tests/ui.rs deleted file mode 100644 index 4fa8efd..0000000 --- a/firmware/libs/kaptan-log-derive/tests/ui.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[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/firmware/libs/kaptan-log-derive/tests/ui/enum.rs b/firmware/libs/kaptan-log-derive/tests/ui/enum.rs deleted file mode 100644 index 96b21d5..0000000 --- a/firmware/libs/kaptan-log-derive/tests/ui/enum.rs +++ /dev/null @@ -1,62 +0,0 @@ -extern crate self as kaptan_log; - -use kaptan_log_derive::KaptanError; - -pub struct StaticError { - path: &'static str, - additional_information: &'static str, -} - -impl StaticError { - pub const fn new(text: &'static str) -> Self { - Self { - path: text, - additional_information: "", - } - } - - pub const fn path(&self) -> &'static str { - self.path - } - - pub const fn additional_information(&self) -> &'static str { - self.additional_information - } -} - -pub trait ErrorReport { - fn static_error(&self) -> &'static StaticError; -} - -#[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.static_error().path(), - concat!(module_path!(), "::Generic::Unit"), - ); - assert_eq!( - Generic::Tuple(1_u8, 2).static_error().path(), - concat!(module_path!(), "::Generic::Tuple"), - ); - let named: Generic<'_, u8> = Generic::Named { label: "test" }; - assert_eq!( - named.to_string(), - concat!(module_path!(), "::Generic::Named"), - ); - assert_eq!( - named.static_error().additional_information(), - "", - "derives emit identity only; callers own optional context", - ); -} diff --git a/firmware/libs/kaptan-log-derive/tests/ui/struct.rs b/firmware/libs/kaptan-log-derive/tests/ui/struct.rs deleted file mode 100644 index 0d16e9c..0000000 --- a/firmware/libs/kaptan-log-derive/tests/ui/struct.rs +++ /dev/null @@ -1,8 +0,0 @@ -use kaptan_log_derive::KaptanError; - -#[derive(KaptanError)] -struct NotAnError { - code: u8, -} - -fn main() {} diff --git a/firmware/libs/kaptan-log-derive/tests/ui/struct.stderr b/firmware/libs/kaptan-log-derive/tests/ui/struct.stderr deleted file mode 100644 index bc44d33..0000000 --- a/firmware/libs/kaptan-log-derive/tests/ui/struct.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: KaptanError can only be derived for enums, not structs - --> tests/ui/struct.rs:4:8 - | -4 | struct NotAnError { - | ^^^^^^^^^^ diff --git a/firmware/libs/kaptan-log-derive/tests/ui/union.rs b/firmware/libs/kaptan-log-derive/tests/ui/union.rs deleted file mode 100644 index dac9238..0000000 --- a/firmware/libs/kaptan-log-derive/tests/ui/union.rs +++ /dev/null @@ -1,8 +0,0 @@ -use kaptan_log_derive::KaptanError; - -#[derive(KaptanError)] -union NotAnError { - code: u8, -} - -fn main() {} diff --git a/firmware/libs/kaptan-log-derive/tests/ui/union.stderr b/firmware/libs/kaptan-log-derive/tests/ui/union.stderr deleted file mode 100644 index 9daf8d2..0000000 --- a/firmware/libs/kaptan-log-derive/tests/ui/union.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: KaptanError can only be derived for enums, not unions - --> tests/ui/union.rs:4:7 - | -4 | union NotAnError { - | ^^^^^^^^^^ diff --git a/firmware/libs/kaptan-log-mavlink/src/lib.rs b/firmware/libs/kaptan-log-mavlink/src/lib.rs deleted file mode 100644 index 9a93aa3..0000000 --- a/firmware/libs/kaptan-log-mavlink/src/lib.rs +++ /dev/null @@ -1,322 +0,0 @@ -//! 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, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, MAXIMUM_ERROR_PATH_BYTES, - MAXIMUM_LOG_TEXT_BYTES, 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 = MAXIMUM_LOG_TEXT_BYTES; -/// Exact capacity of the fixed error identity path field. -pub const ERROR_PATH_BYTES: usize = MAXIMUM_ERROR_PATH_BYTES; -/// Exact capacity of the fixed error additional-information field. -pub const ERROR_ADDITIONAL_INFORMATION_BYTES: usize = MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES; - -/// 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 { - match record.channel() { - RecordChannel::Log => { - let message = record - .message() - .expect("log channel must contain ordinary message text"); - debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES); - DiagnosticPayload::Log(KAPTAN_LOG_DATA { - level: record.severity().wire_value(), - flags: KaptanLogFlags::from_bits_retain(record.flags().log_bits()), - message_length: message.len(), - message: *message.bytes(), - }) - } - RecordChannel::Error => { - let path = record - .error_path() - .expect("error channel must contain an identity path"); - let additional_information = record - .additional_information() - .expect("error channel must contain additional-information storage"); - debug_assert_eq!(path.bytes().len(), ERROR_PATH_BYTES); - debug_assert_eq!( - additional_information.bytes().len(), - ERROR_ADDITIONAL_INFORMATION_BYTES - ); - DiagnosticPayload::Error(KAPTAN_ERROR_DATA { - level: record.severity().wire_value(), - flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()), - path_length: path.len(), - additional_information_length: additional_information.len(), - path: *path.bytes(), - additional_information: *additional_information.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::User => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_USER, - ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY, - }, - } -} - -#[cfg(test)] -mod tests { - use std::sync::Once; - - use super::{ - DiagnosticPayload, ERROR_ADDITIONAL_INFORMATION_BYTES, ERROR_PATH_BYTES, LOG_MESSAGE_BYTES, - encode_diagnostic, encode_shutdown, - }; - use kaptan_log::{ - ErrorAdditionalInformation, ErrorPath, FacadeIngress, GlobalLogger, 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(); - 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.with(LogFlags::COALESCED), - 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.wire_value()); - 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 record = ERROR_INGRESS - .take() - .expect("critical macro record retained"); - let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else { - panic!("log::error! must not produce KAPTAN_LOG"); - }; - assert!( - payload.path[..usize::from(payload.path_length)] - .ends_with(b"kaptan_log_mavlink::tests") - ); - assert_eq!( - &payload.additional_information[..usize::from(payload.additional_information_length)], - 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::error( - LogFlags::TRACE.with(LogFlags::COALESCED), - ErrorPath::from_message("driver::Error::Transfer"), - ErrorAdditionalInformation::new(), - ); - 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.level, Severity::Error.wire_value()); - assert_eq!(error_data.path_length, 23); - assert_eq!(error_data.additional_information_length, 0); - assert_eq!(&error_data.path[..23], b"driver::Error::Transfer"); - assert!( - error_data - .additional_information - .iter() - .all(|byte| *byte == 0) - ); - assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 164); - let error_frame = encoder.custom(&error_data); - assert_eq!(error_frame.message_id(), 51_021); - assert!(error_frame.raw_bytes().len() <= 176); - 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 error_record_bounds_each_component_before_wire_projection() { - let error = LogRecord::error( - LogFlags::NONE, - ErrorPath::from_message(&"p".repeat(LOG_MESSAGE_BYTES)), - ErrorAdditionalInformation::from_message(&"i".repeat(LOG_MESSAGE_BYTES)), - ); - let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else { - panic!("error record must use KAPTAN_ERROR"); - }; - assert_eq!(usize::from(payload.path_length), ERROR_PATH_BYTES); - assert_eq!( - usize::from(payload.additional_information_length), - ERROR_ADDITIONAL_INFORMATION_BYTES - ); - assert_eq!(payload.flags.bits(), LogFlags::TRUNCATED.bits()); - assert_eq!(&payload.path, &[b'p'; ERROR_PATH_BYTES]); - assert_eq!( - &payload.additional_information, - &[b'i'; ERROR_ADDITIONAL_INFORMATION_BYTES] - ); - } - - #[test] - fn ordinary_error_constructor_preserves_narrowing_as_truncation() { - let error = LogRecord::new( - Severity::Error, - LogFlags::NONE, - LogText::from_message(&"p".repeat(ERROR_PATH_BYTES + 1)), - ); - let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else { - panic!("error record must use KAPTAN_ERROR"); - }; - assert_eq!(usize::from(payload.path_length), ERROR_PATH_BYTES); - assert_eq!(&payload.path, &[b'p'; ERROR_PATH_BYTES]); - assert_eq!(payload.flags.bits(), LogFlags::TRUNCATED.bits()); - } - - #[test] - fn error_wire_preserves_every_error_severity() { - for severity in [ - Severity::Emergency, - Severity::Alert, - Severity::Critical, - Severity::Error, - ] { - let error = LogRecord::new( - severity, - LogFlags::NONE, - LogText::from_message("flight fault"), - ); - let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else { - panic!("error severity must use KAPTAN_ERROR"); - }; - assert_eq!(payload.level, severity.wire_value()); - } - } - - #[test] - fn error_path_remains_decodable_when_only_additional_information_clips() { - let error = LogRecord::error( - LogFlags::NONE, - ErrorPath::from_message("kaptan::driver::Transfer"), - ErrorAdditionalInformation::from_message(&"é".repeat(80)), - ); - let DiagnosticPayload::Error(payload) = encode_diagnostic(&error) else { - panic!("error record must use KAPTAN_ERROR"); - }; - assert_eq!( - core::str::from_utf8(&payload.path[..usize::from(payload.path_length)]), - Ok("kaptan::driver::Transfer") - ); - assert_eq!(usize::from(payload.additional_information_length), 64); - assert!( - core::str::from_utf8( - &payload.additional_information - [..usize::from(payload.additional_information_length)] - ) - .is_ok() - ); - assert!(payload.flags.bits() & LogFlags::TRUNCATED.bits() != 0); - } - - #[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/firmware/libs/kaptan-log/Cargo.toml b/firmware/libs/kaptan-log/Cargo.toml index e8c4a1d..8a878fb 100644 --- a/firmware/libs/kaptan-log/Cargo.toml +++ b/firmware/libs/kaptan-log/Cargo.toml @@ -8,13 +8,5 @@ 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-config-derive = { path = "../kaptan-config-derive" } -kaptan-log-derive = { path = "../kaptan-log-derive" } -log = { version = "0.4.33", default-features = false, optional = true } diff --git a/firmware/libs/kaptan-log/src/crash.rs b/firmware/libs/kaptan-log/src/crash.rs deleted file mode 100644 index 17da920..0000000 --- a/firmware/libs/kaptan-log/src/crash.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! 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.static_error().path(), - "kaptan_log::crash::CrashRecordError::BadChecksum" - ); - } -} diff --git a/firmware/libs/kaptan-log/src/facade.rs b/firmware/libs/kaptan-log/src/facade.rs index 6dfb3bf..1339cfc 100644 --- a/firmware/libs/kaptan-log/src/facade.rs +++ b/firmware/libs/kaptan-log/src/facade.rs @@ -1,548 +1,231 @@ -//! 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. +//! Fixed, allocation-free ingress used by the logging macros. use core::{ cell::UnsafeCell, + fmt, sync::atomic::{AtomicBool, Ordering}, }; -use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError}; +use crate::{Level, LogFlags, LogRecord, LogText}; -use crate::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity}; +const GLOBAL_INGRESS_CAPACITY: usize = 2; +static GLOBAL_INGRESS: LogIngress = LogIngress::new(); -/// 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, - /// 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 LogIngress { + entries: UnsafeCell<[Option; N]>, + head: UnsafeCell, + len: UnsafeCell, + busy: AtomicBool, } -struct FacadeCounters { - forbidden_context: core::sync::atomic::AtomicU32, - contended: core::sync::atomic::AtomicU32, - full: core::sync::atomic::AtomicU32, - truncated: core::sync::atomic::AtomicU32, -} +// SAFETY: mutable state is accessed only while `busy` grants exclusive access. +// Producers and the consumer make one attempt and never wait or retry. +unsafe impl Sync for LogIngress {} -impl FacadeCounters { +impl LogIngress { const fn new() -> Self { + assert!(N > 0, "log ingress must be nonempty"); Self { - forbidden_context: 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), + entries: UnsafeCell::new([None; N]), + head: UnsafeCell::new(0), + len: UnsafeCell::new(0), + busy: AtomicBool::new(false), } } - /// 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), - 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, - body: IngressBody, -} - -#[derive(Clone, Copy)] -enum IngressBody { - Log { - message: LogText, - }, - Error { - path: ErrorPath, - additional_information: ErrorAdditionalInformation, - }, -} - -impl IngressBody { - const fn is_truncated(self) -> bool { - match self { - Self::Log { message } => message.is_truncated(), - Self::Error { - path, - additional_information, - } => path.is_truncated() || additional_information.is_truncated(), - } - } -} - -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 takes each -/// transport-neutral record and fans it out to independently filtered sinks. -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, - 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 an empty fixed ingress. - pub const fn new() -> Self { - Self { - critical: UnsafeCell::new(None), - critical_busy: AtomicBool::new(false), - queue: UnsafeCell::new(IngressQueue::new()), - queue_busy: AtomicBool::new(false), - counters: FacadeCounters::new(), - } - } - - 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 + fn publish(&self, level: Level, flags: LogFlags, arguments: fmt::Arguments<'_>) { + let message = arguments + .as_str() + .map_or_else(|| LogText::from_arguments(arguments), LogText::from_message); + let record = LogRecord::new(level, flags, message); + let _ = self.with_state(|entries, head, len| { + if *len < N { + entries[(*head + *len) % N] = Some(record); + *len += 1; + return; } - }) { - 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() - } - - /// Takes at most one preformatted event for composition-level fan-out. - /// - /// The ingress deliberately applies no sink policy. A caller may submit - /// the returned copy to MAVLink, blackbox, or any later sink without one - /// sink's verbosity selection suppressing another's event. - pub fn take(&self) -> 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(match record.body { - IngressBody::Log { message } => LogRecord::new(record.severity, record.flags, message), - IngressBody::Error { - path, - additional_information, - } => LogRecord::error(record.flags, path, additional_information), - }) - } - - 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) - } -} - -impl Default for FacadeIngress { - fn default() -> Self { - Self::new() - } -} - -/// 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. Each sink applies its policy after it - /// receives a record from the ingress. - 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<'_>) -> bool { - if (self.forbidden_context)() { - FacadeCounters::increment(&self.ingress.counters.forbidden_context); - return false; - } - true - } - - fn log(&self, record: &Record<'_>) { - if (self.forbidden_context)() { - FacadeCounters::increment(&self.ingress.counters.forbidden_context); - return; - } - let severity = severity(record.level()); - let flags = if record.level() == Level::Trace { - LogFlags::TRACE - } else { - LogFlags::NONE - }; - let body = if severity <= Severity::Error { - IngressBody::Error { - path: ErrorPath::from_message(record.target()), - additional_information: ErrorAdditionalInformation::from_arguments(format_args!( - "{}", - record.args() - )), + if level != Level::Error { + return; } - } else { - IngressBody::Log { - message: LogText::from_arguments(format_args!( - "[{}] {}", - record.target(), - record.args() - )), - } - }; - if body.is_truncated() { - FacadeCounters::increment(&self.ingress.counters.truncated); - } - self.ingress.push(IngressRecord { - severity, - flags, - body, + let Some(oldest_best_effort) = (0..*len).find(|offset| { + entries[(*head + *offset) % N] + .is_some_and(|pending| pending.level() != Level::Error) + }) else { + return; + }; + Self::remove(entries, *head, len, oldest_best_effort); + entries[(*head + *len) % N] = Some(record); + *len += 1; }); } - fn flush(&self) {} + fn remove(entries: &mut [Option; N], head: usize, len: &mut usize, offset: usize) { + debug_assert!(offset < *len); + for current in offset..(*len - 1) { + let source = (head + current + 1) % N; + let destination = (head + current) % N; + entries[destination] = entries[source].take(); + } + entries[(head + *len - 1) % N] = None; + *len -= 1; + } + + fn drain_one(&self, consume: impl FnOnce(&LogRecord) -> R) -> Option { + self.with_state(|entries, head, len| { + if *len == 0 { + return None; + } + let record = entries[*head].as_ref()?; + let result = consume(record); + entries[*head] = None; + *head = (*head + 1) % N; + *len -= 1; + Some(result) + }) + .flatten() + } + + fn with_state( + &self, + operation: impl FnOnce(&mut [Option; N], &mut usize, &mut usize) -> R, + ) -> Option { + if self + .busy + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return None; + } + // SAFETY: `busy` grants exclusive access to all three cells. + let result = operation( + unsafe { &mut *self.entries.get() }, + unsafe { &mut *self.head.get() }, + unsafe { &mut *self.len.get() }, + ); + self.busy.store(false, Ordering::Release); + Some(result) + } } -const fn severity(level: Level) -> Severity { - match level { - Level::Error => Severity::Error, - Level::Warn => Severity::Warning, - Level::Info => Severity::Info, - Level::Debug | Level::Trace => Severity::Debug, - } +/// Publishes one macro event to the fixed global ingress. +#[doc(hidden)] +#[inline] +pub fn publish(level: Level, flags: LogFlags, arguments: fmt::Arguments<'_>) { + GLOBAL_INGRESS.publish(level, flags, arguments); +} + +/// Visits and removes the oldest pending log event. +/// +/// The callback runs during the ingress's one nonblocking ownership attempt; +/// it should perform only bounded composition-level sink fanout. +#[inline] +pub fn drain_one(consume: impl FnOnce(&LogRecord) -> R) -> Option { + GLOBAL_INGRESS.drain_one(consume) } #[cfg(test)] mod tests { - use core::fmt; - use std::sync::Once; + use core::sync::atomic::Ordering; - use super::{FacadeIngress, GlobalLogger}; - use crate::{RecordChannel, Severity}; - use log::{Level, Log, Metadata, Record}; + use super::{LogIngress, drain_one}; + use crate::{Level, LogFlags}; + use std::sync::Mutex; - static GLOBAL_INGRESS: FacadeIngress<2> = FacadeIngress::new(); - static GLOBAL_LOGGER: GlobalLogger<2> = GlobalLogger::new(&GLOBAL_INGRESS, never_forbidden); - static INSTALL_GLOBAL: Once = Once::new(); + static GLOBAL_INGRESS_TEST: Mutex<()> = Mutex::new(()); - fn never_forbidden() -> bool { - false - } - - fn always_forbidden() -> bool { - true + #[test] + fn every_macro_produces_the_same_log_record_kind() { + let _guard = GLOBAL_INGRESS_TEST + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while drain_one(|_| ()).is_some() {} + crate::info!("configuration loaded"); + crate::error!("transfer failed"); + assert_eq!(drain_one(|record| record.level()), Some(Level::Info)); + assert_eq!( + drain_one(|record| { + assert_eq!(record.message().as_str(), "transfer failed"); + record.level() + }), + Some(Level::Error) + ); } #[test] - fn rejects_forbidden_context_before_formatting() { - static INGRESS: FacadeIngress<2> = FacadeIngress::new(); - 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); + fn full_mixed_ingress_keeps_critical_records_and_evicts_best_effort() { + let ingress = LogIngress::<2>::new(); + ingress.publish(Level::Info, LogFlags::NONE, core::format_args!("advance")); + assert_eq!( + ingress.drain_one(|record| assert_eq!(record.message().as_str(), "advance")), + Some(()) + ); + ingress.publish(Level::Info, LogFlags::NONE, core::format_args!("optional")); + ingress.publish( + Level::Error, + LogFlags::NONE, + core::format_args!("first error"), + ); + ingress.publish( + Level::Error, + LogFlags::NONE, + core::format_args!("second error"), + ); - static UNFILTERED: FacadeIngress<2> = FacadeIngress::new(); - let unfiltered_logger = GlobalLogger::new(&UNFILTERED, never_forbidden); - let debug = Metadata::builder() - .level(Level::Debug) - .target("test") - .build(); - assert!(unfiltered_logger.enabled(&debug)); - unfiltered_logger.log( - &Record::builder() - .metadata(debug) - .args(format_args!("sink decides whether to keep this")) - .build(), + assert_eq!( + ingress.drain_one(|record| assert_eq!(record.message().as_str(), "first error")), + Some(()) ); assert_eq!( - UNFILTERED - .take() - .expect("debug event reaches the unfiltered ingress") - .severity(), - Severity::Debug + ingress.drain_one(|record| assert_eq!(record.message().as_str(), "second error")), + Some(()) ); + assert!(ingress.drain_one(|_| ()).is_none()); } #[test] - fn formats_once_then_exposes_a_transport_neutral_record() { - static INGRESS: FacadeIngress<2> = FacadeIngress::new(); - 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 pending = INGRESS.take().expect("facade event retained"); - assert_eq!(pending.error_path().unwrap().as_str(), "test"); + fn full_critical_ingress_keeps_the_earlier_records() { + let ingress = LogIngress::<2>::new(); + ingress.publish( + Level::Error, + LogFlags::NONE, + core::format_args!("first error"), + ); + ingress.publish( + Level::Error, + LogFlags::NONE, + core::format_args!("second error"), + ); + ingress.publish( + Level::Error, + LogFlags::NONE, + core::format_args!("discarded error"), + ); + assert_eq!( - pending.additional_information().unwrap().as_str(), - "link failed" + ingress.drain_one(|record| assert_eq!(record.message().as_str(), "first error")), + Some(()) ); - 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") - } + assert_eq!( + ingress.drain_one(|record| assert_eq!(record.message().as_str(), "second error")), + Some(()) + ); + assert!(ingress.drain_one(|_| ()).is_none()); } #[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"); - assert!( - GLOBAL_INGRESS - .take() - .expect("macro event retained") - .message() - .unwrap() - .as_str() - .ends_with("armed configuration loaded") - ); - } + fn contended_drain_keeps_the_record_for_a_later_attempt() { + let ingress = LogIngress::<1>::new(); + ingress.publish(Level::Info, LogFlags::NONE, core::format_args!("pending")); - #[test] - fn forbidden_context_skips_argument_formatting() { - static INGRESS: FacadeIngress<2> = FacadeIngress::new(); - 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); - } + ingress.busy.store(true, Ordering::Relaxed); + let mut called = false; + assert!(ingress.drain_one(|_| called = true).is_none()); + assert!(!called); + ingress.busy.store(false, Ordering::Relaxed); - #[test] - fn bounded_ingress_exposes_local_drop_and_truncation_accounting() { - static INGRESS: FacadeIngress<1> = FacadeIngress::new(); - 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(), + assert_eq!( + ingress.drain_one(|record| assert_eq!(record.message().as_str(), "pending")), + Some(()) ); - 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(); - 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 error = INGRESS - .take() - .expect("error retained ahead of optional text"); - assert_eq!(error.channel(), RecordChannel::Error); - assert_eq!(error.error_path().unwrap().as_str(), "test"); - assert_eq!(error.additional_information().unwrap().as_str(), "critical"); - assert_eq!(INGRESS.health().full, 0); } } diff --git a/firmware/libs/kaptan-log/src/filter.rs b/firmware/libs/kaptan-log/src/filter.rs deleted file mode 100644 index fa4644a..0000000 --- a/firmware/libs/kaptan-log/src/filter.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Bounded log filtering policy. - -use crate::Severity; - -/// Immutable policy for one log sink. -/// -/// Filtering belongs to a sink rather than to event creation: MAVLink and a -/// future blackbox may therefore select different policies without either -/// sink suppressing the other's events. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LogFilter { - /// The sink receives no log events, including errors. - Disabled, - /// The sink receives this level and every more severe level. - UpTo(Severity), -} - -impl LogFilter { - /// Creates a disabled sink policy. - pub const fn disabled() -> Self { - Self::Disabled - } - - /// Creates a sink policy with one inclusive verbosity cap. - pub const fn up_to(maximum_severity: Severity) -> Self { - Self::UpTo(maximum_severity) - } - - /// The selected MAVLink-compatible severity cap, or `None` when disabled. - pub const fn maximum_severity(self) -> Option { - match self { - Self::Disabled => None, - Self::UpTo(maximum_severity) => Some(maximum_severity), - } - } - - /// Returns true if this sink accepts the event. - pub const fn allows(self, severity: Severity) -> bool { - match self { - Self::Disabled => false, - Self::UpTo(maximum_severity) => severity.wire_value() <= maximum_severity.wire_value(), - } - } -} - -#[cfg(test)] -mod tests { - use super::LogFilter; - use crate::Severity; - - #[test] - fn disabled_policy_rejects_every_severity() { - let filter = LogFilter::disabled(); - for severity in [ - Severity::Emergency, - Severity::Alert, - Severity::Critical, - Severity::Error, - Severity::Warning, - Severity::Notice, - Severity::Info, - Severity::Debug, - ] { - assert!(!filter.allows(severity)); - } - assert_eq!(filter.maximum_severity(), None); - } - - #[test] - fn level_policy_is_inclusive_and_rejects_more_verbose_events() { - let filter = LogFilter::up_to(Severity::Info); - assert!(filter.allows(Severity::Emergency)); - assert!(filter.allows(Severity::Error)); - assert!(filter.allows(Severity::Info)); - assert!(!filter.allows(Severity::Debug)); - assert_eq!(filter.maximum_severity(), Some(Severity::Info)); - } -} diff --git a/firmware/libs/kaptan-log/src/hub.rs b/firmware/libs/kaptan-log/src/hub.rs index 94616f3..aff21e2 100644 --- a/firmware/libs/kaptan-log/src/hub.rs +++ b/firmware/libs/kaptan-log/src/hub.rs @@ -1,18 +1,15 @@ -//! Bounded priority handling for fixed human-readable diagnostic records. +//! Bounded priority handling for log records. use core::fmt; -use crate::{ - DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags, - LogRecord, LogText, Severity, StaticError, StaticErrorMailbox, -}; +use crate::{DeliveryClass, Level, LevelFilter, LogFlags, LogRecord, LogText}; -/// Outcome from one bounded diagnostic admission attempt. +/// Outcome from one bounded log admission attempt. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PublishOutcome { /// The record was retained. Queued, - /// A record was excluded by this sink's configured policy. + /// The configured level filter excluded the record. Filtered, /// A best-effort record was replaced by newer optional information. ReplacedOldest, @@ -20,13 +17,12 @@ pub enum PublishOutcome { RejectedFull, } -/// Result returned by the sole telemetry owner after attempting to admit a -/// record to its lower transport. +/// Result returned after attempting lower-transport admission. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TransportAdmission { - /// The lower transport retained the record, so it may leave the queue. + /// The lower transport retained the record. Accepted, - /// The lower transport did not accept it yet; retain it for retry. + /// The record must remain queued for retry. Retry, } @@ -38,7 +34,7 @@ struct Lane { impl Lane { const fn new() -> Self { - assert!(N > 0, "diagnostic lanes must be nonempty"); + assert!(N > 0, "log lanes must be nonempty"); Self { entries: [None; N], head: 0, @@ -61,10 +57,6 @@ impl Lane { PublishOutcome::Queued } - const fn is_full(&self) -> bool { - self.len == N - } - fn peek(&self) -> Option { self.entries.get(self.head).copied().flatten() } @@ -80,18 +72,16 @@ impl Lane { } } -/// One bounded priority queue owned by a log sink. -/// -/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink. +/// One independently filtered bounded log-sink queue. pub struct LogSinkQueue { - filter: LogFilter, + filter: LevelFilter, critical: Lane, best_effort: Lane, } impl LogSinkQueue { - /// Creates one sink queue with its immutable boot-active policy. - pub const fn new(filter: LogFilter) -> Self { + /// Creates a sink queue with one immutable boot-active filter. + pub const fn new(filter: LevelFilter) -> Self { Self { filter, critical: Lane::new(), @@ -100,146 +90,50 @@ impl LogSinkQueue LogFilter { + pub const fn filter(&self) -> LevelFilter { self.filter } - /// Whether an error source may be consumed without losing an enabled - /// critical record. Filtered errors may be consumed intentionally. - pub const fn can_publish_error(&self) -> bool { - !self.filter.allows(Severity::Error) || !self.critical.is_full() - } - - /// Consumes at most one source-local static-error mailbox in round-robin - /// order. An enabled source remains untouched while the critical lane is - /// full; a filtered source is consumed intentionally. - pub fn drain_next_error_mailbox( - &mut self, - mailboxes: [&StaticErrorMailbox; SOURCES], - cursor: &mut u8, - ) -> bool { - assert!( - SOURCES != 0 && SOURCES <= u8::MAX as usize, - "error mailbox fan-in must fit its u8 cursor" - ); - if !self.can_publish_error() { - return false; - } - if usize::from(*cursor) >= SOURCES { - *cursor = 0; - } - - let mut checked = 0; - while checked < SOURCES { - let index = usize::from(*cursor); - *cursor = if index + 1 == SOURCES { 0 } else { *cursor + 1 }; - checked += 1; - - let (error, coalesced) = mailboxes[index].take(); - let Some(error) = error else { - continue; - }; - let flags = if coalesced { - LogFlags::COALESCED - } else { - LogFlags::NONE - }; - let outcome = self.publish_error_static(flags, error); - debug_assert!(matches!( - outcome, - PublishOutcome::Queued | PublishOutcome::Filtered - )); - return true; - } - false - } - - /// Formats and retains one ordinary task/worker diagnostic. Error-level - /// severities are emitted through `KAPTAN_ERROR`; lower severities use - /// `KAPTAN_LOG`. + /// Formats and retains one log event. pub fn publish_arguments( &mut self, - severity: Severity, + level: Level, flags: LogFlags, arguments: fmt::Arguments<'_>, ) -> PublishOutcome { - self.publish_text(severity, flags, LogText::from_arguments(arguments)) + self.publish_text(level, 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_record(LogRecord::from_error(flags, error)) + /// Retains one preformatted log event. + pub fn publish_text(&mut self, level: Level, flags: LogFlags, text: LogText) -> PublishOutcome { + self.publish_record(LogRecord::new(level, flags, text)) } - /// Retains one static mailbox error. This is the ISR-safe counterpart to - /// [`Self::publish_error`]. - pub fn publish_error_static( - &mut self, - flags: LogFlags, - error: &'static StaticError, - ) -> PublishOutcome { - self.publish_record(LogRecord::from_static_error(flags, error)) - } - - /// Retains a preformatted error identity and optional detail. Both - /// transport-neutral components already have the exact common diagnostic - /// envelope capacities, so a sink adapter need not retain or clip them - /// again. - pub fn publish_error_text( - &mut self, - flags: LogFlags, - path: ErrorPath, - additional_information: ErrorAdditionalInformation, - ) -> PublishOutcome { - self.publish_record(LogRecord::error(flags, path, additional_information)) - } - - /// Retains one preformatted bounded text payload. Error-or-higher - /// severities are independently encoded as `KAPTAN_ERROR`. - pub fn publish_text( - &mut self, - severity: Severity, - flags: LogFlags, - text: LogText, - ) -> PublishOutcome { - self.publish_record(LogRecord::new(severity, flags, text)) - } - - /// Applies this sink's policy and retains one already-constructed event. - /// - /// Composition may submit the same transport-neutral record to multiple - /// sink queues without coupling event creation to any sink policy. + /// Applies this sink's filter and retains one record. pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome { - if !self.filter.allows(record.severity()) { + if !self.filter.allows(record.level()) { return PublishOutcome::Filtered; } - let delivery = record.delivery(); - match delivery { + match record.delivery() { DeliveryClass::Critical => self.critical.publish(record, false), DeliveryClass::BestEffort => self.best_effort.publish(record, true), } } - /// Highest-priority record without relinquishing it. + /// Highest-priority pending record. pub fn peek(&self) -> Option { self.critical.peek().or_else(|| self.best_effort.peek()) } - /// Submits the current record through the sink owner and removes it only - /// after `Accepted`. This keeps encoder/admission retry semantics local - /// for every severity. + /// Removes the current record only after successful lower admission. 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::Retry => {} + if admission == TransportAdmission::Accepted { + let _ = self.pop(); } Some(result) } @@ -252,219 +146,24 @@ impl LogSinkQueue::new(LogFilter::disabled()); + fn filter_and_priority_apply_only_to_logs() { + let mut queue = LogSinkQueue::<1, 1>::new(LevelFilter::Info); assert_eq!( - disabled.publish_text( - Severity::Error, - LogFlags::NONE, - LogText::from_message("error") - ), - PublishOutcome::Filtered - ); - assert!(disabled.peek().is_none()); - - let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info)); - assert_eq!( - queue.publish_text( - Severity::Debug, - LogFlags::NONE, - LogText::from_message("debug") - ), + queue.publish_text(Level::Debug, LogFlags::NONE, LogText::from_message("debug")), PublishOutcome::Filtered ); assert_eq!( - queue.publish_text( - Severity::Error, - LogFlags::NONE, - LogText::from_message("error") - ), + queue.publish_text(Level::Error, LogFlags::NONE, LogText::from_message("error")), PublishOutcome::Queued ); - let record = queue.peek().unwrap(); - assert_eq!(record.error_path().unwrap().as_str(), "error"); - assert!(record.additional_information().unwrap().is_empty()); - assert_eq!(record.channel(), RecordChannel::Error); - } - - #[test] - fn full_critical_lane_backpressures_only_an_enabled_error_source() { - let mut enabled = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info)); - assert!(enabled.can_publish_error()); - assert_eq!( - enabled.publish_text( - Severity::Error, - LogFlags::NONE, - LogText::from_message("pending") - ), - PublishOutcome::Queued - ); - assert!(!enabled.can_publish_error()); - - let disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled()); - assert!(disabled.can_publish_error()); - } - - #[test] - fn mailbox_fan_in_survives_backpressure_and_is_round_robin() { - static FIRST: StaticError = StaticError::new("first"); - static SECOND: StaticError = StaticError::new("second"); - static THIRD: StaticError = StaticError::new("third"); - let first = crate::StaticErrorMailbox::new(); - let second = crate::StaticErrorMailbox::new(); - let third = crate::StaticErrorMailbox::new(); - let mailboxes = [&first, &second, &third]; - first.record(&FIRST); - second.record(&SECOND); - third.record(&THIRD); - - let mut queue = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Info)); - let mut cursor = 0; - assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor)); - assert_eq!( - queue.peek().unwrap().error_path().unwrap().as_str(), - "first" - ); - - first.record(&FIRST); - assert!(!queue.drain_next_error_mailbox(mailboxes, &mut cursor)); - assert_eq!( - queue.peek().unwrap().error_path().unwrap().as_str(), - "first" - ); - - let mut expect = |path| { - assert_eq!(queue.peek().unwrap().error_path().unwrap().as_str(), path); - assert_eq!( - queue.submit_next(|_| (TransportAdmission::Accepted, ())), - Some(()) - ); - assert!(queue.drain_next_error_mailbox(mailboxes, &mut cursor)); - }; - expect("first"); - expect("second"); - expect("third"); - assert_eq!( - queue.peek().unwrap().error_path().unwrap().as_str(), - "first" - ); - } - - #[test] - fn identical_events_remain_distinct_records() { - static FAULT: StaticError = StaticError::new("driver::Fault"); - let mut queue = LogSinkQueue::<2, 1>::new(LogFilter::up_to(Severity::Debug)); - assert_eq!( - queue.publish_error_static(LogFlags::NONE, &FAULT), - PublishOutcome::Queued - ); - assert_eq!( - queue.publish_error_static(LogFlags::NONE, &FAULT), - PublishOutcome::Queued - ); - let record = queue.peek().unwrap(); - assert!(!record.flags().contains(LogFlags::COALESCED)); - assert_eq!(record.error_path().unwrap().as_str(), "driver::Fault"); - assert!(record.additional_information().unwrap().is_empty()); + assert_eq!(queue.peek().unwrap().delivery(), DeliveryClass::Critical); assert_eq!( queue.submit_next(|_| (TransportAdmission::Accepted, ())), Some(()) ); - assert_eq!(queue.peek(), Some(record)); - } - - #[test] - fn static_error_detail_needs_no_producer_formatting() { - static FAULT: StaticError = - StaticError::with_additional_information("driver::Dma::Fault", "stream 7"); - let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); - assert_eq!( - hub.publish_error_static(LogFlags::NONE, &FAULT), - PublishOutcome::Queued - ); - let record = hub.peek().unwrap(); - assert_eq!(record.error_path().unwrap().as_str(), "driver::Dma::Fault"); - assert_eq!( - record.additional_information().unwrap().as_str(), - "stream 7" - ); - } - - #[test] - fn error_identity_and_detail_are_independent_record_components() { - let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); - let _ = hub.publish_error_text( - LogFlags::NONE, - ErrorPath::from_message("driver::Icm42688::Transfer"), - ErrorAdditionalInformation::from_message("SPI DMA timeout"), - ); - let record = hub.peek().unwrap(); - assert_eq!(record.channel(), RecordChannel::Error); - assert_eq!( - record.error_path().unwrap().as_str(), - "driver::Icm42688::Transfer" - ); - assert_eq!( - record.additional_information().unwrap().as_str(), - "SPI DMA timeout" - ); - } - - #[test] - fn retry_retains_every_severity() { - let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); - let _ = hub.publish_text( - Severity::Error, - LogFlags::NONE, - LogText::from_message("fault"), - ); - assert_eq!( - hub.submit_next(|_| (TransportAdmission::Retry, ())), - 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::Retry, ())), - Some(()) - ); - assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::BestEffort); - assert_eq!( - hub.submit_next(|_| (TransportAdmission::Accepted, ())), - Some(()) - ); - assert!(hub.peek().is_none()); - } - - #[test] - fn independent_sink_policies_do_not_suppress_each_other() { - let record = LogRecord::new( - Severity::Info, - LogFlags::NONE, - LogText::from_message("flight ready"), - ); - let mut mavlink = LogSinkQueue::<1, 1>::new(LogFilter::disabled()); - let mut blackbox = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug)); - - assert_eq!(mavlink.publish_record(record), PublishOutcome::Filtered); - assert_eq!(blackbox.publish_record(record), PublishOutcome::Queued); - assert!(mavlink.peek().is_none()); - assert_eq!(blackbox.peek(), Some(record)); + assert!(queue.peek().is_none()); } } diff --git a/firmware/libs/kaptan-log/src/level.rs b/firmware/libs/kaptan-log/src/level.rs index 1548012..b04fedf 100644 --- a/firmware/libs/kaptan-log/src/level.rs +++ b/firmware/libs/kaptan-log/src/level.rs @@ -1,36 +1,72 @@ -//! Standard log levels and their MAVLink severity projection. +//! Compact conventional log levels and filtering. -/// MAVLink-compatible severity. Numeric values deliberately match -/// `MAV_SEVERITY`. +/// Severity of one emitted log event. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[repr(u8)] +pub enum Level { + /// A runtime operation failed. + Error = 1, + /// Recoverable warning. + Warn = 2, + /// Informational operation. + Info = 3, + /// Diagnostic output. + Debug = 4, + /// Fine-grained diagnostic output. + Trace = 5, +} + +impl Level { + /// Conventional uppercase label. + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "ERROR", + Self::Warn => "WARN", + Self::Info => "INFO", + Self::Debug => "DEBUG", + Self::Trace => "TRACE", + } + } +} + +/// Conventional maximum log level, including the disabled state. #[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)] #[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, +pub enum LevelFilter { + /// Disable the sink. + Off = 0, + /// Accept error events only. + Error = 1, + /// Accept errors and warnings. + Warn = 2, + /// Accept through informational events. + Info = 3, + /// Accept through debug events. + Debug = 4, + /// Accept every event, including trace. + Trace = 5, +} + +impl LevelFilter { + /// Returns true when this filter accepts the event. + pub const fn allows(self, level: Level) -> bool { + level as u8 <= self as u8 + } } #[cfg(test)] mod tests { use core::mem::size_of; - use super::Severity; + use super::{Level, LevelFilter}; #[test] - fn severity_is_one_byte() { - assert_eq!(size_of::(), 1); + fn levels_are_one_byte_and_filter_conventionally() { + assert_eq!(size_of::(), 1); + assert_eq!(size_of::(), 1); + assert!(!LevelFilter::Off.allows(Level::Error)); + assert!(LevelFilter::Info.allows(Level::Error)); + assert!(LevelFilter::Info.allows(Level::Info)); + assert!(!LevelFilter::Info.allows(Level::Debug)); } } diff --git a/firmware/libs/kaptan-log/src/lib.rs b/firmware/libs/kaptan-log/src/lib.rs index 6c1bab4..98dc509 100644 --- a/firmware/libs/kaptan-log/src/lib.rs +++ b/firmware/libs/kaptan-log/src/lib.rs @@ -1,7 +1,8 @@ -//! Bounded, transport-neutral runtime diagnostics for Kaptan. +//! Bounded, transport-neutral runtime logging for Kaptan. //! -//! This crate deliberately contains no MAVLink, board, filesystem, clock, or -//! configuration dependency. Flight code reports bounded events to +//! This crate deliberately contains no MAVLink, board, filesystem, or clock +//! dependency. Its sole configuration coupling is compile-time enum metadata +//! for [`LevelFilter`]. Flight code reports bounded events to //! independently filtered sink queues; a platform-owned adapter may later //! encode one queue for a link. //! Reporting is observational and must never influence flight control or @@ -13,35 +14,77 @@ extern crate self as kaptan_log; #[cfg(test)] extern crate std; -pub mod crash; -#[cfg(feature = "facade")] pub mod facade; -mod filter; 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::{LogSinkQueue, PublishOutcome, TransportAdmission}; -pub use kaptan_log_derive::KaptanError; -pub use level::Severity; -pub use mailbox::StaticErrorMailbox; -pub use report::{DeliveryClass, ErrorReport, LogFlags, LogRecord, RecordChannel, StaticError}; -pub use shutdown::{ShutdownReason, ShutdownReport}; -pub use text::{ - ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, - MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES, -}; +pub use level::{Level, LevelFilter}; +pub use report::{DeliveryClass, LogFlags, LogRecord}; +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; +pub use facade::drain_one; +#[doc(hidden)] +pub use facade::publish as __publish; + +/// Emits a debug event through the bounded global ingress. +#[macro_export] +macro_rules! debug { + ($($argument:tt)+) => { + $crate::__publish( + $crate::Level::Debug, + $crate::LogFlags::NONE, + core::format_args!($($argument)+), + ) + }; +} + +/// Emits an informational event through the bounded global ingress. +#[macro_export] +macro_rules! info { + ($($argument:tt)+) => { + $crate::__publish( + $crate::Level::Info, + $crate::LogFlags::NONE, + core::format_args!($($argument)+), + ) + }; +} + +/// Emits a warning event through the bounded global ingress. +#[macro_export] +macro_rules! warn { + ($($argument:tt)+) => { + $crate::__publish( + $crate::Level::Warn, + $crate::LogFlags::NONE, + core::format_args!($($argument)+), + ) + }; +} + +/// Emits an error event through the bounded global ingress. +#[macro_export] +macro_rules! error { + ($($argument:tt)+) => { + $crate::__publish( + $crate::Level::Error, + $crate::LogFlags::NONE, + core::format_args!($($argument)+), + ) + }; +} + +/// Emits a trace event, represented as MAVLink Debug plus the trace flag. +#[macro_export] +macro_rules! trace { + ($($argument:tt)+) => { + $crate::__publish( + $crate::Level::Trace, + $crate::LogFlags::NONE, + core::format_args!($($argument)+), + ) + }; +} diff --git a/firmware/libs/kaptan-log/src/mailbox.rs b/firmware/libs/kaptan-log/src/mailbox.rs deleted file mode 100644 index 5008edf..0000000 --- a/firmware/libs/kaptan-log/src/mailbox.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Fixed atomic handoff for static derived error messages. - -use core::{ - ptr, - sync::atomic::{AtomicPtr, Ordering}, -}; - -use crate::StaticError; - -/// One source-local static-fault mailbox with fixed bounded atomic attempts. -/// -/// 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 { - /// Pending pointer with its low alignment bit used as the coalesced flag. - state: AtomicPtr, -} - -const COALESCED_TAG: usize = 1; -const _: () = assert!(core::mem::align_of::() > COALESCED_TAG); - -impl StaticErrorMailbox { - /// Creates an empty mailbox. - pub const fn new() -> Self { - Self { - state: AtomicPtr::new(ptr::null_mut()), - } - } - - /// Records one static message with at most two fixed atomic attempts: one - /// to retain it, then one to tag an already pending observation. - pub fn record(&self, error: &'static StaticError) { - let error = core::ptr::from_ref(error).cast_mut(); - if let Err(pending) = - self.state - .compare_exchange(ptr::null_mut(), error, Ordering::AcqRel, Ordering::Acquire) - { - self.mark_coalesced(pending); - } - } - - /// Marks only the exact pending pointer observed by `record`. If a worker - /// drained it concurrently, the single CAS fails without leaving a flag - /// that could attach to a later error. - fn mark_coalesced(&self, pending: *mut StaticError) { - if pending.addr() & COALESCED_TAG != 0 { - return; - } - let tagged = pending.map_addr(|address| address | COALESCED_TAG); - let _ = self - .state - .compare_exchange(pending, tagged, Ordering::AcqRel, Ordering::Acquire); - } - - /// Removes the retained first message and its coalesced marker. - pub fn take(&self) -> (Option<&'static StaticError>, bool) { - let state = self.state.swap(ptr::null_mut(), Ordering::AcqRel); - if state.is_null() { - return (None, false); - } - let coalesced = state.addr() & COALESCED_TAG != 0; - let error = state.map_addr(|address| address & !COALESCED_TAG); - // SAFETY: `record` stores only pointers to static messages and the - // atomic swap transfers the exact pointer to this one worker. The low - // tag bit is cleared before dereferencing the aligned pointer. - let error = unsafe { error.as_ref() }; - (error, coalesced) - } -} - -impl Default for StaticErrorMailbox { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use core::sync::atomic::Ordering; - - use super::StaticErrorMailbox; - use crate::StaticError; - - #[test] - fn source_mailbox_keeps_the_first_static_message_and_marks_later_faults() { - static FIRST: StaticError = StaticError::new("first"); - static SECOND: StaticError = StaticError::new("second"); - let mailbox = StaticErrorMailbox::new(); - mailbox.record(&FIRST); - mailbox.record(&SECOND); - assert_eq!(mailbox.take(), (Some(&FIRST), true)); - assert_eq!(mailbox.take(), (None, false)); - } - - #[test] - fn a_late_coalesced_mark_cannot_attach_to_the_next_error() { - static FIRST: StaticError = StaticError::new("first"); - static NEXT: StaticError = StaticError::new("next"); - let mailbox = StaticErrorMailbox::new(); - mailbox.record(&FIRST); - - // Reproduce the losing producer's observed pointer, then let the - // worker drain it and a different error arrive before that producer's - // bounded marking CAS. - let observed = mailbox.state.load(Ordering::Acquire); - assert_eq!(mailbox.take(), (Some(&FIRST), false)); - mailbox.record(&NEXT); - mailbox.mark_coalesced(observed); - - assert_eq!(mailbox.take(), (Some(&NEXT), false)); - assert_eq!(mailbox.take(), (None, false)); - } -} diff --git a/firmware/libs/kaptan-log/src/report.rs b/firmware/libs/kaptan-log/src/report.rs index af19f0c..0085a82 100644 --- a/firmware/libs/kaptan-log/src/report.rs +++ b/firmware/libs/kaptan-log/src/report.rs @@ -1,55 +1,8 @@ -//! Bounded human-readable diagnostic records. +//! Bounded human-readable log records. -use crate::{ErrorAdditionalInformation, ErrorPath, LogText, Severity}; +use crate::{Level, LogText}; -/// Static identity and optional detail for one error. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct StaticError { - path: &'static str, - additional_information: &'static str, -} - -impl StaticError { - /// Creates one compile-time error identity without additional detail. - pub const fn new(path: &'static str) -> Self { - Self { - path, - additional_information: "", - } - } - - /// Creates one compile-time error identity with static additional detail. - pub const fn with_additional_information( - path: &'static str, - additional_information: &'static str, - ) -> Self { - Self { - path, - additional_information, - } - } - - /// Stable human-readable error identity. - pub const fn path(self) -> &'static str { - self.path - } - - /// Optional static human-facing detail. - pub const fn additional_information(self) -> &'static str { - self.additional_information - } -} - -/// Implemented by [`crate::KaptanError`] derives. -/// -/// This trait is deliberately pure: obtaining error identity never allocates, -/// publishes telemetry, changes safety state, or serializes generic sources. -pub trait ErrorReport { - /// Returns static identity and optional detail for this error value. - fn static_error(&self) -> &'static StaticError; -} - -/// Bounded MAVLink log flags. +/// Flags belonging exclusively to a log record. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(transparent)] pub struct LogFlags(u8); @@ -57,208 +10,70 @@ 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. + /// Message text exceeded its fixed capacity. pub const TRUNCATED: Self = Self(1 << 1); - /// Multiple ISR producer observations were folded into one mailbox event. - pub const COALESCED: Self = Self(1 << 2); - /// Raw MAVLink bitmask. + /// Raw transport bitmask. pub const fn bits(self) -> u8 { self.0 } - /// Bits valid for an ordinary `KAPTAN_LOG` record. - pub const fn log_bits(self) -> u8 { - self.0 & (Self::TRACE.0 | Self::TRUNCATED.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, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RecordBody { - Log { - message: LogText, - }, - Error { - path: ErrorPath, - additional_information: ErrorAdditionalInformation, - }, -} - -/// Priority selected internally from a diagnostic level. +/// Internal transport priority selected from a conventional log level. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DeliveryClass { - /// Error logs retained independently from optional log verbosity. + /// Error-level logs. Critical, - /// Warning/info/debug/trace logs subject to the configured filter. + /// Warning, info, debug, and trace logs. BestEffort, } -/// A fully formatted bounded diagnostic waiting for MAVLink admission. +/// One bounded transport-neutral log event. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct LogRecord { - severity: Severity, + level: Level, flags: LogFlags, - body: RecordBody, + message: LogText, } impl LogRecord { - /// Creates one bounded diagnostic record. Error-or-higher severities use - /// their supplied text as the error path with no additional information; - /// ordinary severity records use `KAPTAN_LOG` message text. - pub fn new(severity: Severity, flags: LogFlags, message: LogText) -> Self { - if severity <= Severity::Error { - let flags = with_truncation(flags, message); - return Self::error_with_severity( - severity, - flags, - ErrorPath::from_message(message.as_str()), - ErrorAdditionalInformation::new(), - ); + /// Creates one bounded log record at the supplied level. + pub fn new(level: Level, mut flags: LogFlags, message: LogText) -> Self { + if message.is_truncated() { + flags = flags.with(LogFlags::TRUNCATED); } Self { - severity, - flags: with_truncation(flags, message), - body: RecordBody::Log { message }, - } - } - - /// Creates an error-channel record with independently bounded identity - /// and optional additional information. - pub fn error( - flags: LogFlags, - path: ErrorPath, - additional_information: ErrorAdditionalInformation, - ) -> Self { - Self::error_with_severity(Severity::Error, flags, path, additional_information) - } - - /// Creates one record from a typed derived error without choosing a sink. - pub fn from_error(flags: LogFlags, error: &E) -> Self { - Self::from_static_error(flags, error.static_error()) - } - - /// Creates one record from an ISR-safe static error without choosing a sink. - pub fn from_static_error(flags: LogFlags, error: &'static StaticError) -> Self { - Self::error( + level, flags, - ErrorPath::from_message(error.path()), - ErrorAdditionalInformation::from_message(error.additional_information()), - ) - } - - fn error_with_severity( - severity: Severity, - flags: LogFlags, - path: ErrorPath, - additional_information: ErrorAdditionalInformation, - ) -> Self { - debug_assert!(severity <= Severity::Error); - Self { - severity, - flags: flags_for_error(flags, path, additional_information), - body: RecordBody::Error { - path, - additional_information, - }, + message, } } - /// Wire-compatible severity. - pub const fn severity(&self) -> Severity { - self.severity + /// Conventional log level. + pub const fn level(&self) -> Level { + self.level } - /// Wire flag bitmask. + /// Log-specific flag bitmask. pub const fn flags(&self) -> LogFlags { self.flags } - /// Fixed text carried by an ordinary `KAPTAN_LOG` record. - pub const fn message(&self) -> Option<&LogText> { - match &self.body { - RecordBody::Log { message } => Some(message), - RecordBody::Error { .. } => None, - } + /// Fixed human-readable message. + pub const fn message(&self) -> &LogText { + &self.message } - /// Fixed identity path carried by a `KAPTAN_ERROR` record. - pub const fn error_path(&self) -> Option<&ErrorPath> { - match &self.body { - RecordBody::Log { .. } => None, - RecordBody::Error { path, .. } => Some(path), - } - } - - /// Fixed optional information carried by a `KAPTAN_ERROR` record. - pub const fn additional_information(&self) -> Option<&ErrorAdditionalInformation> { - match &self.body { - RecordBody::Log { .. } => None, - RecordBody::Error { - additional_information, - .. - } => Some(additional_information), - } - } - - /// Custom MAVLink diagnostic family selected for this record. - pub const fn channel(&self) -> RecordChannel { - match self.body { - RecordBody::Log { .. } => RecordChannel::Log, - RecordBody::Error { .. } => RecordChannel::Error, - } - } - - /// Internal admission priority. + /// Internal transport priority. pub const fn delivery(&self) -> DeliveryClass { - match self.body { - RecordBody::Log { .. } => DeliveryClass::BestEffort, - RecordBody::Error { .. } => DeliveryClass::Critical, + match self.level { + Level::Error => DeliveryClass::Critical, + Level::Warn | Level::Info | Level::Debug | Level::Trace => DeliveryClass::BestEffort, } } } - -fn with_truncation(mut flags: LogFlags, value: LogText) -> LogFlags { - if value.is_truncated() { - flags = flags.with(LogFlags::TRUNCATED); - } - flags -} - -fn flags_for_error( - mut flags: LogFlags, - path: ErrorPath, - additional_information: ErrorAdditionalInformation, -) -> LogFlags { - if path.is_truncated() || additional_information.is_truncated() { - flags = flags.with(LogFlags::TRUNCATED); - } - flags -} diff --git a/firmware/libs/kaptan-log/src/shutdown.rs b/firmware/libs/kaptan-log/src/shutdown.rs deleted file mode 100644 index 7e04643..0000000 --- a/firmware/libs/kaptan-log/src/shutdown.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! 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 { - /// A user initiated the controlled stop. - User = 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/firmware/libs/kaptan-log/src/text.rs b/firmware/libs/kaptan-log/src/text.rs index d26ab16..33f2a9f 100644 --- a/firmware/libs/kaptan-log/src/text.rs +++ b/firmware/libs/kaptan-log/src/text.rs @@ -1,50 +1,40 @@ -//! Fixed-capacity UTF-8 diagnostic text. +//! Fixed-capacity UTF-8 log text. use core::fmt::{self, Write}; -/// Maximum bytes retained in one ordinary log message. +/// Maximum bytes retained in one log message. pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160; -/// Maximum bytes retained in one error identity path. -pub const MAXIMUM_ERROR_PATH_BYTES: usize = 96; -/// Maximum bytes retained in one error additional-information value. -pub const MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES: usize = 64; -/// Fixed UTF-8 diagnostic text with an explicit truncation marker. +/// Transport-neutral bounded log message. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct BoundedText { - bytes: [u8; N], +pub struct LogText { + bytes: [u8; MAXIMUM_LOG_TEXT_BYTES], length: u8, - truncated: bool, + // A byte keeps the all-zero value available to `Option` via + // `Level`'s enum niche. A `bool` has its own larger niche, for which Rust + // selected the nonzero value `2` for `None`; that moved the complete + // static ingress buffer from zero-initialized BSS into `.data`. + truncated: u8, } -/// Transport-neutral ordinary log message. -pub type LogText = BoundedText; -/// Transport-neutral error identity path. -pub type ErrorPath = BoundedText; -/// Transport-neutral optional error detail. -pub type ErrorAdditionalInformation = BoundedText; - -impl BoundedText { +impl LogText { /// Creates empty fixed text storage. pub const fn new() -> Self { - assert!(N > 0, "diagnostic text storage must be nonempty"); - assert!(N <= u8::MAX as usize, "diagnostic text length must fit u8"); Self { - bytes: [0; N], + bytes: [0; MAXIMUM_LOG_TEXT_BYTES], length: 0, - truncated: false, + truncated: 0, } } - /// Formats one message into fixed storage without allocation. + /// Formats one message 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. + /// Copies UTF-8 text without splitting a code point at the fixed boundary. pub fn from_message(message: &str) -> Self { let mut text = Self::new(); let _ = text.write_str(message); @@ -52,116 +42,72 @@ impl BoundedText { } /// Raw bounded wire bytes. - pub const fn bytes(&self) -> &[u8; N] { + pub const fn bytes(&self) -> &[u8; MAXIMUM_LOG_TEXT_BYTES] { &self.bytes } - /// Maximum number of UTF-8 bytes retained by this text type. - pub const fn capacity(&self) -> usize { - N - } - /// Number of meaningful UTF-8 bytes. pub const fn len(&self) -> u8 { self.length } - /// Whether this record contains no message bytes. + /// Whether this message contains no text. pub const fn is_empty(&self) -> bool { self.length == 0 } - /// Whether the original message exceeded bounded storage. + /// Whether the input exceeded fixed storage. pub const fn is_truncated(&self) -> bool { - self.truncated + self.truncated != 0 } /// 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`. + // `write_str` copies only complete code points from an input `&str`. + // SAFETY: every populated byte belongs to that valid UTF-8 prefix. unsafe { core::str::from_utf8_unchecked(&self.bytes[..usize::from(self.length)]) } } } -impl Default for BoundedText { +impl Default for LogText { fn default() -> Self { Self::new() } } -impl fmt::Write for BoundedText { +impl fmt::Write for LogText { fn write_str(&mut self, message: &str) -> fmt::Result { + let remaining = MAXIMUM_LOG_TEXT_BYTES - usize::from(self.length); + let prefix = if message.len() <= remaining { + message.len() + } else { + self.truncated = 1; + message + .char_indices() + .take_while(|(index, character)| index + character.len_utf8() <= remaining) + .map(|(index, character)| index + character.len_utf8()) + .last() + .unwrap_or(0) + }; let used = usize::from(self.length); - let remaining = N - 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::{ - ErrorAdditionalInformation, ErrorPath, LogText, MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES, - MAXIMUM_ERROR_PATH_BYTES, MAXIMUM_LOG_TEXT_BYTES, - }; + use super::{LogText, MAXIMUM_LOG_TEXT_BYTES}; #[test] fn truncates_only_at_utf8_boundaries() { + let short = LogText::from_message("ready"); + assert!(!short.is_truncated()); + 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()); - } - - #[test] - fn each_semantic_component_owns_only_its_exact_capacity() { - assert_eq!(LogText::new().capacity(), MAXIMUM_LOG_TEXT_BYTES); - assert_eq!(ErrorPath::new().capacity(), MAXIMUM_ERROR_PATH_BYTES); - assert_eq!( - ErrorAdditionalInformation::new().capacity(), - MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - ); - - assert_eq!(core::mem::size_of::(), MAXIMUM_LOG_TEXT_BYTES + 2); - assert_eq!( - core::mem::size_of::(), - MAXIMUM_ERROR_PATH_BYTES + 2 - ); - assert_eq!( - core::mem::size_of::(), - MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES + 2 - ); - } - - #[test] - fn error_components_truncate_at_their_own_utf8_boundaries() { - let path = ErrorPath::from_message(&("p".repeat(MAXIMUM_ERROR_PATH_BYTES - 1) + "é")); - assert!(path.is_truncated()); - assert_eq!(path.as_str().len(), MAXIMUM_ERROR_PATH_BYTES - 1); - - let information = ErrorAdditionalInformation::from_message( - &("i".repeat(MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1) + "é"), - ); - assert!(information.is_truncated()); - assert_eq!( - information.as_str().len(), - MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES - 1 - ); } } diff --git a/firmware/libs/kaptan-math/Cargo.toml b/firmware/libs/kaptan-math/Cargo.toml index 63ee810..b9f2d29 100644 --- a/firmware/libs/kaptan-math/Cargo.toml +++ b/firmware/libs/kaptan-math/Cargo.toml @@ -9,4 +9,4 @@ version.workspace = true workspace = true [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } diff --git a/firmware/libs/kaptan-math/src/lib.rs b/firmware/libs/kaptan-math/src/lib.rs index 5e71ed8..eaff6ad 100644 --- a/firmware/libs/kaptan-math/src/lib.rs +++ b/firmware/libs/kaptan-math/src/lib.rs @@ -7,7 +7,7 @@ #![no_std] use core::marker::PhantomData; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; /// Failure reported by a checked mathematical operation. /// @@ -17,12 +17,16 @@ use kaptan_log::KaptanError; #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum MathError { /// An operand or intermediate value was not finite. + #[kaptan_error(id = 6001)] NonFinite, /// A vector or quaternion did not have a representable non-zero norm. + #[kaptan_error(id = 6002)] ZeroNorm, /// A small-angle quaternion was requested outside its reviewed domain. + #[kaptan_error(id = 6003)] RotationIncrementOutOfRange, /// Rotation-matrix axes were not a finite, right-handed orthonormal basis. + #[kaptan_error(id = 6004)] NonOrthonormalAxes, } diff --git a/firmware/libs/kaptan-mavlink/Cargo.toml b/firmware/libs/kaptan-mavlink/Cargo.toml index 71aefd2..a65114b 100644 --- a/firmware/libs/kaptan-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-mavlink/Cargo.toml @@ -16,7 +16,7 @@ sensor-telemetry = ["dep:kaptan-sensor"] dialect-kaptan = [] [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-dma = { path = "../kaptan-dma" } kaptan-sensor = { path = "../kaptan-sensor", optional = true } kaptan-tx = { path = "../kaptan-tx" } diff --git a/firmware/libs/kaptan-mavlink/build.rs b/firmware/libs/kaptan-mavlink/build.rs index 8031f84..4037209 100644 --- a/firmware/libs/kaptan-mavlink/build.rs +++ b/firmware/libs/kaptan-mavlink/build.rs @@ -6,8 +6,7 @@ fn main() { let config = manifest_directory.join("definitions/kaptan/config.xml"); let log = manifest_directory.join("definitions/kaptan/log.xml"); let error = manifest_directory.join("definitions/kaptan/error.xml"); - let shutdown = manifest_directory.join("definitions/kaptan/shutdown.xml"); - for definition in [&dialect, &config, &log, &error, &shutdown] { + for definition in [&dialect, &config, &log, &error] { println!("cargo::rerun-if-changed={}", definition.display()); } println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)"); diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan.xml index 54b3f24..1df5e7c 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan.xml @@ -5,5 +5,4 @@ kaptan/config.xml kaptan/log.xml kaptan/error.xml - kaptan/shutdown.xml diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml index 8a8ae59..6dd8976 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml @@ -3,25 +3,11 @@ 3 0 - - - - The path or additional-information UTF-8 prefix was truncated to its bounded on-wire field. - - - One or more later observations from the same source mailbox were folded while this first error was pending. - - - - 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. - MAV_SEVERITY-compatible error level from Emergency=0 through Error=3. - Known bits are TRUNCATED=2 and COALESCED=4. Receivers ignore unknown future bits. - Number of valid UTF-8 bytes in path; no more than 96. - Number of valid UTF-8 bytes in additional_information; no more than 64. - Bounded UTF-8 derived or explicit error path. Bytes after path_length are zero. - Bounded UTF-8 optional error context. Bytes after additional_information_length are zero. + Typed firmware error reported when an Err leaves an annotated propagation boundary. This is distinct from every logging macro, which emits KAPTAN_LOG. The stable ID is explicitly assigned to the Rust error variant. Structured configuration failures remain authoritative in KAPTAN_CONFIG_RESULT. + Stable explicitly assigned error catalog ID. Zero is reserved. + Zero-padded compile-time file:line of the propagation boundary that reported the error. diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml index 1b84394..38ec44b 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml @@ -6,7 +6,7 @@ - Record originated at Rust trace level and uses MAVLink DEBUG severity. + Record originated at Kaptan trace level and uses MAVLink DEBUG severity. Message text was truncated to the bounded on-wire buffer. @@ -15,8 +15,8 @@ - Bounded human-readable runtime log. The receiver needs only level, flags, and the valid UTF-8 text prefix. MAVLink framing, bounded admission, DMA, and retries remain platform-owned. - MAV_SEVERITY-compatible level. Rust TRACE uses DEBUG plus the TRACE flag. + Bounded human-readable runtime log emitted by every Kaptan logging macro, including error. The receiver needs only level, flags, and the valid UTF-8 text prefix. MAVLink framing, bounded admission, DMA, and retries remain platform-owned. + Kaptan level encoded with MAVLink values: ERROR=3, WARNING=4, INFO=6, DEBUG=7; TRACE uses DEBUG=7 plus the TRACE flag. Other values are invalid. Known bits are TRACE=1 and TRUNCATED=2. 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. diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml deleted file mode 100644 index a625418..0000000 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/shutdown.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - 3 - 0 - - - - - A user initiated the completed controlled firmware shutdown. - - - The safety owner initiated the completed controlled firmware shutdown. - - - - - - 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/firmware/libs/kaptan-mavlink/src/transmitter.rs b/firmware/libs/kaptan-mavlink/src/transmitter.rs index 03cb62e..1b46f20 100644 --- a/firmware/libs/kaptan-mavlink/src/transmitter.rs +++ b/firmware/libs/kaptan-mavlink/src/transmitter.rs @@ -1,7 +1,7 @@ //! MAVLink frame encoding and UART transmission API. use kaptan_dma::{DmaTxBufferFactory, DmaTxEngine, TxCompletion}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_tx::{PendingFrame, TransmitQueue}; use crate::MavlinkFrame; @@ -13,8 +13,10 @@ pub use kaptan_tx::{EnqueueResult, FrameClass}; #[derive(Debug, KaptanError)] pub enum MavlinkTransmitFault { /// DMA-visible frame preparation failed. + #[kaptan_error(id = 10001)] Memory(MemoryError), /// The UART DMA route rejected a transfer start. + #[kaptan_error(id = 10002)] Engine(EngineError), } diff --git a/firmware/libs/kaptan-navigation/Cargo.toml b/firmware/libs/kaptan-navigation/Cargo.toml index 340d828..4b1ef8d 100644 --- a/firmware/libs/kaptan-navigation/Cargo.toml +++ b/firmware/libs/kaptan-navigation/Cargo.toml @@ -14,7 +14,7 @@ default = [] # executable. It is not part of a board build. sitl = ["dep:kaptan-sensor"] [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-math = { path = "../kaptan-math" } kaptan-sensor = { path = "../kaptan-sensor", optional = true } kaptan-time = { path = "../kaptan-time" } diff --git a/firmware/libs/kaptan-navigation/src/eskf.rs b/firmware/libs/kaptan-navigation/src/eskf.rs index b097b09..ef8f903 100644 --- a/firmware/libs/kaptan-navigation/src/eskf.rs +++ b/firmware/libs/kaptan-navigation/src/eskf.rs @@ -1,6 +1,6 @@ //! Fixed-size multiplicative error-state Kalman filter. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_math::{Frd, MathError, Ned, Sensor, UnitQuaternion, Vector3, sqrt}; use kaptan_time::Timestamp; @@ -209,22 +209,31 @@ pub struct EstimatorHealth { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum PropagationError { /// An IMU observation was invalid or was not calibrated into FRD. + #[kaptan_error(id = 8001)] InvalidImu, /// IMU source differs from the stream bound during stationary initialization. + #[kaptan_error(id = 8002)] ImuSourceMismatch, /// IMU calibration differs from the stream bound during stationary initialization. + #[kaptan_error(id = 8003)] ImuCalibrationMismatch, /// IMU sequence repeated or moved backwards within the bound stream. + #[kaptan_error(id = 8004)] ReplayedImu, /// IMU measurement time did not advance strictly. + #[kaptan_error(id = 8005)] NonMonotonicTimestamp, /// The interval exceeded the bounded propagation contract. + #[kaptan_error(id = 8006)] ImuGapTooLarge, /// The IMU rate and interval exceed the bounded quaternion increment. + #[kaptan_error(id = 8007)] RotationIncrementTooLarge, /// Input or resulting state was non-finite. + #[kaptan_error(id = 8008)] NonFinite, /// The candidate state or covariance failed validation; reinitialize before reuse. + #[kaptan_error(id = 8009)] FilterInvalid, } @@ -232,33 +241,47 @@ pub enum PropagationError { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum FusionError { /// Observation is not at this filter's current history time. + #[kaptan_error(id = 8101)] TimestampMismatch, /// Observation was device-invalid. + #[kaptan_error(id = 8102)] InvalidObservation, /// Observation source does not identify the configured physical device. + #[kaptan_error(id = 8103)] SourceMismatch, /// Observation frame does not match the configured extrinsics. + #[kaptan_error(id = 8104)] FrameMismatch, /// Observation calibration revision does not match the configured transform. + #[kaptan_error(id = 8105)] CalibrationMismatch, /// The fixed source-integrity table has no entry for this source. + #[kaptan_error(id = 8106)] SourceCapacityExhausted, /// A source repeated or moved backwards from its accepted sequence stream. + #[kaptan_error(id = 8107)] ReplayedObservation, /// This estimator profile has no terrain state. + #[kaptan_error(id = 8108)] TerrainProfileRequired, /// Optical flow from an offset sensor needs a correlated IMU-noise update /// that this profile does not implement. + #[kaptan_error(id = 8109)] UnsupportedOpticalFlowLeverArm, /// Measurement fails physical range or velocity plausibility bounds. + #[kaptan_error(id = 8110)] Implausible, /// Range ray is too tilted or does not intersect terrain below the sensor. + #[kaptan_error(id = 8111)] InvalidRangeGeometry, /// Innovation covariance was singular/non-finite. + #[kaptan_error(id = 8112)] SingularInnovation, /// Innovation exceeds the configured chi-square gate. + #[kaptan_error(id = 8113)] InnovationRejected, /// The candidate state or covariance failed validation; reinitialize before reuse. + #[kaptan_error(id = 8114)] FilterInvalid, } diff --git a/firmware/libs/kaptan-safety/Cargo.toml b/firmware/libs/kaptan-safety/Cargo.toml index 1213196..f7ad19b 100644 --- a/firmware/libs/kaptan-safety/Cargo.toml +++ b/firmware/libs/kaptan-safety/Cargo.toml @@ -9,5 +9,5 @@ version.workspace = true workspace = true [dependencies] -kaptan-log = { path = "../kaptan-log" } +kaptan-err-propagate = { path = "../kaptan-err-propagate" } kaptan-time = { path = "../kaptan-time" } diff --git a/firmware/libs/kaptan-safety/src/lib.rs b/firmware/libs/kaptan-safety/src/lib.rs index 37b7867..721dbbe 100644 --- a/firmware/libs/kaptan-safety/src/lib.rs +++ b/firmware/libs/kaptan-safety/src/lib.rs @@ -7,7 +7,7 @@ #![no_std] -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_time::Timestamp; /// Supervisor arming state. @@ -27,18 +27,25 @@ pub enum SafetyState { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum SafetyFailure { /// Estimator is absent, stale, or non-finite. + #[kaptan_error(id = 9001)] EstimatorUnhealthy, /// Required inertial sensor stream is stale. + #[kaptan_error(id = 9002)] ImuStale, /// Required command/link stream is stale. + #[kaptan_error(id = 9003)] LinkStale, /// Actuator transport reports a fault. + #[kaptan_error(id = 9004)] ActuatorFault, /// No current explicit arm transaction exists. + #[kaptan_error(id = 9005)] ArmNotRequested, /// The platform supplied time that moved backwards. + #[kaptan_error(id = 9006)] ClockRegression, /// A non-wrapping actuator-permit epoch could not be allocated. + #[kaptan_error(id = 9007)] PermitEpochExhausted, } @@ -46,6 +53,7 @@ pub enum SafetyFailure { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum SafetyConfigError { /// A zero dwell would permit two evaluations at one timestamp to arm. + #[kaptan_error(id = 9101)] ZeroPrearmDwell, } diff --git a/firmware/platforms/stm32f7/Cargo.toml b/firmware/platforms/stm32f7/Cargo.toml index 6b5c996..6d7d92e 100644 --- a/firmware/platforms/stm32f7/Cargo.toml +++ b/firmware/platforms/stm32f7/Cargo.toml @@ -15,6 +15,7 @@ workspace = true test = false [dependencies] +kaptan-err-propagate = { path = "../../libs/kaptan-err-propagate" } kaptan-config = { path = "../../libs/kaptan-config" } kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" } kaptan-data-channel = { path = "../../libs/kaptan-data-channel" } @@ -22,7 +23,7 @@ kaptan-dma = { path = "../../libs/kaptan-dma" } kaptan-drivers = { path = "../../libs/kaptan-drivers" } kaptan-i2c = { path = "../../libs/kaptan-i2c" } kaptan-log = { path = "../../libs/kaptan-log" } -kaptan-log-mavlink = { path = "../../libs/kaptan-log-mavlink" } +kaptan-diagnostics-mavlink = { path = "../../libs/kaptan-diagnostics-mavlink" } kaptan-mavlink = { path = "../../libs/kaptan-mavlink" } kaptan-sensor = { path = "../../libs/kaptan-sensor" } kaptan-spi = { path = "../../libs/kaptan-spi" } diff --git a/firmware/platforms/stm32f7/src/f722/board.rs b/firmware/platforms/stm32f7/src/f722/board.rs index 21038bf..4cc82b0 100644 --- a/firmware/platforms/stm32f7/src/f722/board.rs +++ b/firmware/platforms/stm32f7/src/f722/board.rs @@ -3,7 +3,7 @@ use core::sync::atomic::{AtomicU8, Ordering}; use super::peripherals::{ - DwtMicrosecondClock, I2c1, I2c1Config, ReceiveInterrupt, Tim2Periodic, Uart4, + DwtMicrosecondClock, I2c1, I2c1Config, I2c1Error, ReceiveInterrupt, Tim2Periodic, Uart4, configure_exti3_pin_pc3, configure_exti4_pin_pa4, configure_i2c1_pins_pb8_pb9, configure_spi1_pins_pa5_pa6_pa7, configure_spi3_pins_pc10_pc11_pc12, configure_uart4_pins_pa0_pa1, configure_usart1_rx_pin_pa10, configure_usart1_tx_pin_pa9, @@ -14,15 +14,16 @@ use kaptan_data_channel::{ DataChannel, DataChannelRates, DataSources, LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox, }; +use kaptan_diagnostics_mavlink::{encode_error, encode_log}; use kaptan_drivers::{ - lidar_lite_v3::{AcquisitionMode, LidarLiteV3, LidarLiteV3Service, LidarLiteV3ServiceEvent}, + lidar_lite_v3::{ + AcquisitionMode, Error as LidarError, LidarLiteV3, LidarLiteV3Service, + LidarLiteV3ServiceEvent, StartupError, + }, mtf01::Mtf01Service, }; -use kaptan_log::{ - DeliveryClass, ErrorReport, KaptanError, LogFilter, LogSinkQueue, StaticError, - StaticErrorMailbox, TransportAdmission, -}; -use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; +use kaptan_err_propagate::{ErrorId, ErrorSink}; +use kaptan_log::{DeliveryClass, LogSinkQueue, TransportAdmission}; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; @@ -70,16 +71,6 @@ pub(crate) const fn scheduled_link_budget_fits(baud: u32) -> bool { SCHEDULED_TELEMETRY_BYTES_PER_SECOND < baud as usize / 10 } static UART_COMPLETION: AtomicU8 = AtomicU8::new(0); -#[derive(Clone, Copy, Debug, KaptanError)] -enum F722TelemetryError { - Mtf01Uart, -} -// High-priority/ISR-adjacent producers never encode, lock, allocate, or send -// MAVLink. They retain the first fault text and coalesce later reports for -// the low-priority MAVLink worker to publish. -static IMU_FAULTS: StaticErrorMailbox = StaticErrorMailbox::new(); -static LIDAR_FAULTS: StaticErrorMailbox = StaticErrorMailbox::new(); -static MTF01_FAULTS: StaticErrorMailbox = StaticErrorMailbox::new(); static SAMPLES: LatestImuMailbox = LatestImuMailbox::new(); static RANGES: LatestRangeMailbox = LatestRangeMailbox::new(); static OPTICAL_FLOW: LatestOpticalFlowMailbox = LatestOpticalFlowMailbox::new(); @@ -106,8 +97,8 @@ where config_protocol: ConfigProtocol, receiver: IntegrityMavlinkReceiver<2>, config_lease_ticks: u16, + mavlink_errors: ErrorSink, mavlink_logs: LogSinkQueue<1, 1>, - fault_mailbox_cursor: u8, led: F722StatusLed, } @@ -367,11 +358,7 @@ where optical_flow_hz: runtime.optical_flow_hz.map(usize::from), heartbeat_hz: usize::from(runtime.heartbeat_hz), }; - let mavlink_log_filter = if runtime.mavlink_logging_enabled { - LogFilter::up_to(runtime.mavlink_maximum_log_severity) - } else { - LogFilter::disabled() - }; + let mavlink_log_filter = runtime.mavlink_log_level_filter; let data_channel = DataChannel::new( transmitter, system_id, @@ -400,8 +387,8 @@ where config_protocol: ConfigProtocol::new(Target::new(system_id, component_id)), receiver: IntegrityMavlinkReceiver::new(), config_lease_ticks: 0, + mavlink_errors: ErrorSink::new(), mavlink_logs: LogSinkQueue::new(mavlink_log_filter), - fault_mailbox_cursor: 0, led, }, }) @@ -517,7 +504,7 @@ pub fn on_uart4_interrupt(mtf01: &mut Option) { } ReceiveInterrupt::Fault => { mtf01.reset(); - record_mtf01_fault(F722TelemetryError::Mtf01Uart.static_error()); + kaptan_log::error!("mtf01 uart receive fault"); } ReceiveInterrupt::NotPending => {} } @@ -535,22 +522,23 @@ impl F722LidarState { i2c.acknowledge_event_interrupt() } }); - publish_lidar_event(event); + let _ = publish_lidar_event(event); } - pub fn on_timer(&mut self) { + #[kaptan_err_propagate::propagate] + pub fn on_timer(&mut self) -> Result<(), StartupError> { if let Some(range) = self.range.as_mut() { if self.startup_ticks < LIDAR_STARTUP_TICKS { self.startup_ticks += 1; - if self.startup_ticks == LIDAR_STARTUP_TICKS - && let Err(error) = range.finish_startup() - { - record_lidar_fault(error.static_error()); + if self.startup_ticks == LIDAR_STARTUP_TICKS { + range.finish_startup()?; + kaptan_log::info!("lidar-lite v3 initialized"); } } else { - publish_lidar_event(range.on_periodic_tick()); + let _ = publish_lidar_event(range.on_periodic_tick()); } } + Ok(()) } } @@ -561,32 +549,38 @@ pub fn process_imu(imu: &mut F722Imu, events: u8) { const TRANSFER_COMPLETE: u8 = 1 << 1; const TIMER: u8 = 1 << 3; const DRDY: u8 = 1 << 0; + let transfer_terminal = events & (TRANSFER_FAULT | TRANSFER_COMPLETE) != 0; if events & START != 0 { - accept_imu_event(imu.begin()); + let _ = accept_imu_event(imu.begin()); } if events & TRANSFER_FAULT != 0 { - accept_imu_event(imu.on_transfer_complete(SpiCompletion::TransferFault)); + let _ = accept_imu_event(imu.on_transfer_complete(SpiCompletion::TransferFault)); } if events & TRANSFER_COMPLETE != 0 { - accept_imu_event(imu.on_transfer_complete(SpiCompletion::Complete)); + let _ = accept_imu_event(imu.on_transfer_complete(SpiCompletion::Complete)); } - if events & TIMER != 0 { - accept_imu_event(imu.on_periodic_tick()); + if events & TIMER != 0 && !transfer_terminal { + let _ = accept_imu_event(imu.on_periodic_tick()); } - if events & DRDY != 0 { - accept_imu_event(imu.on_data_ready()); + if events & DRDY != 0 && !transfer_terminal { + let _ = accept_imu_event(imu.on_data_ready()); } } -fn accept_imu_event(event: ImuEvent) { +#[kaptan_err_propagate::propagate] +fn accept_imu_event(event: ImuEvent) -> Result<(), ErrorId> { match event { ImuEvent::Sample(sample) => { SAMPLES.publish(sample); } - ImuEvent::Fault(message) => record_imu_fault(message), - ImuEvent::Idle | ImuEvent::Initialized => {} + ImuEvent::Fault(error) => return Err(error), + ImuEvent::Initialized => { + kaptan_log::info!("imu initialized"); + } + ImuEvent::Idle => {} } + Ok(()) } impl F722MavlinkState @@ -620,8 +614,7 @@ where // would incorrectly claim initialization never completed. self.data_channel .on_service_tick(kaptan_mavlink::message::MavState::MAV_STATE_STANDBY, false); - self.drain_driver_faults(); - self.drain_one_mavlink_log_record(); + self.drain_one_mavlink_diagnostic(); } } @@ -632,6 +625,7 @@ where self.receiver.reset(); self.config_lease_ticks = 0; self.config_protocol.expire(&mut self.configuration); + kaptan_log::warn!("mavlink receive fault; configuration session expired"); false } ReceiveInterrupt::NotPending => false, @@ -683,28 +677,45 @@ where self.config_lease_ticks -= 1; if self.config_lease_ticks == 0 { self.config_protocol.expire(&mut self.configuration); + kaptan_log::debug!("configuration session expired"); } } - fn drain_driver_faults(&mut self) { - let _ = self.mavlink_logs.drain_next_error_mailbox( - [&IMU_FAULTS, &LIDAR_FAULTS, &MTF01_FAULTS], - &mut self.fault_mailbox_cursor, - ); + fn drain_one_mavlink_diagnostic(&mut self) { + if self.mavlink_errors.is_empty() + && let Some(record) = kaptan_err_propagate::take() + { + let _ = self.mavlink_errors.publish_record(record); + } + if self.submit_pending_mavlink_error() { + return; + } + if self.mavlink_logs.peek().is_none() { + let mavlink_logs = &mut self.mavlink_logs; + let _ = kaptan_log::drain_one(|record| mavlink_logs.publish_record(*record)); + } + self.submit_queued_mavlink_log(); } - fn drain_one_mavlink_log_record(&mut self) { + fn submit_pending_mavlink_error(&mut self) -> bool { + let data_channel = &mut self.data_channel; + self.mavlink_errors.submit_next(|record| { + let payload = encode_error(record); + data_channel + .send_custom(kaptan_mavlink::transmitter::FrameClass::Critical, &payload) + .is_retained() + }) + } + + fn submit_queued_mavlink_log(&mut self) { let data_channel = &mut self.data_channel; let _ = self.mavlink_logs.submit_next(|record| { let class = match record.delivery() { DeliveryClass::Critical => kaptan_mavlink::transmitter::FrameClass::Critical, DeliveryClass::BestEffort => kaptan_mavlink::transmitter::FrameClass::BestEffort, }; - let payload = encode_diagnostic(record); - let admission = match &payload { - DiagnosticPayload::Log(payload) => data_channel.send_custom(class, payload), - DiagnosticPayload::Error(payload) => data_channel.send_custom(class, payload), - }; + let payload = encode_log(record); + let admission = data_channel.send_custom(class, &payload); let outcome = if admission.is_retained() { TransportAdmission::Accepted } else { @@ -778,22 +789,50 @@ pub fn record_mavlink_completion(completion: kaptan_dma::TxCompletion) { } } -fn publish_lidar_event(event: LidarLiteV3ServiceEvent) { +#[kaptan_err_propagate::propagate] +fn publish_lidar_event( + event: LidarLiteV3ServiceEvent, +) -> Result<(), LidarError> { match event { - LidarLiteV3ServiceEvent::Measurement(snapshot) => RANGES.publish(snapshot), - LidarLiteV3ServiceEvent::Fault(error) => record_lidar_fault(error.static_error()), - LidarLiteV3ServiceEvent::Idle => {} + LidarLiteV3ServiceEvent::Measurement(snapshot) => { + RANGES.publish(snapshot); + Ok(()) + } + LidarLiteV3ServiceEvent::Fault(LidarError::Start(I2c1Error::Busy)) => Ok(()), + LidarLiteV3ServiceEvent::Fault(error) => Err(error), + LidarLiteV3ServiceEvent::Idle => Ok(()), } } -fn record_imu_fault(message: &'static StaticError) { - IMU_FAULTS.record(message); -} +#[cfg(test)] +mod tests { + use super::{I2c1Error, LidarError, LidarLiteV3ServiceEvent, publish_lidar_event}; -fn record_lidar_fault(message: &'static StaticError) { - LIDAR_FAULTS.record(message); -} + #[test] + fn transient_i2c_busy_is_retry_not_a_typed_lidar_fault() { + while kaptan_err_propagate::take().is_some() {} + assert_eq!( + publish_lidar_event(LidarLiteV3ServiceEvent::Fault(LidarError::Start( + I2c1Error::Busy, + ))), + Ok(()) + ); + assert!(kaptan_err_propagate::take().is_none()); -fn record_mtf01_fault(message: &'static StaticError) { - MTF01_FAULTS.record(message); + assert!(matches!( + publish_lidar_event(LidarLiteV3ServiceEvent::Fault(LidarError::Start( + I2c1Error::InvalidLength, + ))), + Err(LidarError::Start(I2c1Error::InvalidLength)) + )); + assert!(kaptan_err_propagate::take().is_some()); + + assert!(matches!( + publish_lidar_event(LidarLiteV3ServiceEvent::Fault(LidarError::Start( + I2c1Error::InvalidAddress, + ))), + Err(LidarError::Start(I2c1Error::InvalidAddress)) + )); + assert!(kaptan_err_propagate::take().is_some()); + } } diff --git a/firmware/platforms/stm32f7/src/f722/clock.rs b/firmware/platforms/stm32f7/src/f722/clock.rs index 2724ecd..b0fa95c 100644 --- a/firmware/platforms/stm32f7/src/f722/clock.rs +++ b/firmware/platforms/stm32f7/src/f722/clock.rs @@ -1,7 +1,7 @@ //! STM32F722 Pll216 clock-source assembly. use super::peripherals::{DwtMicrosecondClock, select_pll216_profile}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use stm32f7::stm32f722; /// Core frequency in whole megahertz after selecting the PLL216 board profile. @@ -10,7 +10,9 @@ const CORE_CLOCK_MHZ: u32 = 216; /// A failure while establishing the board's clock tree or monotonic clock. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub(crate) enum ClockInitializationError { + #[kaptan_error(id = 11801)] Profile, + #[kaptan_error(id = 11802)] Monotonic, } diff --git a/firmware/platforms/stm32f7/src/f722/config.rs b/firmware/platforms/stm32f7/src/f722/config.rs index 016b170..6a7e68c 100644 --- a/firmware/platforms/stm32f7/src/f722/config.rs +++ b/firmware/platforms/stm32f7/src/f722/config.rs @@ -7,24 +7,22 @@ use kaptan_config::{ }; use stm32f7::stm32f722; -use crate::{F7ImmutableConfig, Severity}; +use crate::{F7ImmutableConfig, LevelFilter}; const MAVLINK_SYSTEM_ID: FieldId = FieldId::new(101); const MAVLINK_COMPONENT_ID: FieldId = FieldId::new(102); const RAW_IMU_HZ: FieldId = FieldId::new(103); const HEARTBEAT_HZ: FieldId = FieldId::new(104); -const MAVLINK_MAXIMUM_LOG_SEVERITY: FieldId = FieldId::new(105); +const MAVLINK_LOG_LEVEL_FILTER: FieldId = FieldId::new(105); const RANGE_HZ: FieldId = FieldId::new(106); const OPTICAL_FLOW_HZ: FieldId = FieldId::new(107); -const MAVLINK_LOGGING_ENABLED: FieldId = FieldId::new(108); /// Narrow boot projection consumed only by fixed F722 services. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct F722UserRuntime { pub(super) system_id: u8, pub(super) component_id: u8, - pub(super) mavlink_logging_enabled: bool, - pub(super) mavlink_maximum_log_severity: Severity, + pub(super) mavlink_log_level_filter: LevelFilter, pub(super) raw_imu_hz: u8, pub(super) range_hz: Option, pub(super) optical_flow_hz: Option, @@ -124,13 +122,10 @@ where } let raw_imu_hz = required_u8(candidate, RAW_IMU_HZ)?; let heartbeat_hz = required_u8(candidate, HEARTBEAT_HZ)?; - let mavlink_logging_enabled = required_bool(candidate, MAVLINK_LOGGING_ENABLED)?; - let severity = required_u8(candidate, MAVLINK_MAXIMUM_LOG_SEVERITY)?; - let mavlink_maximum_log_severity = - Severity::try_from_wire(severity).ok_or(ConfigViolation::field( - MAVLINK_MAXIMUM_LOG_SEVERITY, - ValidationRule::InvalidEnumValue, - ))?; + let level_filter = required_u8(candidate, MAVLINK_LOG_LEVEL_FILTER)?; + let mavlink_log_level_filter = LevelFilter::try_from_wire(level_filter).ok_or( + ConfigViolation::field(MAVLINK_LOG_LEVEL_FILTER, ValidationRule::InvalidEnumValue), + )?; let range_hz = if immutable.hardware.has_range() { Some(required_u8(candidate, RANGE_HZ)?) } else { @@ -155,8 +150,7 @@ where Ok(F722UserRuntime { system_id, component_id, - mavlink_logging_enabled, - mavlink_maximum_log_severity, + mavlink_log_level_filter, raw_imu_hz, range_hz, optical_flow_hz, @@ -172,13 +166,6 @@ fn required_u8(candidate: &C, field: FieldId) -> Result(candidate: &C, field: FieldId) -> Result { - match candidate.field_value(field) { - Some(ConfigValue::Bool(value)) => Ok(value), - Some(_) | None => Err(ConfigViolation::field(field, ValidationRule::Inconsistent)), - } -} - fn checked_rate(rate: u8, field: FieldId, service_hz: usize) -> Result { let rate = usize::from(rate); if rate == 0 { diff --git a/firmware/platforms/stm32f7/src/f722/firmware.rs b/firmware/platforms/stm32f7/src/f722/firmware.rs index f85b7fe..8566562 100644 --- a/firmware/platforms/stm32f7/src/f722/firmware.rs +++ b/firmware/platforms/stm32f7/src/f722/firmware.rs @@ -102,6 +102,7 @@ macro_rules! __f722_flight_application { <$board as $crate::F7Board>::IMMUTABLE_CONFIG, ) .unwrap_or_else(|_| safe_idle()); + $crate::__platform_internal::logging::info!("flight controller initialized"); schedule_imu(IMU_EVENT_START); ( Shared { @@ -250,7 +251,7 @@ macro_rules! __f722_flight_application { #[task(binds = TIM2, priority = 4, local = [timer], shared = [lidar])] fn tim2(mut context: tim2::Context) { context.local.timer.acknowledge_update(); - context.shared.lidar.lock(F722LidarState::on_timer); + let _ = context.shared.lidar.lock(F722LidarState::on_timer); schedule_imu(IMU_EVENT_TIMER); schedule_mavlink(MAVLINK_EVENT_TIMER); } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/clock.rs b/firmware/platforms/stm32f7/src/f722/peripherals/clock.rs index 21b4978..1a964fa 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/clock.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/clock.rs @@ -1,6 +1,6 @@ //! Deterministic STM32F722 PLL clock-source selection. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use stm32f7::stm32f722; const CLOCK_SWITCH_POLL_LIMIT: u16 = 1_024; @@ -8,12 +8,19 @@ const CLOCK_SWITCH_POLL_LIMIT: u16 = 1_024; /// Failure to establish the 216 MHz PLL clock profile. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum ClockProfileError { + #[kaptan_error(id = 11001)] HsiNotReady, + #[kaptan_error(id = 11002)] SystemClockNotHsi, + #[kaptan_error(id = 11003)] PllNotStopped, + #[kaptan_error(id = 11004)] PllNotReady, + #[kaptan_error(id = 11005)] OverDriveNotReady, + #[kaptan_error(id = 11006)] OverDriveSwitchNotReady, + #[kaptan_error(id = 11007)] SystemClockNotPll, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/i2c1.rs b/firmware/platforms/stm32f7/src/f722/peripherals/i2c1.rs index b1fb87d..561adfd 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/i2c1.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/i2c1.rs @@ -1,7 +1,7 @@ //! STM32F722 I²C1 interrupt-driven controller. +use kaptan_err_propagate::KaptanError; use kaptan_i2c::{I2cBus, I2cCompletion}; -use kaptan_log::KaptanError; use stm32f7::stm32f722; const MAX_BYTES: usize = 255; @@ -23,8 +23,11 @@ const CR2_STOP: u32 = 1 << 14; #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum I2c1Error { + #[kaptan_error(id = 11701)] InvalidLength, + #[kaptan_error(id = 11702)] InvalidAddress, + #[kaptan_error(id = 11703)] Busy, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/mod.rs b/firmware/platforms/stm32f7/src/f722/peripherals/mod.rs index 722c8b2..789f846 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/mod.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/mod.rs @@ -23,7 +23,7 @@ pub use gpio::{ configure_uart4_pins_pa0_pa1, configure_usart1_rx_pin_pa10, configure_usart1_tx_pin_pa9, configure_usart2_pins_pa2_pa3, }; -pub use i2c1::{I2c1, I2c1Config}; +pub use i2c1::{I2c1, I2c1Config, I2c1Error}; pub use spi1::{Spi1, Spi1Error}; pub use spi3::{Spi3, Spi3Error}; pub use tim2::{Tim2Periodic, Tim2PeriodicConfig}; diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/spi1.rs b/firmware/platforms/stm32f7/src/f722/peripherals/spi1.rs index 86f82dc..45b7842 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/spi1.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/spi1.rs @@ -5,7 +5,7 @@ //! is generated after the last serial frame; transmit completion can occur //! while the final serial clock is still in progress. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; @@ -15,8 +15,10 @@ const MAX_TRANSFER_LENGTH: usize = u16::MAX as usize; #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum Spi1Error { /// The transaction is malformed or exceeds the DMA counter range. + #[kaptan_error(id = 11501)] InvalidLength, /// A previous DMA transaction still owns SPI1. + #[kaptan_error(id = 11502)] Busy, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/spi3.rs b/firmware/platforms/stm32f7/src/f722/peripherals/spi3.rs index 35a599d..635fbcc 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/spi3.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/spi3.rs @@ -5,7 +5,7 @@ //! is generated after the last serial frame; transmit completion can occur //! while the final serial clock is still in progress. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; @@ -15,8 +15,10 @@ const MAX_TRANSFER_LENGTH: usize = u16::MAX as usize; #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum Spi3Error { /// The transaction is malformed or exceeds the DMA counter range. + #[kaptan_error(id = 11601)] InvalidLength, /// A previous DMA transaction still owns SPI3. + #[kaptan_error(id = 11602)] Busy, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/time.rs b/firmware/platforms/stm32f7/src/f722/peripherals/time.rs index 0f75244..4eeccda 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/time.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/time.rs @@ -1,7 +1,7 @@ //! DWT-backed monotonic microsecond clock for STM32F722. use cortex_m::peripheral::{DCB, DWT}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use kaptan_time::{MonotonicClock, Timestamp}; /// A DWT cycle counter extended into monotonic microseconds. @@ -19,6 +19,7 @@ pub struct DwtMicrosecondClock { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DwtClockStartError { /// A zero-megahertz core clock cannot produce microsecond timestamps. + #[kaptan_error(id = 11101)] ZeroFrequency, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs b/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs index b632e8b..aeed18f 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/usart.rs @@ -1,14 +1,16 @@ //! STM32F722 USART routes used by supported boards. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use stm32f7::stm32f722; /// UART baud-rate configuration error. #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BaudRateError { /// Zero is not a valid baud rate. + #[kaptan_error(id = 11201)] Zero, /// The requested divisor cannot fit USART BRR. + #[kaptan_error(id = 11202)] OutOfRange, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs b/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs index c3128ba..0463d00 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs @@ -5,7 +5,7 @@ //! cannot be selected at construction. use kaptan_dma::{DmaReadableBuffer, DmaTxEngine, TxCompletion}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use stm32f7::stm32f722; use super::Usart1Tx; @@ -24,8 +24,10 @@ pub struct Usart1TxDma { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum StartError { /// DMA cannot transfer an empty buffer or one exceeding the NDTR limit. + #[kaptan_error(id = 11301)] InvalidBufferLength, /// DMA2 stream 7 still owns an earlier transfer. + #[kaptan_error(id = 11302)] Busy, } diff --git a/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs b/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs index ee6b23c..21531f3 100644 --- a/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs +++ b/firmware/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs @@ -5,7 +5,7 @@ //! cannot be selected at construction. use kaptan_dma::{DmaReadableBuffer, DmaTxEngine, TxCompletion}; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use stm32f7::stm32f722; use super::Usart2Tx; @@ -23,8 +23,10 @@ pub struct Usart2TxDma { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum Usart2TxDmaStartError { /// DMA cannot transfer an empty buffer or one exceeding the NDTR limit. + #[kaptan_error(id = 11401)] InvalidBufferLength, /// DMA1 stream 6 still owns an earlier transfer. + #[kaptan_error(id = 11402)] Busy, } diff --git a/firmware/platforms/stm32f7/src/f722/sensors.rs b/firmware/platforms/stm32f7/src/f722/sensors.rs index acb2776..82a3cd8 100644 --- a/firmware/platforms/stm32f7/src/f722/sensors.rs +++ b/firmware/platforms/stm32f7/src/f722/sensors.rs @@ -1,11 +1,11 @@ //! Platform-owned F722 sensor-service assembly. -use super::peripherals::{DwtMicrosecondClock, Spi1, Spi3}; +use super::peripherals::{DwtMicrosecondClock, Spi1, Spi1Error, Spi3, Spi3Error}; use kaptan_drivers::{ - icm42688::{Icm42688, Icm42688Service, Icm42688ServiceEvent}, - mpu6000::{Mpu6000, Mpu6000Config, Mpu6000Service, Mpu6000ServiceEvent}, + icm42688::{Error as Icm42688Error, Icm42688, Icm42688Service, Icm42688ServiceEvent}, + mpu6000::{Error as Mpu6000Error, Mpu6000, Mpu6000Config, Mpu6000Service, Mpu6000ServiceEvent}, }; -use kaptan_log::{ErrorReport, StaticError}; +use kaptan_err_propagate::{ErrorId, ErrorReport}; use kaptan_sensor::imu::ImuSnapshot; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; @@ -20,7 +20,7 @@ pub(crate) enum ImuEvent { Idle, Initialized, Sample(ImuSnapshot), - Fault(&'static StaticError), + Fault(ErrorId), } /// Opaque owner of the selected F722 IMU service. @@ -86,11 +86,11 @@ impl F722Imu { match &mut self.inner { F722ImuKind::Mpu6000(imu) => match imu.begin() { Ok(()) => ImuEvent::Idle, - Err(error) => ImuEvent::Fault(error.static_error()), + Err(error) => ImuEvent::Fault(error.error_id()), }, F722ImuKind::Icm42688(imu) => match imu.begin() { Ok(()) => ImuEvent::Idle, - Err(error) => ImuEvent::Fault(error.static_error()), + Err(error) => ImuEvent::Fault(error.error_id()), }, } } @@ -119,21 +119,23 @@ impl F722Imu { } } -fn mpu_event(event: Mpu6000ServiceEvent) -> ImuEvent { +fn mpu_event(event: Mpu6000ServiceEvent) -> ImuEvent { match event { Mpu6000ServiceEvent::Idle => ImuEvent::Idle, Mpu6000ServiceEvent::Initialized => ImuEvent::Initialized, Mpu6000ServiceEvent::Sample(sample) => ImuEvent::Sample(sample), - Mpu6000ServiceEvent::Fault(error) => ImuEvent::Fault(error.static_error()), + Mpu6000ServiceEvent::Fault(Mpu6000Error::Start(Spi1Error::Busy)) => ImuEvent::Idle, + Mpu6000ServiceEvent::Fault(error) => ImuEvent::Fault(error.error_id()), } } -fn icm_event(event: Icm42688ServiceEvent, mount: ImuMount) -> ImuEvent { +fn icm_event(event: Icm42688ServiceEvent, mount: ImuMount) -> ImuEvent { match event { Icm42688ServiceEvent::Idle => ImuEvent::Idle, Icm42688ServiceEvent::Initialized => ImuEvent::Initialized, Icm42688ServiceEvent::Sample(sample) => ImuEvent::Sample(align_mount(sample, mount)), - Icm42688ServiceEvent::Fault(error) => ImuEvent::Fault(error.static_error()), + Icm42688ServiceEvent::Fault(Icm42688Error::Start(Spi3Error::Busy)) => ImuEvent::Idle, + Icm42688ServiceEvent::Fault(error) => ImuEvent::Fault(error.error_id()), } } @@ -160,8 +162,13 @@ fn align_mount(mut sample: ImuSnapshot, mount: ImuMount) -> ImuSnapshot { #[cfg(test)] mod tests { - use super::align_mount; + use super::{ImuEvent, align_mount, icm_event, mpu_event}; use crate::ImuMount; + use crate::f722::peripherals::{Spi1Error, Spi3Error}; + use kaptan_drivers::{ + icm42688::{Error as Icm42688Error, Icm42688ServiceEvent}, + mpu6000::{Error as Mpu6000Error, Mpu6000ServiceEvent}, + }; use kaptan_sensor::imu::ImuSnapshot; use kaptan_time::Timestamp; @@ -179,4 +186,35 @@ mod tests { assert_eq!(sample.acceleration_milligravity, [-10, -20, 30]); assert_eq!(sample.angular_rate_milliradians_per_second, [-40, -50, 60]); } + + #[test] + fn transient_spi_busy_is_retry_not_a_typed_imu_fault() { + assert!(matches!( + mpu_event(Mpu6000ServiceEvent::Fault(Mpu6000Error::Start( + Spi1Error::Busy + ))), + ImuEvent::Idle + )); + assert!(matches!( + icm_event( + Icm42688ServiceEvent::Fault(Icm42688Error::Start(Spi3Error::Busy)), + ImuMount::BodyAligned, + ), + ImuEvent::Idle + )); + + assert!(matches!( + mpu_event(Mpu6000ServiceEvent::Fault(Mpu6000Error::Start( + Spi1Error::InvalidLength + ))), + ImuEvent::Fault(_) + )); + assert!(matches!( + icm_event( + Icm42688ServiceEvent::Fault(Icm42688Error::Start(Spi3Error::InvalidLength)), + ImuMount::BodyAligned, + ), + ImuEvent::Fault(_) + )); + } } diff --git a/firmware/platforms/stm32f7/src/lib.rs b/firmware/platforms/stm32f7/src/lib.rs index 59f2d9b..d3477e2 100644 --- a/firmware/platforms/stm32f7/src/lib.rs +++ b/firmware/platforms/stm32f7/src/lib.rs @@ -9,12 +9,12 @@ #![no_std] use kaptan_config::Config; -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; pub use kaptan_drivers::mpu6000::{ AccelerometerRange, DigitalLowPassFilter, GyroscopeRange, Mpu6000Config, }; -pub use kaptan_log::Severity; +pub use kaptan_log::LevelFilter; /// Fixed sensor-to-body mounting transform. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -50,6 +50,7 @@ pub mod __platform_internal { pub use cortex_m as cm; pub use cortex_m_rt as cmrt; pub use kaptan_dma as dma; + pub use kaptan_log as logging; #[cfg(target_os = "none")] pub use panic_halt as panic; pub use rtic as rtic_app; @@ -62,15 +63,20 @@ pub mod __platform_internal { #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BoardError { /// The immutable vertical route and its electrical parameters disagree. + #[kaptan_error(id = 11901)] VerticalSensorCapabilityMismatch, /// The declaration requests a service-timer frequency not implemented by /// the selected F722 platform profile. + #[kaptan_error(id = 11902)] UnsupportedServiceFrequency, /// The selected baud cannot carry the deterministic telemetry schedule. + #[kaptan_error(id = 11903)] TelemetryLinkOvercommitted, /// The platform could not establish a clock, peripheral, or DMA owner. + #[kaptan_error(id = 11904)] Initialization, /// The target configuration could not be recovered from its flash journal. + #[kaptan_error(id = 11905)] Configuration, } diff --git a/firmware/platforms/stm32f7/tests/config_active_state.rs b/firmware/platforms/stm32f7/tests/config_active_state.rs index be7a570..2bcfe56 100644 --- a/firmware/platforms/stm32f7/tests/config_active_state.rs +++ b/firmware/platforms/stm32f7/tests/config_active_state.rs @@ -2,8 +2,8 @@ use kaptan_config::{Config, ConfigManager, ConfigModel, ConfigStore, ConfigValue, FieldId}; use platform_stm32f7::{ - F7ImmutableConfig, F722Hardware, F722ImmutableConfig, ImuMount, Mavlink, Mpu6000, - Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, Severity, VerticalSensor, + F7ImmutableConfig, F722Hardware, F722ImmutableConfig, ImuMount, LevelFilter, Mavlink, Mpu6000, + Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, VerticalSensor, }; // Include the implementation as a child module so this platform-internal test @@ -15,29 +15,17 @@ use target_config::UserConfigModel; #[derive(Clone, kaptan_config::Config)] struct MavlinkConfig { - #[config( - id = 101, - key = "mavlink.system_id", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 101, key = "mavlink.system_id", unit = "id", range(min = 1))] system_id: u8, - #[config( - id = 102, - key = "mavlink.component_id", - unit = "id", - range(min = 1, max = 255) - )] + #[config(id = 102, key = "mavlink.component_id", unit = "id", range(min = 1))] component_id: u8, - #[config(id = 108, key = "mavlink.logging.enabled", unit = "bool")] - logging_enabled: bool, #[config( enum, id = 105, - key = "mavlink.logging.maximum_severity", - unit = "mav_severity" + key = "mavlink.logging.level_filter", + unit = "log_level" )] - maximum_log_severity: Severity, + log_level_filter: LevelFilter, } #[derive(Clone, kaptan_config::Config)] @@ -118,8 +106,7 @@ fn factory() -> TestUserConfig { mavlink: MavlinkConfig { system_id: 1, component_id: 1, - logging_enabled: true, - maximum_log_severity: Severity::Info, + log_level_filter: LevelFilter::Info, }, telemetry: TelemetryConfig { raw_imu_hz: 20, @@ -167,21 +154,17 @@ impl Config for MalformedProjection { fn mavlink_logging_policy_is_projected_and_validated_independently() { assert_eq!( core::mem::size_of::(), - 10, + 9, "runtime projection must retain narrow board-native scalar widths" ); let mut candidate = UserConfigModel::::new(factory()); - candidate - .set_field(FieldId::new(108), ConfigValue::Bool(false)) - .unwrap(); candidate .set_field(FieldId::new(105), ConfigValue::U32(0)) .unwrap(); let active = candidate.validate(&IMMUTABLE).unwrap(); let runtime = target_config::project_runtime(&active, &IMMUTABLE).unwrap(); - assert!(!runtime.mavlink_logging_enabled); - assert_eq!(runtime.mavlink_maximum_log_severity, Severity::Emergency); + assert_eq!(runtime.mavlink_log_level_filter, LevelFilter::Off); } #[test] diff --git a/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs b/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs index ce9469d..48530b8 100644 --- a/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs +++ b/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs @@ -267,10 +267,9 @@ fn configuration_schema_is_generated_without_board_forwarding_code() { "MAVLINK_COMPONENT_ID", "RAW_IMU_HZ", "HEARTBEAT_HZ", - "MAVLINK_MAXIMUM_LOG_SEVERITY", + "MAVLINK_LOG_LEVEL_FILTER", "RANGE_HZ", "OPTICAL_FLOW_HZ", - "MAVLINK_LOGGING_ENABLED", ] { assert!( platform_config.contains(&format!("const {semantic_id}: FieldId")), @@ -308,16 +307,11 @@ fn f722_link_budget_reserves_only_deterministic_telemetry() { let board = platform_source("src/f722/board.rs"); assert!(board.contains("SCHEDULED_TELEMETRY_BYTES_PER_SECOND")); assert!(board.contains("SERVICE_HZ * SCHEDULED_FRAME_MAXIMUM_BYTES")); - assert_eq!( - board - .matches("self.drain_one_mavlink_log_record();") - .count(), - 1, - "each service turn must make exactly one bounded log admission attempt" - ); - assert!(board.contains("if runtime.mavlink_logging_enabled")); - assert!(board.contains("LogFilter::up_to(runtime.mavlink_maximum_log_severity)")); - assert!(board.contains("LogFilter::disabled()")); + assert!(board.contains("self.drain_one_mavlink_diagnostic();")); + assert!(board.contains("let mavlink_log_filter = runtime.mavlink_log_level_filter;")); + assert!(board.contains("LogSinkQueue::new(mavlink_log_filter)")); + assert!(!board.contains("mavlink_logging_enabled")); + assert!(!board.contains("LogFilter")); for periodic_diagnostic_policy in [ "ERROR_RECORDS_PER_SECOND", "LOG_RECORDS_PER_SECOND", @@ -341,6 +335,11 @@ fn mavlink_transport_faults_do_not_recurse_or_retain_dead_accounting() { let transport = platform_source("src/f722/data_channel.rs"); let portable_channel = platform_source("../../libs/kaptan-data-channel/src/lib.rs"); + assert!( + !board.contains("mavlink transmit dma fault"), + "the MAVLink sink must not report its own transmit failure through itself" + ); + for recursive_path in [ "drain_transport_faults", "take_transport_fault", @@ -368,3 +367,30 @@ fn mavlink_transport_faults_do_not_recurse_or_retain_dead_accounting() { ); } } + +#[test] +fn f722_producers_keep_logs_and_typed_errors_separate() { + let board = platform_source("src/f722/board.rs"); + let logger = platform_source("../../libs/kaptan-log/src/facade.rs"); + let errors = platform_source("../../libs/kaptan-err-propagate/src/ingress.rs"); + + for macro_name in [ + "kaptan_log::debug!", + "kaptan_log::info!", + "kaptan_log::warn!", + "kaptan_log::error!", + ] { + assert!( + board.contains(macro_name), + "f722 does not exercise {macro_name}" + ); + } + assert!(board.contains("#[kaptan_err_propagate::propagate]")); + assert!(!board.contains("report_error")); + assert!(!board.contains("report_static_error")); + assert!(logger.contains("GLOBAL_INGRESS_CAPACITY")); + assert!(logger.contains("compare_exchange")); + assert!(errors.contains("AtomicU8")); + assert!(errors.contains("UnsafeCell>")); + assert!(errors.contains("compare_exchange")); +} diff --git a/firmware/tools/configurator/Cargo.toml b/firmware/tools/configurator/Cargo.toml index 945cca6..491e201 100644 --- a/firmware/tools/configurator/Cargo.toml +++ b/firmware/tools/configurator/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] kaptan-config = { path = "../../libs/kaptan-config" } kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" } +kaptan-log = { path = "../../libs/kaptan-log" } kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] } mavlink-core = { version = "0.18", default-features = false } libc = "0.2" diff --git a/firmware/tools/configurator/src/api.rs b/firmware/tools/configurator/src/api.rs index 7722612..55624ee 100644 --- a/firmware/tools/configurator/src/api.rs +++ b/firmware/tools/configurator/src/api.rs @@ -209,6 +209,8 @@ mod tests { assert!(INDEX.contains("id=\"packet-rows\"")); assert!(APP.contains("api('/api/logs')")); assert!(APP.contains("newEntries.map(createLogEntry)")); + assert!(APP.contains("entry.truncated")); + assert!(APP.contains("truncated.textContent = 'TRUNCATED'")); assert!(INDEX.contains("id=\"log-entries\"")); } } diff --git a/firmware/tools/configurator/src/config_client.rs b/firmware/tools/configurator/src/config_client.rs index c58d5df..bb871b9 100644 --- a/firmware/tools/configurator/src/config_client.rs +++ b/firmware/tools/configurator/src/config_client.rs @@ -1540,15 +1540,15 @@ mod tests { let descriptor = response(MavMessage::KAPTAN_CONFIG_DESCRIPTOR( KAPTAN_CONFIG_DESCRIPTOR_DATA { - minimum_bits: 5, - maximum_bits: 6, - default_bits: 6, + minimum_bits: 3, + maximum_bits: 4, + default_bits: 3, request_id: 2, descriptor_index: 0, field_id: 108, enum_option_count: 2, - display_key: "future.logging.severity".into(), - unit: "MAV_SEVERITY".into(), + display_key: "future.logging.level_filter".into(), + unit: "log_level".into(), value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, flags: DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM, }, @@ -1568,12 +1568,12 @@ mod tests { let first_option = response(MavMessage::KAPTAN_CONFIG_ENUM_OPTION( KAPTAN_CONFIG_ENUM_OPTION_DATA { - value: 5, + value: 3, request_id: first.request_id, descriptor_index: 0, field_id: 108, option_index: 0, - label: "Notice".into(), + label: "Info".into(), }, )); let (event, second_request) = client.observe(&first_option, &mut sequence); @@ -1591,12 +1591,12 @@ mod tests { let second_option = response(MavMessage::KAPTAN_CONFIG_ENUM_OPTION( KAPTAN_CONFIG_ENUM_OPTION_DATA { - value: 6, + value: 4, request_id: second.request_id, descriptor_index: 0, field_id: 108, option_index: 1, - label: "Info".into(), + label: "Debug".into(), }, )); let (event, value_request) = client.observe(&second_option, &mut sequence); @@ -1613,7 +1613,7 @@ mod tests { _ => panic!("enum discovery must advance to VALUE_REQUEST"), }; let value = response(MavMessage::KAPTAN_CONFIG_VALUE(KAPTAN_CONFIG_VALUE_DATA { - value_bits: 6, + value_bits: 3, request_id: value_request_id, session_id: 0, revision: 0, @@ -1626,9 +1626,9 @@ mod tests { panic!("complete enum discovery must publish the configuration"); }; assert_eq!(configuration.fields[0].options.len(), 2); - assert_eq!(configuration.fields[0].options[0].label, "Notice"); - assert_eq!(configuration.fields[0].options[0].value, Scalar::U32(5)); - assert_eq!(configuration.fields[0].options[1].label, "Info"); + assert_eq!(configuration.fields[0].options[0].label, "Info"); + assert_eq!(configuration.fields[0].options[0].value, Scalar::U32(3)); + assert_eq!(configuration.fields[0].options[1].label, "Debug"); } #[test] diff --git a/firmware/tools/configurator/src/log_monitor.rs b/firmware/tools/configurator/src/log_monitor.rs index 01a04cb..0d100e6 100644 --- a/firmware/tools/configurator/src/log_monitor.rs +++ b/firmware/tools/configurator/src/log_monitor.rs @@ -2,71 +2,42 @@ use std::{collections::VecDeque, time::SystemTime}; +use kaptan_log::Level; use serde::{Serialize, Serializer}; const MAX_LOG_ENTRIES: usize = 512; -/// Severity attached to one decoded diagnostic event. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LogSeverity { - Emergency, - Alert, - Critical, - Error, - Warning, - Notice, - Info, - Debug, - Trace, - Unknown(u8), +fn serialize_level(level: &Level, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(level.as_str()) } -impl LogSeverity { - /// Decodes the stable MAV_SEVERITY numeric representation without - /// accepting values that the protocol has not defined. - pub const fn from_wire(level: u8, trace: bool) -> Self { - match level { - 0 => Self::Emergency, - 1 => Self::Alert, - 2 => Self::Critical, - 3 => Self::Error, - 4 => Self::Warning, - 5 => Self::Notice, - 6 => Self::Info, - 7 if trace => Self::Trace, - 7 => Self::Debug, - value => Self::Unknown(value), - } +/// Decodes the exact Kaptan log-level subset from its MAVLink representation. +/// +/// Kaptan emits Error, Warn, Info, and Debug on the conventional MAVLink +/// numbers 3, 4, 6, and 7. Trace is Debug plus the dedicated trace flag. The +/// other MAVLink severity numbers are not Kaptan log levels. +pub const fn level_from_wire(level: u8, trace: bool) -> Option { + if trace && level != 7 { + return None; } - - const fn known_label(self) -> Option<&'static str> { - match self { - Self::Emergency => Some("EMERGENCY"), - Self::Alert => Some("ALERT"), - Self::Critical => Some("CRITICAL"), - Self::Error => Some("ERROR"), - Self::Warning => Some("WARNING"), - Self::Notice => Some("NOTICE"), - Self::Info => Some("INFO"), - Self::Debug => Some("DEBUG"), - Self::Trace => Some("TRACE"), - Self::Unknown(_) => None, - } + match level { + 3 => Some(Level::Error), + 4 => Some(Level::Warn), + 6 => Some(Level::Info), + 7 if trace => Some(Level::Trace), + 7 => Some(Level::Debug), + _ => None, } } -impl Serialize for LogSeverity { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - if let Some(label) = self.known_label() { - serializer.serialize_str(label) - } else if let Self::Unknown(value) = self { - serializer.serialize_str(&format!("UNKNOWN ({value})")) - } else { - unreachable!("every known severity has a static label") - } +/// Human-readable packet-view representation, including malformed values. +pub fn wire_level_label(level: u8, trace: bool) -> String { + match level_from_wire(level, trace) { + Some(level) => level.as_str().into(), + None => format!("UNKNOWN ({level})"), } } @@ -75,7 +46,8 @@ impl Serialize for LogSeverity { pub struct LogObservation { pub system_id: u8, pub component_id: u8, - pub severity: LogSeverity, + pub level: Level, + pub truncated: bool, pub text: String, } @@ -94,7 +66,9 @@ pub struct LogView { pub received_at_ms: u64, pub system_id: u8, pub component_id: u8, - pub severity: LogSeverity, + #[serde(serialize_with = "serialize_level")] + pub level: Level, + pub truncated: bool, pub text: String, } @@ -131,7 +105,8 @@ impl LogMonitor { received_at_ms, system_id: observation.system_id, component_id: observation.component_id, - severity: observation.severity, + level: observation.level, + truncated: observation.truncated, text: observation.text, }); self.next_id += 1; @@ -164,7 +139,8 @@ mod tests { LogObservation { system_id: 1, component_id: 2, - severity: LogSeverity::Info, + level: Level::Info, + truncated: false, text: format!("event {index}"), } } @@ -179,10 +155,15 @@ mod tests { assert_eq!(snapshot.entries.len(), 2); assert_eq!(snapshot.entries[0].id, 0); assert_eq!(snapshot.entries[0].received_at_ms, 1_234); + assert!(!snapshot.entries[0].truncated); assert_eq!(snapshot.entries[0].text, "event 0"); assert_eq!(snapshot.entries[1].id, 1); assert_eq!(snapshot.entries[1].text, "event 1"); assert_eq!(snapshot.discarded, 0); + assert_eq!( + serde_json::to_value(&snapshot).unwrap()["entries"][0]["level"], + "INFO" + ); } #[test] @@ -213,14 +194,41 @@ mod tests { } #[test] - fn wire_severity_is_checked_and_unknown_values_remain_visible() { - assert_eq!(LogSeverity::from_wire(3, false), LogSeverity::Error); - assert_eq!(LogSeverity::from_wire(7, false), LogSeverity::Debug); - assert_eq!(LogSeverity::from_wire(7, true), LogSeverity::Trace); - assert_eq!(LogSeverity::from_wire(9, false), LogSeverity::Unknown(9)); + fn wire_level_accepts_only_the_exact_kaptan_contract() { + for (wire, expected) in [ + (0, None), + (1, None), + (2, None), + (3, Some(Level::Error)), + (4, Some(Level::Warn)), + (5, None), + (6, Some(Level::Info)), + (7, Some(Level::Debug)), + (8, None), + (u8::MAX, None), + ] { + assert_eq!(level_from_wire(wire, false), expected, "wire {wire}"); + } + assert_eq!(level_from_wire(7, true), Some(Level::Trace)); + for invalid_trace_level in [0, 1, 2, 3, 4, 5, 6, 8, u8::MAX] { + assert_eq!(level_from_wire(invalid_trace_level, true), None); + } + assert_eq!(wire_level_label(2, false), "UNKNOWN (2)"); + assert_eq!(wire_level_label(9, false), "UNKNOWN (9)"); + } + + #[test] + fn truncation_state_survives_the_browser_projection() { + let mut event = observation(0); + event.truncated = true; + let mut monitor = LogMonitor::default(); + monitor.observe([event], SystemTime::UNIX_EPOCH); + + let snapshot = monitor.snapshot(); + assert!(snapshot.entries[0].truncated); assert_eq!( - serde_json::to_value(LogSeverity::Unknown(9)).unwrap(), - "UNKNOWN (9)" + serde_json::to_value(&snapshot).unwrap()["entries"][0]["truncated"], + true ); } diff --git a/firmware/tools/configurator/src/serial_worker.rs b/firmware/tools/configurator/src/serial_worker.rs index 9eb23e4..4c92448 100644 --- a/firmware/tools/configurator/src/serial_worker.rs +++ b/firmware/tools/configurator/src/serial_worker.rs @@ -15,10 +15,11 @@ use std::{ use crate::{ config_client::{ClientEvent, ConfigClient}, - log_monitor::{LogObservation, LogSeverity}, + log_monitor::{LogObservation, level_from_wire, wire_level_label}, packet_monitor::PacketObservation, state::{SaveRequest, SharedState}, }; +use kaptan_log::Level; use kaptan_mavlink::{ MavlinkFrame, message::{self, Message}, @@ -321,36 +322,34 @@ fn log_observation( frame: &MavlinkFrame, message: &message::kaptan::MavMessage, ) -> Option { - let (severity, text) = match message { + let (level, truncated, text) = match message { message::kaptan::MavMessage::KAPTAN_LOG(log) => { let trace = log .flags .contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE); ( - LogSeverity::from_wire(log.level, trace), - bounded_text(&log.message, log.message_length), + level_from_wire(log.level, trace)?, + log.flags + .contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED), + bounded_text(&log.message, log.message_length)?, ) } message::kaptan::MavMessage::KAPTAN_ERROR(error) => { - let path = bounded_text(&error.path, error.path_length); - let information = bounded_text( - &error.additional_information, - error.additional_information_length, - ); - let text = match (path.is_empty(), information.is_empty()) { - (false, false) => format!("{path}: {information}"), - (false, true) => path, - (true, false) => information, - (true, true) => "Unspecified Kaptan error".into(), + let raised_at = zero_padded_text(&error.raised_at)?; + let text = if raised_at.is_empty() { + format!("Kaptan error {}", error.error_id) + } else { + format!("Kaptan error {} at {raised_at}", error.error_id) }; - (LogSeverity::from_wire(error.level, false), text) + (Level::Error, false, text) } _ => return None, }; Some(LogObservation { system_id: frame.system_id(), component_id: frame.component_id(), - severity, + level, + truncated, text, }) } @@ -358,23 +357,17 @@ fn log_observation( fn kaptan_message_values(message: &message::kaptan::MavMessage) -> serde_json::Value { match message { message::kaptan::MavMessage::KAPTAN_ERROR(error) => serde_json::json!({ - "level": LogSeverity::from_wire(error.level, false), - "flags": format!("{:?}", error.flags), - "flags_bits": error.flags.bits(), - "path": bounded_text(&error.path, error.path_length), - "additional_information": bounded_text( - &error.additional_information, - error.additional_information_length, - ), + "error_id": error.error_id, + "raised_at": packet_zero_padded_text(&error.raised_at), }), message::kaptan::MavMessage::KAPTAN_LOG(log) => serde_json::json!({ - "level": LogSeverity::from_wire( + "level": wire_level_label( log.level, log.flags.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE), ), "flags": format!("{:?}", log.flags), "flags_bits": log.flags.bits(), - "message": bounded_text(&log.message, log.message_length), + "message": packet_text(&log.message, log.message_length), }), _ => decoded_fields(message), } @@ -384,9 +377,22 @@ fn decoded_fields(message: &impl core::fmt::Debug) -> serde_json::Value { format!("{message:#?}").into() } -fn bounded_text(bytes: &[u8; N], length: u8) -> String { - let end = usize::from(length).min(N); - String::from_utf8_lossy(&bytes[..end]).into_owned() +fn bounded_text(bytes: &[u8; N], length: u8) -> Option { + let text = core::str::from_utf8(bytes.get(..usize::from(length))?).ok()?; + Some(text.into()) +} + +fn packet_text(bytes: &[u8; N], length: u8) -> String { + bounded_text(bytes, length).unwrap_or_else(|| format!("INVALID TEXT (length {length})")) +} + +fn zero_padded_text(bytes: &[u8; N]) -> Option { + let length = bytes.iter().position(|byte| *byte == 0).unwrap_or(N); + Some(core::str::from_utf8(&bytes[..length]).ok()?.into()) +} + +fn packet_zero_padded_text(bytes: &[u8; N]) -> String { + zero_padded_text(bytes).unwrap_or_else(|| "INVALID TEXT".into()) } fn hex_bytes(bytes: &[u8]) -> String { @@ -533,13 +539,10 @@ mod tests { } #[test] - fn packet_monitor_decodes_kaptan_error_text() { - let path_text = b"driver::Icm42688::Transfer"; - let information_text = b"SPI timeout"; - let mut path = [0_u8; 96]; - let mut additional_information = [0_u8; 64]; - path[..path_text.len()].copy_from_slice(path_text); - additional_information[..information_text.len()].copy_from_slice(information_text); + fn packet_and_history_decode_a_typed_kaptan_error() { + let raised_at_text = "platforms/stm32f7/src/f722/board.rs:572"; + let mut raised_at = [0; 96]; + raised_at[..raised_at_text.len()].copy_from_slice(raised_at_text.as_bytes()); let mut frame = MavlinkFrame::new(); frame.serialize_message_data( MavHeader { @@ -548,27 +551,28 @@ mod tests { component_id: 7, }, &message::kaptan::KAPTAN_ERROR_DATA { - level: 2, - flags: message::kaptan::KaptanErrorFlags::empty(), - path_length: path_text.len() as u8, - additional_information_length: information_text.len() as u8, - path, - additional_information, + error_id: 1234, + raised_at, }, ); let (observation, log) = frame_observations(&frame); assert_eq!(observation.name, "KAPTAN_ERROR"); - assert_eq!(observation.values["level"], "CRITICAL"); - assert_eq!(observation.values["path"], "driver::Icm42688::Transfer"); - assert_eq!(observation.values["additional_information"], "SPI timeout"); + assert!(observation.values.get("level").is_none()); + assert_eq!(observation.values["error_id"], 1234); + assert_eq!(observation.values["raised_at"], raised_at_text); + assert!(observation.values.get("flags").is_none()); + assert!(observation.values.get("flags_bits").is_none()); + assert!(observation.values.get("path").is_none()); + assert!(observation.values.get("additional_information").is_none()); assert_eq!( log, Some(LogObservation { system_id: 42, component_id: 7, - severity: LogSeverity::Critical, - text: "driver::Icm42688::Transfer: SPI timeout".into(), + level: kaptan_log::Level::Error, + truncated: false, + text: format!("Kaptan error 1234 at {raised_at_text}"), }) ); } @@ -601,12 +605,104 @@ mod tests { Some(LogObservation { system_id: 42, component_id: 7, - severity: LogSeverity::Info, + level: kaptan_log::Level::Info, + truncated: false, text: "estimator initialized".into(), }) ); } + #[test] + fn error_level_runtime_log_remains_kaptan_log() { + let message_bytes = [b'x'; 160]; + let message_text = "x".repeat(message_bytes.len()); + let mut frame = MavlinkFrame::new(); + frame.serialize_message_data( + MavHeader { + sequence: 1, + system_id: 42, + component_id: 7, + }, + &message::kaptan::KAPTAN_LOG_DATA { + level: 3, + flags: message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED, + message_length: message_bytes.len() as u8, + message: message_bytes, + }, + ); + + let (packet, log) = frame_observations(&frame); + assert_eq!(packet.name, "KAPTAN_LOG"); + assert_eq!( + log, + Some(LogObservation { + system_id: 42, + component_id: 7, + level: Level::Error, + truncated: true, + text: message_text, + }) + ); + } + + #[test] + fn malformed_log_text_or_level_is_not_added_to_history() { + let mut invalid_utf8 = [0_u8; 160]; + invalid_utf8[0] = 0xff; + for (level, flags, length, message) in [ + ( + 6, + message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE, + 0, + [0_u8; 160], + ), + ( + 6, + message::kaptan::KaptanLogFlags::empty(), + 161, + [0_u8; 160], + ), + (6, message::kaptan::KaptanLogFlags::empty(), 1, invalid_utf8), + ] { + let mut frame = MavlinkFrame::new(); + frame.serialize_message_data( + MavHeader { + sequence: 1, + system_id: 42, + component_id: 7, + }, + &message::kaptan::KAPTAN_LOG_DATA { + level, + flags, + message_length: length, + message, + }, + ); + let (_, log) = frame_observations(&frame); + assert_eq!(log, None); + } + } + + #[test] + fn malformed_typed_error_location_is_not_added_to_history() { + let mut raised_at = [0_u8; 96]; + raised_at[0] = 0xff; + let mut frame = MavlinkFrame::new(); + frame.serialize_message_data( + MavHeader { + sequence: 1, + system_id: 42, + component_id: 7, + }, + &message::kaptan::KAPTAN_ERROR_DATA { + error_id: 1, + raised_at, + }, + ); + let (_, log) = frame_observations(&frame); + assert_eq!(log, None); + } + #[test] fn serial_baud_accepts_only_rates_with_an_exact_termios_mapping() { for rate in [ diff --git a/firmware/tools/configurator/web/app.js b/firmware/tools/configurator/web/app.js index 7b94e66..67c2046 100644 --- a/firmware/tools/configurator/web/app.js +++ b/firmware/tools/configurator/web/app.js @@ -341,23 +341,30 @@ function logTime(receivedAtMs) { function createLogEntry(entry) { const item = document.createElement('li'); item.dataset.logId = String(entry.id); - const severityClass = entry.severity.split(/[^A-Z]+/, 1)[0].toLowerCase(); - item.className = `log-entry log-${severityClass}`; + const levelClass = entry.level.split(/[^A-Z]+/, 1)[0].toLowerCase(); + item.className = `log-entry log-${levelClass}`; const time = document.createElement('time'); time.dateTime = new Date(entry.received_at_ms).toISOString(); time.textContent = logTime(entry.received_at_ms); - const severity = document.createElement('span'); - severity.className = 'log-severity'; - severity.textContent = entry.severity; + const level = document.createElement('span'); + level.className = 'log-level'; + level.textContent = entry.level; const text = document.createElement('span'); text.className = 'log-text'; - text.textContent = entry.text; + if (entry.truncated) { + const truncated = document.createElement('span'); + truncated.className = 'log-truncated'; + truncated.textContent = 'TRUNCATED'; + text.append(truncated, document.createTextNode(entry.text)); + } else { + text.textContent = entry.text; + } item.title = `MAVLink source ${entry.system_id}:${entry.component_id}`; - item.append(time, severity, text); + item.append(time, level, text); return item; } diff --git a/firmware/tools/configurator/web/styles.css b/firmware/tools/configurator/web/styles.css index 839fdf4..d98a4f5 100644 --- a/firmware/tools/configurator/web/styles.css +++ b/firmware/tools/configurator/web/styles.css @@ -223,13 +223,19 @@ th:nth-child(n + 3) { text-align: right; } .log-entry:last-child { border-bottom: 0; } .log-entry time { color: var(--muted); } -.log-severity { font-weight: 700; } +.log-level { font-weight: 700; } .log-text { white-space: pre-wrap; overflow-wrap: anywhere; } -.log-emergency, -.log-alert, -.log-critical, +.log-truncated { + display: inline-block; + margin-right: .5rem; + padding: 0 .25rem; + border: 1px solid currentColor; + color: var(--warning); + font-size: .75em; + font-weight: 700; +} .log-error { color: var(--error); } -.log-warning { color: var(--warning); } +.log-warn { color: var(--warning); } .empty-log { padding: .55rem; diff --git a/firmware/tools/sitl/Cargo.toml b/firmware/tools/sitl/Cargo.toml index 38fc16a..ff3d91b 100644 --- a/firmware/tools/sitl/Cargo.toml +++ b/firmware/tools/sitl/Cargo.toml @@ -9,20 +9,20 @@ version.workspace = true workspace = true [dependencies] +kaptan-err-propagate = { path = "../../libs/kaptan-err-propagate" } kaptan-control = { path = "../../libs/kaptan-control" } kaptan-config = { path = "../../libs/kaptan-config" } kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" } kaptan-flight-config = { path = "../../libs/kaptan-flight-config" } kaptan-flight-runtime = { path = "../../libs/kaptan-flight-runtime" } -kaptan-log = { path = "../../libs/kaptan-log", features = ["facade"] } -kaptan-log-mavlink = { path = "../../libs/kaptan-log-mavlink" } +kaptan-log = { path = "../../libs/kaptan-log" } +kaptan-diagnostics-mavlink = { path = "../../libs/kaptan-diagnostics-mavlink" } kaptan-mavlink = { path = "../../libs/kaptan-mavlink" } kaptan-math = { path = "../../libs/kaptan-math" } kaptan-navigation = { path = "../../libs/kaptan-navigation", features = ["sitl"] } kaptan-safety = { path = "../../libs/kaptan-safety" } kaptan-time = { path = "../../libs/kaptan-time" } minicbor = "0.25" -log = { version = "0.4.33", default-features = false } [dev-dependencies] mavlink = { version = "0.18", default-features = false, features = ["embedded"] } diff --git a/firmware/tools/sitl/src/config/logging.rs b/firmware/tools/sitl/src/config/logging.rs index d0a85bd..deda233 100644 --- a/firmware/tools/sitl/src/config/logging.rs +++ b/firmware/tools/sitl/src/config/logging.rs @@ -1,32 +1,8 @@ -//! Host-only standard-log facade composition. +//! Host-side access to the common bounded log ingress. -use std::sync::Once; +use kaptan_log::LogRecord; -use kaptan_log::{FacadeIngress, GlobalLogger, LogRecord}; - -const SITL_LOG_INGRESS_CAPACITY: usize = 16; -static SITL_LOG_INGRESS: FacadeIngress = FacadeIngress::new(); -static SITL_LOGGER: GlobalLogger = - GlobalLogger::new(&SITL_LOG_INGRESS, sitl_forbidden_log_context); -static INSTALL_SITL_LOGGER: Once = Once::new(); - -const fn sitl_forbidden_log_context() -> bool { - false -} - -/// Installs the host-only standard logging facade once for the SITL process. -/// -/// SITL has no ISR, panic, or hard-fault context. Flight targets deliberately -/// do not enable this host-only feature. -pub fn install_log_facade() { - INSTALL_SITL_LOGGER.call_once(|| { - SITL_LOGGER - .install() - .expect("SITL owns the process-global log facade") - }); -} - -/// Removes one transport-neutral event for composition-level sink fanout. -pub(super) fn take() -> Option { - SITL_LOG_INGRESS.take() +/// Visits one transport-neutral event for composition-level sink fanout. +pub(super) fn drain_one(consume: impl FnOnce(&LogRecord) -> R) -> Option { + kaptan_log::drain_one(consume) } diff --git a/firmware/tools/sitl/src/config/mod.rs b/firmware/tools/sitl/src/config/mod.rs index 1100fff..7c62a98 100644 --- a/firmware/tools/sitl/src/config/mod.rs +++ b/firmware/tools/sitl/src/config/mod.rs @@ -11,7 +11,6 @@ mod store; #[cfg(test)] mod tests; -pub use logging::install_log_facade; pub use runtime::{CAPABILITIES, SitlConfigRuntime, configuration_service}; pub use store::{ FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile, diff --git a/firmware/tools/sitl/src/config/runtime.rs b/firmware/tools/sitl/src/config/runtime.rs index 6815cee..6dbe370 100644 --- a/firmware/tools/sitl/src/config/runtime.rs +++ b/firmware/tools/sitl/src/config/runtime.rs @@ -2,10 +2,11 @@ use kaptan_config::ConfigManager; use kaptan_config_mavlink::{ConfigCommand, ConfigProtocol, Principal, ServiceResponse, Target}; +use kaptan_diagnostics_mavlink::encode_log; use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile}; use kaptan_flight_runtime::FlightRuntime; use kaptan_log::TransportAdmission; -use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; +use kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA; use super::{FileConfigStore, logging}; @@ -51,12 +52,11 @@ impl SitlConfigRuntime { /// Drains one standard `log` facade record and exposes the generated /// diagnostic payload for the host's sole MAVLink link owner. This /// host-only harness accepts the bounded handoff synchronously. - pub fn pending_diagnostic_payload(&mut self) -> Option { - if let Some(record) = logging::take() { - self.runtime.publish_log_event(record); - } + pub fn pending_log_payload(&mut self) -> Option { + let runtime = &mut self.runtime; + let _ = logging::drain_one(|record| runtime.publish_log_event(*record)); self.runtime - .submit_next_mavlink(|record| (TransportAdmission::Accepted, encode_diagnostic(record))) + .submit_next_mavlink(|record| (TransportAdmission::Accepted, encode_log(record))) } } @@ -64,7 +64,6 @@ impl SitlConfigRuntime { /// SITL/GCS integration. Flight targets must supply authenticated authority /// and a reviewed journal/activation coordinator before constructing one. pub fn configuration_service(store: FileConfigStore) -> Result { - logging::install_log_facade(); let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store) .map_err(|error| format!("configuration recovery failed: {error:?}"))?; Ok(SitlConfigRuntime { diff --git a/firmware/tools/sitl/src/config/store.rs b/firmware/tools/sitl/src/config/store.rs index 1739ff1..2c7a6df 100644 --- a/firmware/tools/sitl/src/config/store.rs +++ b/firmware/tools/sitl/src/config/store.rs @@ -7,11 +7,11 @@ use std::{ }; use kaptan_config::ConfigStore; +use kaptan_err_propagate::KaptanError; use kaptan_flight_config::{ CandidateProfile, CborEncodeError, CborError, MAXIMUM_CANDIDATE_BYTES, VehicleProfile, decode_candidate, encode_candidate, }; -use kaptan_log::KaptanError; use super::{CAPABILITIES, configuration_service}; @@ -19,12 +19,16 @@ use super::{CAPABILITIES, configuration_service}; #[derive(Debug, KaptanError)] pub enum FileStoreError { /// Host filesystem operation failed. + #[kaptan_error(id = 12101)] Io(std::io::Error), /// The bounded file record did not decode and validate. + #[kaptan_error(id = 12102)] Cbor(CborError), /// The fixed snapshot buffer could not encode the complete candidate. + #[kaptan_error(id = 12103)] Encode, /// Factory-only startup deliberately has no durable configuration path. + #[kaptan_error(id = 12104)] FactoryOnly, } diff --git a/firmware/tools/sitl/src/config/tests.rs b/firmware/tools/sitl/src/config/tests.rs index f1d183f..379cdb2 100644 --- a/firmware/tools/sitl/src/config/tests.rs +++ b/firmware/tools/sitl/src/config/tests.rs @@ -2,7 +2,6 @@ use std::fs; use kaptan_config::{ConfigModel, ConfigStore}; use kaptan_flight_config::{CandidateProfile, decode_candidate}; -use kaptan_log_mavlink::DiagnosticPayload; use kaptan_mavlink::{ encoder::MavlinkEncoder, message::{Message, kaptan::MavMessage}, @@ -49,15 +48,14 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() { let _ = fs::remove_file(&path); let mut service = configuration_service(FileConfigStore::new(&path)).unwrap(); - log::info!("SITL configuration service ready"); - let Some(DiagnosticPayload::Log(payload)) = service.pending_diagnostic_payload() else { - panic!("info facade record must reach KAPTAN_LOG"); - }; - assert_eq!(payload.level, kaptan_log::Severity::Info.wire_value()); + kaptan_log::info!("SITL configuration service ready"); + let payload = service + .pending_log_payload() + .expect("info facade record must reach KAPTAN_LOG"); + assert_eq!(payload.level, 6); let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)]) .expect("facade text remains UTF-8"); - assert!(message.contains("config::tests")); - assert!(message.ends_with("SITL configuration service ready")); + assert_eq!(message, "SITL configuration service ready"); let mut encoder = MavlinkEncoder::new(1, 1); let frame = encoder.custom(&payload); assert_eq!(frame.message_id(), 51_020); diff --git a/firmware/tools/sitl/src/protocol.rs b/firmware/tools/sitl/src/protocol.rs index d52deaa..3ffef86 100644 --- a/firmware/tools/sitl/src/protocol.rs +++ b/firmware/tools/sitl/src/protocol.rs @@ -4,7 +4,7 @@ //! dialect. It is confined to one host network namespace and is versioned so //! malformed simulator traffic never becomes estimator or motor authority. -use kaptan_log::KaptanError; +use kaptan_err_propagate::KaptanError; use std::net::SocketAddr; pub const SENSOR_PORT: u16 = 14_660; @@ -42,8 +42,11 @@ pub const STATUS_OUTPUT_PERMITTED: u16 = 1 << 3; pub const STATUS_ACTUATOR_TRANSPORT: u16 = 1 << 4; #[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum PacketError { + #[kaptan_error(id = 12001)] Length, + #[kaptan_error(id = 12002)] Version, + #[kaptan_error(id = 12003)] NonFinite, }