//! MAVLink projection for bounded Kaptan diagnostics. //! //! This adapter creates generated payload data only. A platform-owned link is //! solely responsible for MAVLink header sequence, bounded admission, DMA, and //! retry policy. #![no_std] #[cfg(test)] extern crate std; use kaptan_log::{LogRecord, RecordChannel, ShutdownReason, ShutdownReport}; use kaptan_mavlink::message::kaptan::{ KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, KaptanErrorFlags, KaptanLogFlags, KaptanShutdownReason, }; /// Exact capacity of the fixed text field in [`KAPTAN_LOG_DATA`]. pub const LOG_MESSAGE_BYTES: usize = 160; /// One generated diagnostic payload. A platform-owned link remains the sole /// MAVLink framing, admission, DMA, and retry owner. #[derive(Clone, Debug, PartialEq)] pub enum DiagnosticPayload { /// Ordinary severity-and-text record. Log(KAPTAN_LOG_DATA), /// Error-only bounded display identity. Error(KAPTAN_ERROR_DATA), } /// Encodes one bounded diagnostic record. /// /// The adapter produces payload data only; it neither frames nor transmits /// MAVLink. `RecordChannel` is the only routing decision: ordinary records /// become `KAPTAN_LOG`, while error-or-higher records become `KAPTAN_ERROR`. pub fn encode_diagnostic(record: &LogRecord) -> DiagnosticPayload { let message = record.message(); debug_assert!(usize::from(message.len()) <= LOG_MESSAGE_BYTES); match record.channel() { RecordChannel::Log => DiagnosticPayload::Log(KAPTAN_LOG_DATA { level: record.severity() as u8, flags: KaptanLogFlags::from_bits_retain(record.flags().bits()), message_length: message.len(), message: *message.bytes(), }), RecordChannel::Error => DiagnosticPayload::Error(KAPTAN_ERROR_DATA { flags: KaptanErrorFlags::from_bits_retain(record.flags().error_bits()), message_length: message.len(), message: *message.bytes(), }), } } /// Encodes one typed shutdown event. The caller owns when a shutdown is safe /// to report; this pure adapter cannot run a shutdown state machine. pub const fn encode_shutdown(report: ShutdownReport) -> KAPTAN_SHUTDOWN_DATA { KAPTAN_SHUTDOWN_DATA { reason: match report.reason() { ShutdownReason::Operator => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_OPERATOR, ShutdownReason::Safety => KaptanShutdownReason::KAPTAN_SHUTDOWN_REASON_SAFETY, }, } } #[cfg(test)] mod tests { use std::sync::Once; use super::{DiagnosticPayload, LOG_MESSAGE_BYTES, encode_diagnostic, encode_shutdown}; use kaptan_log::{ EventHub, FacadeIngress, GlobalLogger, LogFilter, LogFlags, LogRecord, LogText, Severity, ShutdownReason, ShutdownReport, }; use kaptan_mavlink::{ encoder::MavlinkEncoder, message::{ Message, kaptan::{KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KAPTAN_SHUTDOWN_DATA, MavMessage}, }, }; static ERROR_INGRESS: FacadeIngress<1> = FacadeIngress::new(LogFilter::new(Severity::Info)); static ERROR_LOGGER: GlobalLogger<1> = GlobalLogger::new(&ERROR_INGRESS, never_forbidden); static INSTALL_ERROR_LOGGER: Once = Once::new(); const fn never_forbidden() -> bool { false } #[test] fn ordinary_record_preserves_level_flags_and_bounded_text() { let record = LogRecord::new( Severity::Info, LogFlags::TRUNCATED, LogText::from_message("imu unavailable"), ); let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else { panic!("info record must use KAPTAN_LOG"); }; assert_eq!(data.level, Severity::Info as u8); assert_eq!(data.message_length, 15); assert_eq!(&data.message[..15], b"imu unavailable"); assert!(data.message[15..].iter().all(|byte| *byte == 0)); assert_eq!(data.flags.bits(), LogFlags::TRUNCATED.bits()); assert_eq!(KAPTAN_LOG_DATA::ENCODED_LEN, 163); } #[test] fn standard_error_macro_reaches_error_channel_after_optional_ingress_is_full() { INSTALL_ERROR_LOGGER.call_once(|| { ERROR_LOGGER .install() .expect("the test owns the process-global log facade"); }); kaptan_log::log::info!("optional ingress is occupied"); kaptan_log::log::error!("critical error survives"); let mut hub = EventHub::<1, 1>::new(LogFilter::default()); assert!(ERROR_INGRESS.drain_one_into(&mut hub).is_some()); let record = hub.peek().expect("critical macro record retained"); let DiagnosticPayload::Error(payload) = encode_diagnostic(&record) else { panic!("log::error! must not produce KAPTAN_LOG"); }; assert!( payload.message[..usize::from(payload.message_length)] .ends_with(b"critical error survives") ); } #[test] fn generated_log_and_error_messages_round_trip_through_the_sole_encoder() { let record = LogRecord::new( Severity::Info, LogFlags::NONE, LogText::from_message(&"x".repeat(LOG_MESSAGE_BYTES)), ); let DiagnosticPayload::Log(data) = encode_diagnostic(&record) else { panic!("info record must use KAPTAN_LOG"); }; let mut encoder = MavlinkEncoder::new(1, 1); let frame = encoder.custom(&data); assert!(frame.has_valid_crc::()); assert_eq!(frame.message_id(), 51_020); assert_eq!(frame.raw_bytes().len(), 175); match MavMessage::parse( mavlink::MavlinkVersion::V2, frame.message_id(), frame.payload(), ) { Ok(MavMessage::KAPTAN_LOG(decoded)) => assert_eq!(decoded, data), other => panic!("unexpected decoded KAPTAN_LOG payload: {other:?}"), } assert_eq!(data.message.len(), LOG_MESSAGE_BYTES); let error = LogRecord::new( Severity::Error, LogFlags::TRACE.with(LogFlags::COALESCED), LogText::from_message("driver::Error::Transfer"), ); let DiagnosticPayload::Error(error_data) = encode_diagnostic(&error) else { panic!("error record must use KAPTAN_ERROR"); }; assert_eq!(error_data.flags.bits(), LogFlags::COALESCED.bits()); assert_eq!(error_data.message_length, 23); assert_eq!(KAPTAN_ERROR_DATA::ENCODED_LEN, 162); let error_frame = encoder.custom(&error_data); assert_eq!(error_frame.message_id(), 51_021); assert!(matches!( MavMessage::parse( mavlink::MavlinkVersion::V2, error_frame.message_id(), error_frame.payload(), ), Ok(MavMessage::KAPTAN_ERROR(decoded)) if decoded == error_data )); } #[test] fn typed_shutdown_maps_to_its_sole_generated_message() { let data = encode_shutdown(ShutdownReport::new(ShutdownReason::Safety)); assert_eq!(KAPTAN_SHUTDOWN_DATA::ENCODED_LEN, 1); let mut encoder = MavlinkEncoder::new(1, 1); let frame = encoder.custom(&data); assert_eq!(frame.message_id(), 51_022); assert!(matches!( MavMessage::parse( mavlink::MavlinkVersion::V2, frame.message_id(), frame.payload(), ), Ok(MavMessage::KAPTAN_SHUTDOWN(decoded)) if decoded == data )); } }