refactor(firmware): centralize configuration and diagnostics

This commit is contained in:
Onur Özkan committed 2026-08-22 18:37:18 +03:00
1 parent 7048ed89d0
commit 387fdaad23
140 files changed
+6891 -4541

No files matched your search

+197 -160
View File
@@ -4,7 +4,7 @@ use core::fmt;
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, ErrorReport, LogFilter, LogFlags,
LogRecord, LogText, Severity, StaticError,
LogRecord, LogText, Severity, StaticError, StaticErrorMailbox,
};
/// Outcome from one bounded diagnostic admission attempt.
@@ -12,9 +12,7 @@ use crate::{
pub enum PublishOutcome {
/// The record was retained.
Queued,
/// An adjacent equivalent record absorbed this occurrence.
Coalesced,
/// An ordinary log record was excluded by the configured verbosity cap.
/// A record was excluded by this sink's configured policy.
Filtered,
/// A best-effort record was replaced by newer optional information.
ReplacedOldest,
@@ -26,37 +24,10 @@ pub enum PublishOutcome {
/// record to its lower transport.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportAdmission {
/// The lower transport retained the record, so it may leave the hub.
/// The lower transport retained the record, so it may leave the queue.
Accepted,
/// The lower transport did not accept it yet; retain it for retry.
Retry,
/// The lower transport will not accept optional detail. This can only
/// discard a best-effort record; critical records remain queued.
DropBestEffort,
}
/// Saturating, process-local accounting for bounded diagnostic admission.
///
/// These counters intentionally are not part of `KAPTAN_LOG` or
/// `KAPTAN_ERROR`: neither wire message is a metrics protocol.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HubHealth {
/// Optional records rejected by the active verbosity cap.
pub filtered: u32,
/// Adjacent records folded into an existing entry.
pub coalesced: u32,
/// Optional entries replaced because their bounded lane was full.
pub best_effort_replaced: u32,
/// Error entries rejected because their bounded critical lane was full.
pub critical_rejected: u32,
/// Optional entries the lower transport refused permanently.
pub best_effort_transport_dropped: u32,
}
impl HubHealth {
fn increment(value: &mut u32) {
*value = value.saturating_add(1);
}
}
struct Lane<const N: usize> {
@@ -76,15 +47,6 @@ impl<const N: usize> Lane<N> {
}
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
if self.len != 0 {
let tail = (self.head + self.len - 1) % N;
if let Some(previous) = self.entries[tail].as_mut()
&& previous.is_equivalent_to(&record)
{
previous.increment_occurrences();
return PublishOutcome::Coalesced;
}
}
if self.len == N {
if replace_oldest {
self.entries[self.head] = Some(record);
@@ -99,6 +61,10 @@ impl<const N: usize> Lane<N> {
PublishOutcome::Queued
}
const fn is_full(&self) -> bool {
self.len == N
}
fn peek(&self) -> Option<LogRecord> {
self.entries.get(self.head).copied().flatten()
}
@@ -114,39 +80,78 @@ impl<const N: usize> Lane<N> {
}
}
/// One bounded priority hub. It owns no global logger, MAVLink state, or DMA.
pub struct EventHub<const CRITICAL: usize, const BEST_EFFORT: usize> {
/// One bounded priority queue owned by a log sink.
///
/// It owns no global logger, MAVLink state, DMA, filesystem, or other sink.
pub struct LogSinkQueue<const CRITICAL: usize, const BEST_EFFORT: usize> {
filter: LogFilter,
critical: Lane<CRITICAL>,
best_effort: Lane<BEST_EFFORT>,
health: HubHealth,
}
impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EFFORT> {
/// Creates one hub with its immutable boot-active verbosity cap.
impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BEST_EFFORT> {
/// Creates one sink queue with its immutable boot-active policy.
pub const fn new(filter: LogFilter) -> Self {
Self {
filter,
critical: Lane::new(),
best_effort: Lane::new(),
health: HubHealth {
filtered: 0,
coalesced: 0,
best_effort_replaced: 0,
critical_rejected: 0,
best_effort_transport_dropped: 0,
},
}
}
/// Immutable best-effort verbosity policy.
/// Immutable sink policy.
pub const fn filter(&self) -> LogFilter {
self.filter
}
/// Process-local bounded-admission accounting.
pub const fn health(&self) -> HubHealth {
self.health
/// 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<const SOURCES: usize>(
&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
@@ -164,7 +169,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
/// Retains one static derived error without formatting or exposing source
/// values. The adapter maps it to `KAPTAN_ERROR`.
pub fn publish_error<E: ErrorReport>(&mut self, flags: LogFlags, error: &E) -> PublishOutcome {
self.publish_error_static(flags, error.static_error())
self.publish_record(LogRecord::from_error(flags, error))
}
/// Retains one static mailbox error. This is the ISR-safe counterpart to
@@ -174,16 +179,13 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
flags: LogFlags,
error: &'static StaticError,
) -> PublishOutcome {
self.publish_error_text(
flags,
ErrorPath::from_message(error.path()),
ErrorAdditionalInformation::from_message(error.additional_information()),
)
self.publish_record(LogRecord::from_static_error(flags, error))
}
/// Retains a preformatted error identity and optional detail. Both
/// components stay transport-neutral here; the MAVLink adapter enforces
/// its independent on-wire capacities.
/// 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,
@@ -194,38 +196,29 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
/// Retains one preformatted bounded text payload. Error-or-higher
/// severities are independently retained and sent as `KAPTAN_ERROR`.
/// severities are independently encoded as `KAPTAN_ERROR`.
pub fn publish_text(
&mut self,
severity: Severity,
flags: LogFlags,
text: LogText,
) -> PublishOutcome {
let delivery = delivery_for(severity);
if delivery == DeliveryClass::BestEffort && !self.filter.allows(severity) {
HubHealth::increment(&mut self.health.filtered);
return PublishOutcome::Filtered;
}
self.publish_record(LogRecord::new(severity, flags, text))
}
fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
/// 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.
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
if !self.filter.allows(record.severity()) {
return PublishOutcome::Filtered;
}
let delivery = record.delivery();
let outcome = match delivery {
match delivery {
DeliveryClass::Critical => self.critical.publish(record, false),
DeliveryClass::BestEffort => self.best_effort.publish(record, true),
};
match outcome {
PublishOutcome::Coalesced => HubHealth::increment(&mut self.health.coalesced),
PublishOutcome::ReplacedOldest => {
HubHealth::increment(&mut self.health.best_effort_replaced);
}
PublishOutcome::RejectedFull => {
HubHealth::increment(&mut self.health.critical_rejected);
}
PublishOutcome::Queued | PublishOutcome::Filtered => {}
}
outcome
}
/// Highest-priority record without relinquishing it.
@@ -233,9 +226,9 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
self.critical.peek().or_else(|| self.best_effort.peek())
}
/// Submits the current record through the sole telemetry owner. The hub
/// removes it only after `Accepted`, and only permits `DropBestEffort` for
/// optional detail. This keeps encoder/admission retry semantics local.
/// Submits the current record through the sink owner and removes it only
/// after `Accepted`. This keeps encoder/admission retry semantics local
/// for every severity.
pub fn submit_next<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
@@ -246,13 +239,7 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
TransportAdmission::Accepted => {
let _ = self.pop();
}
TransportAdmission::DropBestEffort
if record.delivery() == DeliveryClass::BestEffort =>
{
let _ = self.best_effort.pop();
HubHealth::increment(&mut self.health.best_effort_transport_dropped);
}
TransportAdmission::Retry | TransportAdmission::DropBestEffort => {}
TransportAdmission::Retry => {}
}
Some(result)
}
@@ -262,27 +249,30 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> EventHub<CRITICAL, BEST_EF
}
}
fn delivery_for(severity: Severity) -> DeliveryClass {
if severity <= Severity::Error {
DeliveryClass::Critical
} else {
DeliveryClass::BestEffort
}
}
#[cfg(test)]
mod tests {
use super::{EventHub, PublishOutcome, TransportAdmission};
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
use crate::{
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogText,
RecordChannel, Severity, StaticError,
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
LogText, RecordChannel, Severity, StaticError,
};
#[test]
fn errors_bypass_filter_but_debug_does_not() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
fn sink_policy_applies_to_errors_and_ordinary_logs() {
let mut disabled = LogSinkQueue::<1, 1>::new(LogFilter::disabled());
assert_eq!(
hub.publish_text(
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")
@@ -290,43 +280,110 @@ mod tests {
PublishOutcome::Filtered
);
assert_eq!(
hub.publish_text(
queue.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("error")
),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
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 static_messages_and_repeats_are_bounded() {
static FAULT: StaticError = StaticError::new("driver::Fault");
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
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!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
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!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Coalesced
queue.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
);
let record = hub.peek().unwrap();
assert_eq!(record.occurrences, 2);
assert!(record.flags().contains(LogFlags::COALESCED));
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.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 = EventHub::<1, 1>::new(LogFilter::default());
let mut hub = LogSinkQueue::<1, 1>::new(LogFilter::up_to(Severity::Debug));
assert_eq!(
hub.publish_error_static(LogFlags::NONE, &FAULT),
PublishOutcome::Queued
@@ -341,7 +398,7 @@ mod tests {
#[test]
fn error_identity_and_detail_are_independent_record_components() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
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"),
@@ -360,15 +417,15 @@ mod tests {
}
#[test]
fn retry_retains_critical_and_only_best_effort_can_be_discarded() {
let mut hub = EventHub::<1, 1>::new(LogFilter::default());
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::DropBestEffort, ())),
hub.submit_next(|_| (TransportAdmission::Retry, ())),
Some(())
);
assert_eq!(hub.peek().unwrap().delivery(), DeliveryClass::Critical);
@@ -384,50 +441,30 @@ mod tests {
LogText::from_message("detail"),
);
assert_eq!(
hub.submit_next(|_| (TransportAdmission::DropBestEffort, ())),
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());
assert_eq!(hub.health().best_effort_transport_dropped, 1);
}
#[test]
fn admission_losses_are_retained_as_local_saturating_accounting() {
let mut hub = EventHub::<1, 1>::new(LogFilter::new(Severity::Info));
let _ = hub.publish_text(
Severity::Debug,
LogFlags::NONE,
LogText::from_message("filtered"),
);
let _ = hub.publish_text(
fn independent_sink_policies_do_not_suppress_each_other() {
let record = LogRecord::new(
Severity::Info,
LogFlags::NONE,
LogText::from_message("first optional"),
);
let _ = hub.publish_text(
Severity::Info,
LogFlags::NONE,
LogText::from_message("newer optional"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("first error"),
);
let _ = hub.publish_text(
Severity::Error,
LogFlags::NONE,
LogText::from_message("second error"),
);
assert_eq!(
hub.health(),
super::HubHealth {
filtered: 1,
coalesced: 0,
best_effort_replaced: 1,
critical_rejected: 1,
best_effort_transport_dropped: 0,
}
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));
}
}