refactor(firmware): simplify configuration and diagnostics
This commit is contained in:
1 parent
ba9c97ed3b
commit
fdf82a5114
96 files changed
+1605
-2765
No files matched your search
Generated
+42
-38
@@ -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",
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ pub(super) struct Session {
|
||||
pub(super) revision: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, kaptan_err_propagate::KaptanError)]
|
||||
enum CommitTransactionError<E> {
|
||||
#[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<C, S>(
|
||||
&mut self,
|
||||
manager: &mut ConfigManager<C, S>,
|
||||
command: ConfigCommand,
|
||||
request_id: u32,
|
||||
session: Session,
|
||||
) -> Result<ConfigReply, CommitTransactionError<S::Error>>
|
||||
where
|
||||
C: ConfigModel,
|
||||
S: ConfigStore<C>,
|
||||
{
|
||||
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<u32> {
|
||||
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<Cell<u32>>);
|
||||
|
||||
impl ConfigStore<Candidate> for FailingStore {
|
||||
type Error = ();
|
||||
|
||||
fn load(&mut self) -> Result<Option<Candidate>, 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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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<E> {
|
||||
/// 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<E> {
|
||||
#[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<E> {
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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"] }
|
||||
@@ -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::<MavMessage>());
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<BusError> {
|
||||
/// 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,
|
||||
}
|
||||
@@ -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<BusError> {
|
||||
/// 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<BusError> {
|
||||
#[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,
|
||||
}
|
||||
@@ -55,11 +55,7 @@ impl<Clock: MonotonicClock, Bus: I2cBus> LidarLiteV3Service<Clock, Bus> {
|
||||
&mut self,
|
||||
completion: I2cCompletion,
|
||||
) -> LidarLiteV3ServiceEvent<Bus::StartError> {
|
||||
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<Clock: MonotonicClock, Bus: I2cBus> LidarLiteV3Service<Clock, Bus> {
|
||||
}
|
||||
}
|
||||
|
||||
fn service_event<BusError>(event: LidarLiteV3Event<BusError>) -> LidarLiteV3ServiceEvent<BusError> {
|
||||
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<BusError> {
|
||||
/// 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,
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -13,4 +13,3 @@ pub use active::{
|
||||
TimingSettings, VehicleCapabilities, VehicleProfile,
|
||||
};
|
||||
pub use candidate::{CandidateProfile, FORMAT_VERSION};
|
||||
pub use mavlink_logging::MavlinkLoggingSettings;
|
||||
@@ -33,13 +33,7 @@ where
|
||||
store: S,
|
||||
) -> Result<Self, StartupError<S::Error>> {
|
||||
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"),
|
||||
));
|
||||
|
||||
@@ -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"
|
||||
@@ -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<proc_macro2::TokenStream, Error> {
|
||||
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(
|
||||
<Self as ::kaptan_log::ErrorReport>::static_error(self).path(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl #error_impl_generics ::core::error::Error
|
||||
for #ident #error_type_generics #error_where_clause {}
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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<T: core::error::Error>() {}
|
||||
assert_error::<Generic<'_, u8>>();
|
||||
assert_eq!(
|
||||
Generic::<u8>::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",
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
use kaptan_log_derive::KaptanError;
|
||||
|
||||
#[derive(KaptanError)]
|
||||
struct NotAnError {
|
||||
code: u8,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: KaptanError can only be derived for enums, not structs
|
||||
--> tests/ui/struct.rs:4:8
|
||||
|
|
||||
4 | struct NotAnError {
|
||||
| ^^^^^^^^^^
|
||||
@@ -1,8 +0,0 @@
|
||||
use kaptan_log_derive::KaptanError;
|
||||
|
||||
#[derive(KaptanError)]
|
||||
union NotAnError {
|
||||
code: u8,
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: KaptanError can only be derived for enums, not unions
|
||||
--> tests/ui/union.rs:4:7
|
||||
|
|
||||
4 | union NotAnError {
|
||||
| ^^^^^^^^^^
|
||||
@@ -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::<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::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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<Self> {
|
||||
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<Self, CrashRecordError> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<GLOBAL_INGRESS_CAPACITY> = 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<const N: usize> {
|
||||
entries: UnsafeCell<[Option<LogRecord>; N]>,
|
||||
head: UnsafeCell<usize>,
|
||||
len: UnsafeCell<usize>,
|
||||
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<const N: usize> Sync for LogIngress<N> {}
|
||||
|
||||
impl FacadeCounters {
|
||||
impl<const N: usize> LogIngress<N> {
|
||||
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<const N: usize> {
|
||||
entries: [Option<IngressRecord>; N],
|
||||
head: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<const N: usize> IngressQueue<N> {
|
||||
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<IngressRecord> {
|
||||
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<const N: usize> {
|
||||
/// 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<Option<IngressRecord>>,
|
||||
critical_busy: AtomicBool,
|
||||
queue: UnsafeCell<IngressQueue<N>>,
|
||||
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<const N: usize> Sync for FacadeIngress<N> {}
|
||||
|
||||
impl<const N: usize> FacadeIngress<N> {
|
||||
/// 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);
|
||||
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;
|
||||
}
|
||||
match self.with_queue(|queue| queue.push(record)) {
|
||||
Some(true) => {}
|
||||
Some(false) => FacadeCounters::increment(&self.counters.full),
|
||||
None => FacadeCounters::increment(&self.counters.contended),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_critical(&self, record: IngressRecord) {
|
||||
match self.with_critical(|slot| {
|
||||
if slot.is_none() {
|
||||
*slot = Some(record);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
Some(true) => {}
|
||||
Some(false) => FacadeCounters::increment(&self.counters.full),
|
||||
None => FacadeCounters::increment(&self.counters.contended),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-local bounded-ingress accounting.
|
||||
pub fn health(&self) -> FacadeHealth {
|
||||
self.counters.snapshot()
|
||||
}
|
||||
|
||||
/// 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<LogRecord> {
|
||||
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<R>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut Option<IngressRecord>) -> R,
|
||||
) -> Option<R> {
|
||||
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<R>(&self, operation: impl FnOnce(&mut IngressQueue<N>) -> R) -> Option<R> {
|
||||
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<const N: usize> Default for FacadeIngress<N> {
|
||||
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<const N: usize> {
|
||||
ingress: &'static FacadeIngress<N>,
|
||||
forbidden_context: fn() -> bool,
|
||||
}
|
||||
|
||||
impl<const N: usize> GlobalLogger<N> {
|
||||
/// Creates a logger tied to one static bounded ingress.
|
||||
pub const fn new(ingress: &'static FacadeIngress<N>, 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<const N: usize> Log for GlobalLogger<N> {
|
||||
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);
|
||||
if level != Level::Error {
|
||||
return;
|
||||
}
|
||||
let severity = severity(record.level());
|
||||
let flags = if record.level() == Level::Trace {
|
||||
LogFlags::TRACE
|
||||
} else {
|
||||
LogFlags::NONE
|
||||
let Some(oldest_best_effort) = (0..*len).find(|offset| {
|
||||
entries[(*head + *offset) % N]
|
||||
.is_some_and(|pending| pending.level() != Level::Error)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let body = if severity <= Severity::Error {
|
||||
IngressBody::Error {
|
||||
path: ErrorPath::from_message(record.target()),
|
||||
additional_information: ErrorAdditionalInformation::from_arguments(format_args!(
|
||||
"{}",
|
||||
record.args()
|
||||
)),
|
||||
}
|
||||
} 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,
|
||||
Self::remove(entries, *head, len, oldest_best_effort);
|
||||
entries[(*head + *len) % N] = Some(record);
|
||||
*len += 1;
|
||||
});
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
fn remove(entries: &mut [Option<LogRecord>; 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;
|
||||
}
|
||||
|
||||
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,
|
||||
fn drain_one<R>(&self, consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||
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<R>(
|
||||
&self,
|
||||
operation: impl FnOnce(&mut [Option<LogRecord>; N], &mut usize, &mut usize) -> R,
|
||||
) -> Option<R> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<R>(consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Severity> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<const N: usize> {
|
||||
|
||||
impl<const N: usize> Lane<N> {
|
||||
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<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()
|
||||
}
|
||||
@@ -80,18 +72,16 @@ impl<const N: usize> Lane<N> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<const CRITICAL: usize, const BEST_EFFORT: usize> {
|
||||
filter: LogFilter,
|
||||
filter: LevelFilter,
|
||||
critical: Lane<CRITICAL>,
|
||||
best_effort: Lane<BEST_EFFORT>,
|
||||
}
|
||||
|
||||
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 {
|
||||
/// Creates a sink queue with one immutable boot-active filter.
|
||||
pub const fn new(filter: LevelFilter) -> Self {
|
||||
Self {
|
||||
filter,
|
||||
critical: Lane::new(),
|
||||
@@ -100,147 +90,51 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BES
|
||||
}
|
||||
|
||||
/// Immutable sink policy.
|
||||
pub const fn filter(&self) -> 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<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
|
||||
/// 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<E: ErrorReport>(&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<LogRecord> {
|
||||
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<R>(
|
||||
&mut self,
|
||||
submit: impl FnOnce(&LogRecord) -> (TransportAdmission, R),
|
||||
) -> Option<R> {
|
||||
let record = self.peek()?;
|
||||
let (admission, result) = submit(&record);
|
||||
match admission {
|
||||
TransportAdmission::Accepted => {
|
||||
if admission == TransportAdmission::Accepted {
|
||||
let _ = self.pop();
|
||||
}
|
||||
TransportAdmission::Retry => {}
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
@@ -252,219 +146,24 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BES
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
|
||||
use crate::{
|
||||
DeliveryClass, ErrorAdditionalInformation, ErrorPath, LogFilter, LogFlags, LogRecord,
|
||||
LogText, RecordChannel, Severity, StaticError,
|
||||
};
|
||||
use crate::{DeliveryClass, Level, LevelFilter, LogFlags, LogText};
|
||||
|
||||
#[test]
|
||||
fn sink_policy_applies_to_errors_and_ordinary_logs() {
|
||||
let mut disabled = LogSinkQueue::<1, 1>::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.peek().unwrap().delivery(), DeliveryClass::Critical);
|
||||
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.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());
|
||||
}
|
||||
}
|
||||
@@ -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::<Severity>(), 1);
|
||||
fn levels_are_one_byte_and_filter_conventionally() {
|
||||
assert_eq!(size_of::<Level>(), 1);
|
||||
assert_eq!(size_of::<LevelFilter>(), 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));
|
||||
}
|
||||
}
|
||||
@@ -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)+),
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -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<StaticError>,
|
||||
}
|
||||
|
||||
const COALESCED_TAG: usize = 1;
|
||||
const _: () = assert!(core::mem::align_of::<StaticError>() > 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));
|
||||
}
|
||||
}
|
||||
@@ -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<E: ErrorReport>(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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<const N: usize> {
|
||||
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<LogRecord>` 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<MAXIMUM_LOG_TEXT_BYTES>;
|
||||
/// Transport-neutral error identity path.
|
||||
pub type ErrorPath = BoundedText<MAXIMUM_ERROR_PATH_BYTES>;
|
||||
/// Transport-neutral optional error detail.
|
||||
pub type ErrorAdditionalInformation = BoundedText<MAXIMUM_ERROR_ADDITIONAL_INFORMATION_BYTES>;
|
||||
|
||||
impl<const N: usize> BoundedText<N> {
|
||||
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<const N: usize> BoundedText<N> {
|
||||
}
|
||||
|
||||
/// 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<const N: usize> Default for BoundedText<N> {
|
||||
impl Default for LogText {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> fmt::Write for BoundedText<N> {
|
||||
impl fmt::Write for LogText {
|
||||
fn write_str(&mut self, message: &str) -> fmt::Result {
|
||||
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
|
||||
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);
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let used = usize::from(self.length);
|
||||
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::<LogText>(), MAXIMUM_LOG_TEXT_BYTES + 2);
|
||||
assert_eq!(
|
||||
core::mem::size_of::<ErrorPath>(),
|
||||
MAXIMUM_ERROR_PATH_BYTES + 2
|
||||
);
|
||||
assert_eq!(
|
||||
core::mem::size_of::<ErrorAdditionalInformation>(),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,4 @@ version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
kaptan-log = { path = "../kaptan-log" }
|
||||
kaptan-err-propagate = { path = "../kaptan-err-propagate" }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -5,5 +5,4 @@
|
||||
<include>kaptan/config.xml</include>
|
||||
<include>kaptan/log.xml</include>
|
||||
<include>kaptan/error.xml</include>
|
||||
<include>kaptan/shutdown.xml</include>
|
||||
</mavlink>
|
||||
@@ -3,25 +3,11 @@
|
||||
<version>3</version>
|
||||
<dialect>0</dialect>
|
||||
<!-- Kaptan-reserved development block. Register these IDs before release. -->
|
||||
<enums>
|
||||
<enum name="KAPTAN_ERROR_FLAGS" bitmask="true">
|
||||
<entry value="2" name="KAPTAN_ERROR_FLAG_TRUNCATED">
|
||||
<description>The path or additional-information UTF-8 prefix was truncated to its bounded on-wire field.</description>
|
||||
</entry>
|
||||
<entry value="4" name="KAPTAN_ERROR_FLAG_COALESCED">
|
||||
<description>One or more later observations from the same source mailbox were folded while this first error was pending.</description>
|
||||
</entry>
|
||||
</enum>
|
||||
</enums>
|
||||
<messages>
|
||||
<message id="51021" name="KAPTAN_ERROR">
|
||||
<description>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.</description>
|
||||
<field type="uint8_t" name="level">MAV_SEVERITY-compatible error level from Emergency=0 through Error=3.</field>
|
||||
<field type="uint8_t" name="flags" enum="KAPTAN_ERROR_FLAGS">Known bits are TRUNCATED=2 and COALESCED=4. Receivers ignore unknown future bits.</field>
|
||||
<field type="uint8_t" name="path_length">Number of valid UTF-8 bytes in path; no more than 96.</field>
|
||||
<field type="uint8_t" name="additional_information_length">Number of valid UTF-8 bytes in additional_information; no more than 64.</field>
|
||||
<field type="uint8_t[96]" name="path">Bounded UTF-8 derived or explicit error path. Bytes after path_length are zero.</field>
|
||||
<field type="uint8_t[64]" name="additional_information">Bounded UTF-8 optional error context. Bytes after additional_information_length are zero.</field>
|
||||
<description>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.</description>
|
||||
<field type="uint16_t" name="error_id">Stable explicitly assigned error catalog ID. Zero is reserved.</field>
|
||||
<field type="uint8_t[96]" name="raised_at">Zero-padded compile-time file:line of the propagation boundary that reported the error.</field>
|
||||
</message>
|
||||
</messages>
|
||||
</mavlink>
|
||||
@@ -6,7 +6,7 @@
|
||||
<enums>
|
||||
<enum name="KAPTAN_LOG_FLAGS" bitmask="true">
|
||||
<entry value="1" name="KAPTAN_LOG_FLAG_TRACE">
|
||||
<description>Record originated at Rust trace level and uses MAVLink DEBUG severity.</description>
|
||||
<description>Record originated at Kaptan trace level and uses MAVLink DEBUG severity.</description>
|
||||
</entry>
|
||||
<entry value="2" name="KAPTAN_LOG_FLAG_TRUNCATED">
|
||||
<description>Message text was truncated to the bounded on-wire buffer.</description>
|
||||
@@ -15,8 +15,8 @@
|
||||
</enums>
|
||||
<messages>
|
||||
<message id="51020" name="KAPTAN_LOG">
|
||||
<description>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.</description>
|
||||
<field type="uint8_t" name="level">MAV_SEVERITY-compatible level. Rust TRACE uses DEBUG plus the TRACE flag.</field>
|
||||
<description>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.</description>
|
||||
<field type="uint8_t" name="level">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.</field>
|
||||
<field type="uint8_t" name="flags" enum="KAPTAN_LOG_FLAGS">Known bits are TRACE=1 and TRUNCATED=2. Receivers ignore unknown future bits.</field>
|
||||
<field type="uint8_t" name="message_length">Number of valid UTF-8 bytes in message; at most 160.</field>
|
||||
<field type="uint8_t[160]" name="message">Bounded UTF-8 message bytes. Bytes after message_length are zero.</field>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<mavlink>
|
||||
<version>3</version>
|
||||
<dialect>0</dialect>
|
||||
<!-- Kaptan-reserved development block. Register these IDs before release. -->
|
||||
<enums>
|
||||
<enum name="KAPTAN_SHUTDOWN_REASON">
|
||||
<entry value="1" name="KAPTAN_SHUTDOWN_REASON_USER">
|
||||
<description>A user initiated the completed controlled firmware shutdown.</description>
|
||||
</entry>
|
||||
<entry value="2" name="KAPTAN_SHUTDOWN_REASON_SAFETY">
|
||||
<description>The safety owner initiated the completed controlled firmware shutdown.</description>
|
||||
</entry>
|
||||
</enum>
|
||||
</enums>
|
||||
<messages>
|
||||
<message id="51022" name="KAPTAN_SHUTDOWN">
|
||||
<description>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.</description>
|
||||
<field type="uint8_t" name="reason" enum="KAPTAN_SHUTDOWN_REASON">Typed shutdown reason.</field>
|
||||
</message>
|
||||
</messages>
|
||||
</mavlink>
|
||||
@@ -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<MemoryError, EngineError> {
|
||||
/// 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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<F722Mtf01>) {
|
||||
}
|
||||
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<C, I, const MAX_PAYLOAD_BYTES: usize> F722MavlinkState<C, I, MAX_PAYLOAD_BYTES>
|
||||
@@ -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<E: ErrorReport>(event: LidarLiteV3ServiceEvent<E>) {
|
||||
#[kaptan_err_propagate::propagate]
|
||||
fn publish_lidar_event(
|
||||
event: LidarLiteV3ServiceEvent<I2c1Error>,
|
||||
) -> Result<(), LidarError<I2c1Error>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8>,
|
||||
pub(super) optical_flow_hz: Option<u8>,
|
||||
@@ -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<C: Config>(candidate: &C, field: FieldId) -> Result<u8, ConfigVio
|
||||
}
|
||||
}
|
||||
|
||||
fn required_bool<C: Config>(candidate: &C, field: FieldId) -> Result<bool, ConfigViolation> {
|
||||
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<usize, ConfigViolation> {
|
||||
let rate = usize::from(rate);
|
||||
if rate == 0 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<E: ErrorReport>(event: Mpu6000ServiceEvent<E>) -> ImuEvent {
|
||||
fn mpu_event(event: Mpu6000ServiceEvent<Spi1Error>) -> 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<E: ErrorReport>(event: Icm42688ServiceEvent<E>, mount: ImuMount) -> ImuEvent {
|
||||
fn icm_event(event: Icm42688ServiceEvent<Spi3Error>, 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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<target_config::F722UserRuntime>(),
|
||||
10,
|
||||
9,
|
||||
"runtime projection must retain narrow board-native scalar widths"
|
||||
);
|
||||
let mut candidate = UserConfigModel::<TestUserConfig, F722ImmutableConfig>::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]
|
||||
|
||||
@@ -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<MaybeUninit<ErrorRecord>>"));
|
||||
assert!(errors.contains("compare_exchange"));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for LogSeverity {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
fn serialize_level<S>(level: &Level, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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")
|
||||
serializer.serialize_str(level.as_str())
|
||||
}
|
||||
|
||||
/// 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<Level> {
|
||||
if trace && level != 7 {
|
||||
return 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LogObservation> {
|
||||
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<const N: usize>(bytes: &[u8; N], length: u8) -> String {
|
||||
let end = usize::from(length).min(N);
|
||||
String::from_utf8_lossy(&bytes[..end]).into_owned()
|
||||
fn bounded_text<const N: usize>(bytes: &[u8; N], length: u8) -> Option<String> {
|
||||
let text = core::str::from_utf8(bytes.get(..usize::from(length))?).ok()?;
|
||||
Some(text.into())
|
||||
}
|
||||
|
||||
fn packet_text<const N: usize>(bytes: &[u8; N], length: u8) -> String {
|
||||
bounded_text(bytes, length).unwrap_or_else(|| format!("INVALID TEXT (length {length})"))
|
||||
}
|
||||
|
||||
fn zero_padded_text<const N: usize>(bytes: &[u8; N]) -> Option<String> {
|
||||
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<const N: usize>(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 [
|
||||
|
||||
@@ -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';
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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<SITL_LOG_INGRESS_CAPACITY> = FacadeIngress::new();
|
||||
static SITL_LOGGER: GlobalLogger<SITL_LOG_INGRESS_CAPACITY> =
|
||||
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<LogRecord> {
|
||||
SITL_LOG_INGRESS.take()
|
||||
/// Visits one transport-neutral event for composition-level sink fanout.
|
||||
pub(super) fn drain_one<R>(consume: impl FnOnce(&LogRecord) -> R) -> Option<R> {
|
||||
kaptan_log::drain_one(consume)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DiagnosticPayload> {
|
||||
if let Some(record) = logging::take() {
|
||||
self.runtime.publish_log_event(record);
|
||||
}
|
||||
pub fn pending_log_payload(&mut self) -> Option<KAPTAN_LOG_DATA> {
|
||||
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<SitlConfigRuntime, String> {
|
||||
logging::install_log_facade();
|
||||
let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store)
|
||||
.map_err(|error| format!("configuration recovery failed: {error:?}"))?;
|
||||
Ok(SitlConfigRuntime {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user