feat(log): add bounded diagnostic transport

This commit is contained in:
Onur Özkan committed 2026-08-09 22:04:06 +03:00
1 parent 632bf9f01b
commit 0a75860841
46 files changed
+2758 -60

No files matched your search

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