feat(mavlink): adopt the kaptan-native dialect

This commit is contained in:
Onur Özkan committed 2026-08-24 00:56:06 +03:00
1 parent 1c90870634
commit 5f91465e66
87 files changed
+7648 -6765

No files matched your search

+3 -3
View File
@@ -24,9 +24,9 @@ jobs:
python3 tools/sitl/world-structure-test.py
cargo check --locked -p kaptan-mavlink --no-default-features
cargo check --locked -p kaptan-log --no-default-features
cargo check --locked -p kaptan-log-mavlink --no-default-features
cargo test --locked -p kaptan-log --features facade
cargo test --locked -p kaptan-log-derive -p kaptan-log-mavlink
cargo check --locked -p kaptan-diagnostics-mavlink
cargo test --locked -p kaptan-log
cargo test --locked -p kaptan-diagnostics-mavlink
if rg -n '(^use log::|\blog::)' platforms/stm32f7/src/f722; then
echo 'the standard log facade is forbidden in F722 IRQ/panic paths'
exit 1
+10 -28
View File
@@ -171,21 +171,12 @@ dependencies = [
"trybuild",
]
[[package]]
name = "kaptan-config-mavlink"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-err-reportable",
"kaptan-mavlink",
]
[[package]]
name = "kaptan-configurator"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-config-mavlink",
"kaptan-file-mavlink",
"kaptan-log",
"kaptan-mavlink",
"libc",
@@ -211,6 +202,7 @@ dependencies = [
"kaptan-mavlink",
"kaptan-sensor",
"kaptan-time",
"mavlink-core",
]
[[package]]
@@ -220,7 +212,6 @@ dependencies = [
"kaptan-err-reportable",
"kaptan-log",
"kaptan-mavlink",
"mavlink",
]
[[package]]
@@ -255,6 +246,13 @@ dependencies = [
"trybuild",
]
[[package]]
name = "kaptan-file-mavlink"
version = "0.1.0"
dependencies = [
"kaptan-mavlink",
]
[[package]]
name = "kaptan-flight-config"
version = "0.1.0"
@@ -320,7 +318,6 @@ dependencies = [
"kaptan-sensor",
"kaptan-time",
"kaptan-tx",
"mavlink",
"mavlink-bindgen",
"mavlink-core",
"num-derive",
@@ -357,7 +354,6 @@ name = "kaptan-sitl"
version = "0.1.0"
dependencies = [
"kaptan-config",
"kaptan-config-mavlink",
"kaptan-control",
"kaptan-diagnostics-mavlink",
"kaptan-err-reportable",
@@ -369,7 +365,6 @@ dependencies = [
"kaptan-navigation",
"kaptan-safety",
"kaptan-time",
"mavlink",
"minicbor",
]
@@ -391,19 +386,6 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "mavlink"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef539c358c31f69d47816dac709eebbcb733166ae1bcdeeb4463383c042e3e7c"
dependencies = [
"bitflags",
"mavlink-bindgen",
"mavlink-core",
"num-derive",
"num-traits",
]
[[package]]
name = "mavlink-bindgen"
version = "0.18.0"
@@ -504,12 +486,12 @@ dependencies = [
"cortex-m",
"cortex-m-rt",
"kaptan-config",
"kaptan-config-mavlink",
"kaptan-data-channel",
"kaptan-diagnostics-mavlink",
"kaptan-dma",
"kaptan-drivers",
"kaptan-err-reportable",
"kaptan-file-mavlink",
"kaptan-i2c",
"kaptan-log",
"kaptan-mavlink",
+1 -1
View File
@@ -6,7 +6,7 @@ members = [
"libs/kaptan-control",
"libs/kaptan-config",
"libs/kaptan-config-derive",
"libs/kaptan-config-mavlink",
"libs/kaptan-file-mavlink",
"libs/kaptan-data-channel",
"libs/kaptan-dma",
"libs/kaptan-drivers",
+3 -3
View File
@@ -26,11 +26,11 @@ struct MavlinkConfig {
struct TelemetryConfig {
#[config(
id = 103,
key = "telemetry.raw_imu_hz",
key = "telemetry.imu_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
raw_imu_hz: u8,
imu_hz: u8,
#[config(
id = 104,
key = "telemetry.heartbeat_hz",
@@ -61,7 +61,7 @@ impl F7Board for GeprcF722V2 {
log_level_filter: LevelFilter::Info,
},
telemetry: TelemetryConfig {
raw_imu_hz: 20,
imu_hz: 20,
heartbeat_hz: 2,
},
};
@@ -31,11 +31,11 @@ struct MavlinkConfig {
struct TelemetryConfig {
#[config(
id = 103,
key = "telemetry.raw_imu_hz",
key = "telemetry.imu_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
raw_imu_hz: u8,
imu_hz: u8,
#[config(
id = 104,
key = "telemetry.heartbeat_hz",
@@ -80,7 +80,7 @@ impl F7Board for KakuteF7MiniV3 {
log_level_filter: LevelFilter::Info,
},
telemetry: TelemetryConfig {
raw_imu_hz: 20,
imu_hz: 20,
heartbeat_hz: 2,
range_hz: 20,
optical_flow_hz: 20,
@@ -31,6 +31,7 @@ fn expand_scalars(
let mut descriptors = Vec::new();
let mut reads = Vec::new();
let mut writes = Vec::new();
let mut snapshot_widths = Vec::new();
for field in &fields {
let FieldMetadata {
descriptor,
@@ -42,6 +43,7 @@ fn expand_scalars(
let ty = &field.ty;
let field_ident = &field.ident;
let (kind, read, decode, out_of_domain_rule) = if !field.enum_backed {
snapshot_widths.push(quote! { <#ty as #crate_path::ConfigScalar>::KIND.width() });
(
quote! { <#ty as #crate_path::ConfigScalar>::KIND },
quote! { #crate_path::ConfigScalar::into_config_value(self.#field_ident) },
@@ -49,16 +51,13 @@ fn expand_scalars(
quote! { #crate_path::ValidationRule::AboveMaximum },
)
} else {
snapshot_widths.push(quote! { 1usize });
(
quote! { #crate_path::ValueKind::U32 },
quote! {
#crate_path::ConfigValue::U32(u32::from(self.#field_ident.wire_value()))
},
quote! { #crate_path::ValueKind::U8 },
quote! { #crate_path::ConfigValue::U8(self.#field_ident.wire_value()) },
quote! {
match value {
#crate_path::ConfigValue::U32(value) => u8::try_from(value)
.ok()
.and_then(<#ty>::try_from_wire)
#crate_path::ConfigValue::U8(value) => <#ty>::try_from_wire(value)
.ok_or(#crate_path::ScalarValueError::OutOfDomain),
value => Err(#crate_path::ScalarValueError::WrongKind(value.kind())),
}
@@ -122,6 +121,7 @@ fn expand_scalars(
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
const FIELD_COUNT: usize = Self::DESCRIPTORS.len();
const SNAPSHOT_VALUE_BYTES: usize = 0 #(+ #snapshot_widths)*;
const SCHEMA_ID: [u8; 16] =
#crate_path::validation::__schema_id(Self::DESCRIPTORS);
@@ -168,6 +168,7 @@ fn expand_children(
let mut reads = Vec::new();
let mut writes = Vec::new();
let mut counts = Vec::new();
let mut snapshot_widths = Vec::new();
let mut schema_ids = Vec::new();
let mut descriptor_lookups = Vec::new();
for field in &fields {
@@ -194,6 +195,7 @@ fn expand_children(
}
});
counts.push(quote! { <#ty as #crate_path::Config>::FIELD_COUNT });
snapshot_widths.push(quote! { <#ty as #crate_path::Config>::SNAPSHOT_VALUE_BYTES });
schema_ids.push(quote! { <#ty as #crate_path::Config>::SCHEMA_ID });
descriptor_lookups.push(quote! {
if index < <#ty as #crate_path::Config>::FIELD_COUNT {
@@ -207,6 +209,7 @@ fn expand_children(
Ok(quote! {
impl #impl_generics #crate_path::Config for #ident #type_generics #where_clause {
const FIELD_COUNT: usize = 0 #(+ #counts)*;
const SNAPSHOT_VALUE_BYTES: usize = 0 #(+ #snapshot_widths)*;
const SCHEMA_ID: [u8; 16] =
#crate_path::validation::__combine_schema_ids(&[#(#schema_ids),*]);
@@ -282,7 +285,7 @@ fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Res
)
} else {
(
quote! { #crate_path::ValueKind::U32 },
quote! { #crate_path::ValueKind::U8 },
quote! { <#ty>::CONFIG_ENUM_OPTIONS },
)
};
@@ -328,7 +331,9 @@ fn config_value_constructor_tokens(crate_path: &syn::Path, ty: &syn::Type) -> Re
let value = match segment.ident.to_string().as_str() {
"bool" => quote! { #crate_path::ConfigValue::Bool },
"i32" => quote! { #crate_path::ConfigValue::I32 },
"u8" | "u16" | "u32" => quote! { #crate_path::ConfigValue::U32 },
"u8" => quote! { #crate_path::ConfigValue::U8 },
"u16" => quote! { #crate_path::ConfigValue::U16 },
"u32" => quote! { #crate_path::ConfigValue::U32 },
"u64" => quote! { #crate_path::ConfigValue::U64 },
"f32" => quote! { #crate_path::ConfigValue::F32 },
"f64" => quote! { #crate_path::ConfigValue::F64 },
@@ -77,6 +77,8 @@ struct CompactSection {
endpoint: u8,
#[config(enum, id = 21, key = "compact.mode", unit = "mode")]
mode: TestMode,
#[config(id = 22, key = "compact.port", unit = "id", range(min = 1))]
port: u16,
}
#[test]
@@ -115,14 +117,16 @@ fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
let mut section = CompactSection {
endpoint: 7,
mode: TestMode::Quiet,
port: 4861,
};
assert_eq!(CompactSection::SNAPSHOT_VALUE_BYTES, 1 + 1 + 2);
assert_eq!(
section.field_value(FieldId::new(20)),
Some(ConfigValue::U32(7))
Some(ConfigValue::U8(7))
);
assert_eq!(
CompactSection::DESCRIPTORS[0].minimum,
Some(ConfigValue::U32(1))
Some(ConfigValue::U8(1))
);
assert_eq!(CompactSection::DESCRIPTORS[0].maximum, None);
assert_eq!(
@@ -134,19 +138,27 @@ fn narrow_scalars_and_compact_enums_keep_native_types_and_generic_metadata() {
assert_eq!(TestMode::try_from_wire(2), None);
section
.set_field(FieldId::new(21), ConfigValue::U32(3))
.set_field(FieldId::new(21), ConfigValue::U8(3))
.unwrap();
assert_eq!(section.mode, TestMode::Loud);
assert!(matches!(
section.set_field(FieldId::new(21), ConfigValue::U32(2)),
section.set_field(FieldId::new(21), ConfigValue::U8(2)),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::InvalidEnumValue
));
assert!(matches!(
section.set_field(FieldId::new(20), ConfigValue::U32(256)),
section.set_field(FieldId::new(20), ConfigValue::U8(0)),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::AboveMaximum
if issue.rule() == ValidationRule::BelowMinimum
));
assert_eq!(
section.field_value(FieldId::new(22)),
Some(ConfigValue::U16(4861))
);
assert_eq!(
CompactSection::DESCRIPTORS[2].kind,
kaptan_config::ValueKind::U16
);
}
#[test]
@@ -184,6 +196,7 @@ fn aggregate_routes_writes_and_defers_declared_post_validation() {
#[test]
fn aggregate_derives_its_flattened_descriptor_order() {
assert_eq!(Aggregate::SNAPSHOT_VALUE_BYTES, 4 * 4);
let descriptors: Vec<_> = (0..Aggregate::FIELD_COUNT)
.map(|index| Aggregate::descriptor(index).unwrap())
.collect();
@@ -1,220 +0,0 @@
//! Transport-neutral configuration commands.
use kaptan_config::{ConfigValue, FieldId};
/// Exact vehicle endpoint selected by a configuration request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Target {
/// MAVLink system identity.
system: u8,
/// MAVLink component identity.
component: u8,
}
/// Exact MAVLink endpoint that owns a configuration transaction lease.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Principal(u16);
impl Principal {
/// Creates a principal from one exact source system/component pair.
pub const fn new(system: u8, component: u8) -> Self {
assert!(
system != 0 && component != 0,
"configuration principal must be exact"
);
Self(u16::from_be_bytes([system, component]))
}
/// Parses an exact source principal from an untrusted transport.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self(u16::from_be_bytes([system, component])))
}
}
}
/// Selects the configuration snapshot read by a value request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValueSource {
/// Immutable boot-active snapshot.
Active,
/// Last durably committed candidate.
Committed,
/// The caller's open RAM transaction.
Transaction,
}
impl Target {
/// Creates an exact system/component target.
pub const fn new(system: u8, component: u8) -> Self {
assert!(
system != 0 && component != 0,
"configuration target must be exact"
);
Self { system, component }
}
/// Parses an exact non-broadcast target from an untrusted transport.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self { system, component })
}
}
}
/// One command accepted by the Kaptan configuration microservice.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ConfigCommand {
/// Reads protocol/schema metadata before descriptor and value requests.
InfoRequest {
/// Client correlation identity.
request_id: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Reads one schema descriptor by stable index.
DescriptorRequest {
/// Client correlation identity.
request_id: u32,
/// Stable descriptor index.
index: u16,
/// Exact addressed endpoint.
target: Target,
},
/// Reads one named choice for one descriptor.
EnumOptionRequest {
/// Client correlation identity.
request_id: u32,
/// Stable index of the owning descriptor.
descriptor_index: u16,
/// Stable enum-option index.
option_index: u8,
/// Exact addressed endpoint.
target: Target,
},
/// Reads one typed scalar from an immutable or transaction snapshot.
ValueRequest {
/// Client correlation identity.
request_id: u32,
/// Lease-bound session for transaction reads, otherwise zero.
session_id: u32,
/// Stable field identity.
field: FieldId,
/// Selected snapshot.
source: ValueSource,
/// Exact addressed endpoint.
target: Target,
},
/// Starts an edit transaction from the latest committed configuration.
Begin {
/// Client correlation identity.
request_id: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Sets one typed scalar in the open transaction.
Set {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Stable schema-local field identity.
field: FieldId,
/// Typed value.
value: ConfigValue,
/// Exact addressed endpoint.
target: Target,
},
/// Validates the complete candidate without persistence.
Validate {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Validates and atomically persists the complete candidate.
Commit {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Optimistic transaction revision.
expected_revision: u32,
/// Exact addressed endpoint.
target: Target,
},
/// Discards the RAM transaction.
Abort {
/// Client correlation identity.
request_id: u32,
/// Lease-bound transaction identity.
session_id: u32,
/// Exact addressed endpoint.
target: Target,
},
}
impl ConfigCommand {
/// Returns the client request identity used for idempotency.
pub const fn request_id(self) -> u32 {
match self {
Self::InfoRequest { request_id, .. }
| Self::DescriptorRequest { request_id, .. }
| Self::EnumOptionRequest { request_id, .. }
| Self::ValueRequest { request_id, .. }
| Self::Begin { request_id, .. }
| Self::Set { request_id, .. }
| Self::Validate { request_id, .. }
| Self::Commit { request_id, .. }
| Self::Abort { request_id, .. } => request_id,
}
}
/// Returns the endpoint selected by this command.
pub const fn target(self) -> Target {
match self {
Self::InfoRequest { target, .. }
| Self::DescriptorRequest { target, .. }
| Self::EnumOptionRequest { target, .. }
| Self::ValueRequest { target, .. }
| Self::Begin { target, .. }
| Self::Set { target, .. }
| Self::Validate { target, .. }
| Self::Commit { target, .. }
| Self::Abort { target, .. } => target,
}
}
}
/// Test command paired with its lease principal.
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Authorized<T> {
principal: Principal,
command: T,
}
#[cfg(test)]
impl<T> Authorized<T> {
/// Pairs a test command with component `principal` in test system 1.
pub const fn host(principal: u8, command: T) -> Self {
Self {
principal: Principal::new(1, principal),
command,
}
}
pub(crate) fn into_parts(self) -> (Principal, T) {
(self.principal, self.command)
}
}
@@ -1,35 +0,0 @@
//! Kaptan custom-MAVLink configuration microservice adapter.
//!
//! This crate translates generated `kaptan.xml` records to the transport-
//! neutral configuration lifecycle. It does not parse raw frames, authenticate
//! peers, access storage directly, or mutate active flight
//! consumers.
#![no_std]
#[cfg(test)]
extern crate std;
mod command;
mod protocol;
pub mod service;
pub mod wire;
#[cfg(test)]
pub use command::Authorized;
pub use command::{ConfigCommand, Principal, Target, ValueSource};
pub use protocol::{ConfigReply, Operation, ProtocolError, ResultKind};
pub use service::{
ConfigInfo, ConfigProtocol, DescriptorResponse, EnumOptionResponse, ServiceResponse,
ValueResponse,
};
pub use wire::{ResponseVisitor, visit_response};
/// Wire protocol implemented by the descriptor/value configuration service.
pub const PROTOCOL_MAJOR: u8 = 2;
pub const PROTOCOL_MINOR: u8 = 0;
/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `minimum_bits` is present.
pub const DESCRIPTOR_HAS_MINIMUM: u8 = 1 << 0;
/// `KAPTAN_CONFIG_DESCRIPTOR.flags` bit: `maximum_bits` is present.
pub const DESCRIPTOR_HAS_MAXIMUM: u8 = 1 << 1;
@@ -1,128 +0,0 @@
//! Stable typed outcomes for the Kaptan configuration microservice.
use kaptan_config::{ConfigState, ConfigViolation, FieldId};
use kaptan_err_reportable::KaptanError;
/// Operation reported in an authoritative result record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Operation {
/// Query/read operation.
Query,
/// Begin transaction.
Begin,
/// Scalar write.
Set,
/// Whole-candidate validation.
Validate,
/// Durable commit.
Commit,
/// Transaction abort.
Abort,
}
/// Stable result classification.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultKind {
/// The operation was accepted.
Accepted,
/// A full snapshot committed but requires restart before activation.
CommittedRestartRequired,
/// The operation was rejected without an active-state transition.
Rejected,
}
/// Compact protocol error code. Detail belongs in [`ConfigViolation`] when a
/// configuration invariant was responsible.
#[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,
}
/// Authoritative completion for one request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigReply {
/// Client request identity.
pub request_id: u32,
/// The current session, or zero when none exists.
pub session_id: u32,
/// The transaction revision, or zero when no transaction exists.
pub revision: u32,
/// Completed operation.
pub operation: Operation,
/// High-level result.
pub result: ResultKind,
/// Exact typed failure, if rejected.
pub error: Option<ProtocolError>,
/// Current lifecycle state after handling the request.
pub state: ConfigState,
}
impl ConfigReply {
/// Stable primary field for the wire response, or the reserved sentinel.
pub const fn primary_field(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => match violation.primary() {
Some(field) => field.get(),
None => u16::MAX,
},
Some(ProtocolError::UnknownField(field)) | Some(ProtocolError::TypeMismatch(field)) => {
field.get()
}
_ => u16::MAX,
}
}
/// Stable related field for the wire response, or the reserved sentinel.
pub const fn related_field(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => match violation.related_field() {
Some(field) => field.get(),
None => u16::MAX,
},
_ => u16::MAX,
}
}
/// Stable validation-rule code, or zero when no validation rule applies.
pub const fn validation_rule(self) -> u16 {
match self.error {
Some(ProtocolError::Violation(violation)) => violation.rule().code(),
_ => 0,
}
}
}
@@ -1,14 +0,0 @@
//! Transport-neutral façade for the custom configuration service.
//!
//! Public callers see one small protocol API here. Its bounded query,
//! session and replay mechanics stay in private sibling modules
//! so no one concern becomes a second service owner.
mod query;
mod replay;
mod session;
pub use query::{
ConfigInfo, DescriptorResponse, EnumOptionResponse, ServiceResponse, ValueResponse,
};
pub use session::ConfigProtocol;
@@ -1,222 +0,0 @@
//! Read-only protocol discovery and value-query responses.
use kaptan_config::{
ConfigManager, ConfigModel, ConfigState, ConfigStore, ConfigValue, EnumOption, FieldDescriptor,
FieldId,
};
use super::session::ConfigProtocol;
use crate::{Principal, ProtocolError, ValueSource};
/// One descriptor returned by a schema discovery request.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DescriptorResponse {
/// Request being answered.
pub request_id: u32,
/// Stable index of this descriptor in the advertised schema.
pub index: u16,
/// Self-describing field metadata.
pub descriptor: FieldDescriptor,
/// Factory value selected by the concrete board/image composition.
pub default: ConfigValue,
}
/// One named enum choice associated with a descriptor response.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EnumOptionResponse {
/// Request being answered.
pub request_id: u32,
/// Stable schema index of the owning descriptor.
pub descriptor_index: u16,
/// Stable field identity of the owning descriptor.
pub field: FieldId,
/// Stable zero-based order within the descriptor's choices.
pub option_index: u8,
/// Self-describing named choice.
pub option: EnumOption,
}
/// One typed value read from an explicitly selected configuration snapshot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ValueResponse {
/// Request being answered.
pub request_id: u32,
/// Transaction session for transaction values, otherwise zero.
pub session_id: u32,
/// Current transaction revision, otherwise zero.
pub revision: u32,
/// Requested stable field identity.
pub field: FieldId,
/// Declared scalar value.
pub value: ConfigValue,
}
/// Correlated protocol/schema discovery response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConfigInfo {
/// Request being answered.
pub request_id: u32,
/// Stable schema identity for this target-described field registry.
pub schema_id: [u8; 16],
/// Number of editable fields in the registry.
pub field_count: u16,
/// Current configuration lifecycle state.
pub state: ConfigState,
}
/// Exact response produced by the custom configuration service.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ServiceResponse {
/// Correlated protocol/schema discovery response.
Info(ConfigInfo),
/// Completion for a mutating operation or rejected query.
Result(crate::ConfigReply),
/// One self-describing schema field.
Descriptor(DescriptorResponse),
/// One named choice for its explicitly identified enum descriptor.
EnumOption(EnumOptionResponse),
/// One typed scalar value.
Value(ValueResponse),
}
impl ConfigProtocol {
/// Returns static descriptor metadata by stable schema index.
pub fn descriptor<C, S>(
&self,
manager: &ConfigManager<C, S>,
index: u16,
) -> Option<&'static FieldDescriptor>
where
C: ConfigModel,
S: ConfigStore<C>,
{
manager.descriptor(index)
}
/// Reads one typed value without opening or changing a transaction.
pub fn value<C, S>(
&self,
manager: &ConfigManager<C, S>,
source: ValueSource,
field: FieldId,
) -> Option<ConfigValue>
where
C: ConfigModel,
S: ConfigStore<C>,
{
match source {
ValueSource::Active => manager.active_value(field),
ValueSource::Committed => manager.committed_value(field),
ValueSource::Transaction => manager.transaction_value(field),
}
}
pub(super) fn descriptor_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
request_id: u32,
index: u16,
) -> Option<DescriptorResponse>
where
C: ConfigModel,
S: ConfigStore<C>,
{
let descriptor = *manager.descriptor(index)?;
let default = manager.factory_value(descriptor.id)?;
Some(DescriptorResponse {
request_id,
index,
descriptor,
default,
})
}
pub(super) fn enum_option_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
request_id: u32,
descriptor_index: u16,
option_index: u8,
) -> Option<EnumOptionResponse>
where
C: ConfigModel,
S: ConfigStore<C>,
{
let descriptor = manager.descriptor(descriptor_index)?;
let option = *descriptor.enum_options.get(usize::from(option_index))?;
Some(EnumOptionResponse {
request_id,
descriptor_index,
field: descriptor.id,
option_index,
option,
})
}
pub(super) fn info<C, S>(&self, manager: &ConfigManager<C, S>, request_id: u32) -> ConfigInfo
where
C: ConfigModel,
S: ConfigStore<C>,
{
ConfigInfo {
request_id,
schema_id: C::SCHEMA_ID,
field_count: manager.field_count(),
state: self.state,
}
}
pub(super) fn value_response<C, S>(
&self,
manager: &ConfigManager<C, S>,
owner: Principal,
request_id: u32,
session_id: u32,
field: FieldId,
source: ValueSource,
) -> ServiceResponse
where
C: ConfigModel,
S: ConfigStore<C>,
{
let (value, reply_session, revision) = match source {
ValueSource::Active => (manager.active_value(field), 0, 0),
ValueSource::Committed => (manager.committed_value(field), 0, 0),
ValueSource::Transaction => {
let Some(session) = self.session else {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::InvalidSession,
));
};
if session.id != session_id || self.principal != Some(owner) {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::InvalidSession,
));
}
(
manager.transaction_value(field),
session.id,
session.revision,
)
}
};
let Some(value) = value else {
return ServiceResponse::Result(self.rejected_for(
request_id,
session_id,
ProtocolError::UnknownField(field),
));
};
ServiceResponse::Value(ValueResponse {
request_id,
session_id: reply_session,
revision,
field,
value,
})
}
}
@@ -1,209 +0,0 @@
//! Bounded request-replay cache for idempotent client retries.
use kaptan_config::ConfigValue;
use super::session::ConfigProtocol;
use crate::{ConfigCommand, ConfigReply, Operation, Principal, ProtocolError};
#[derive(Clone, Copy)]
pub(super) struct RememberedResult {
pub(super) command: ConfigCommand,
pub(super) reply: ConfigReply,
pub(super) owner: Principal,
}
impl ConfigProtocol {
pub(super) fn cached(&self, owner: Principal, command: ConfigCommand) -> Option<ConfigReply> {
let cached = self.remembered?;
if cached.owner != owner || cached.command.request_id() != command.request_id() {
return None;
}
if same_command(cached.command, command) {
Some(cached.reply)
} else {
Some(self.rejected(command, ProtocolError::RequestConflict))
}
}
pub(super) fn remember(
&mut self,
owner: Principal,
command: ConfigCommand,
reply: ConfigReply,
) {
if reply.operation == Operation::Query {
return;
}
self.remembered = Some(RememberedResult {
command,
reply,
owner,
});
}
}
fn same_command(left: ConfigCommand, right: ConfigCommand) -> bool {
match (left, right) {
(
ConfigCommand::InfoRequest {
request_id: left_request,
target: left_target,
},
ConfigCommand::InfoRequest {
request_id: right_request,
target: right_target,
},
) => left_request == right_request && left_target == right_target,
(
ConfigCommand::DescriptorRequest {
request_id: left_request,
index: left_index,
target: left_target,
},
ConfigCommand::DescriptorRequest {
request_id: right_request,
index: right_index,
target: right_target,
},
) => {
left_request == right_request
&& left_index == right_index
&& left_target == right_target
}
(
ConfigCommand::EnumOptionRequest {
request_id: left_request,
descriptor_index: left_descriptor,
option_index: left_option,
target: left_target,
},
ConfigCommand::EnumOptionRequest {
request_id: right_request,
descriptor_index: right_descriptor,
option_index: right_option,
target: right_target,
},
) => {
left_request == right_request
&& left_descriptor == right_descriptor
&& left_option == right_option
&& left_target == right_target
}
(
ConfigCommand::ValueRequest {
request_id: left_request,
session_id: left_session,
field: left_field,
source: left_source,
target: left_target,
},
ConfigCommand::ValueRequest {
request_id: right_request,
session_id: right_session,
field: right_field,
source: right_source,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_field == right_field
&& left_source == right_source
&& left_target == right_target
}
(
ConfigCommand::Begin {
request_id: left_request,
target: left_target,
},
ConfigCommand::Begin {
request_id: right_request,
target: right_target,
},
) => left_request == right_request && left_target == right_target,
(
ConfigCommand::Set {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
field: left_field,
value: left_value,
target: left_target,
},
ConfigCommand::Set {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
field: right_field,
value: right_value,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_field == right_field
&& same_value(left_value, right_value)
&& left_target == right_target
}
(
ConfigCommand::Validate {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::Validate {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_target == right_target
}
(
ConfigCommand::Commit {
request_id: left_request,
session_id: left_session,
expected_revision: left_revision,
target: left_target,
},
ConfigCommand::Commit {
request_id: right_request,
session_id: right_session,
expected_revision: right_revision,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_revision == right_revision
&& left_target == right_target
}
(
ConfigCommand::Abort {
request_id: left_request,
session_id: left_session,
target: left_target,
},
ConfigCommand::Abort {
request_id: right_request,
session_id: right_session,
target: right_target,
},
) => {
left_request == right_request
&& left_session == right_session
&& left_target == right_target
}
_ => false,
}
}
fn same_value(left: ConfigValue, right: ConfigValue) -> bool {
left.kind() == right.kind() && left.raw_bits() == right.raw_bits()
}
File diff suppressed because it is too large. Load diff
@@ -1,679 +0,0 @@
//! Generated Kaptan-dialect message translation.
use kaptan_config::{
ConfigState, ConfigValue, FieldDescriptor, FieldId, ValidationRule, ValueKind,
};
use kaptan_err_reportable::KaptanError;
use kaptan_mavlink::message::Message;
use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_INFO_DATA,
KAPTAN_CONFIG_RESULT_DATA, KAPTAN_CONFIG_VALUE_DATA, KaptanConfigError, KaptanConfigOperation,
KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, KaptanConfigValueSource,
MavMessage,
};
use crate::{
ConfigCommand, ConfigReply, DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, Operation,
PROTOCOL_MAJOR, PROTOCOL_MINOR, ProtocolError, ResultKind, Target, ValueSource,
service::{ConfigInfo, EnumOptionResponse, ServiceResponse, ValueResponse},
};
/// Transport-owned consumer of generated configuration response payloads.
///
/// Implementors retain ownership of MAVLink framing, sender sequence, queue
/// classification, and physical transmission.
pub trait ResponseVisitor {
/// Transport-specific admission or serialization failure.
type Error;
/// Visits one protocol/schema information response.
fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error>;
/// Visits one authoritative operation result.
fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error>;
/// Visits one field descriptor.
fn descriptor(&mut self, response: &KAPTAN_CONFIG_DESCRIPTOR_DATA) -> Result<(), Self::Error>;
/// Visits one named choice for an enum-backed field descriptor.
fn enum_option(&mut self, response: &KAPTAN_CONFIG_ENUM_OPTION_DATA)
-> Result<(), Self::Error>;
/// Visits one typed configuration value.
fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error>;
}
/// Projects one transport-neutral service response into generated MAVLink
/// payloads without allocating or assuming a board, schema, or field set.
pub fn visit_response<V>(response: ServiceResponse, visitor: &mut V) -> Result<(), V::Error>
where
V: ResponseVisitor,
{
match response {
ServiceResponse::Info(info) => visitor.info(&encode_info(info)),
ServiceResponse::Result(result) => visitor.result(&encode_result(result)),
ServiceResponse::Descriptor(response) => visitor.descriptor(&encode_descriptor(
response.request_id,
response.index,
response.descriptor,
response.default,
)),
ServiceResponse::EnumOption(response) => visitor.enum_option(&encode_enum_option(response)),
ServiceResponse::Value(value) => visitor.value(&encode_value(value)),
}
}
/// Rejects a generated message that is not a Kaptan configuration command.
#[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,
}
/// Converts one generated custom request into a transport-neutral command.
///
/// This only decodes a validated MAVLink payload. CRC validity is not peer
/// authentication; the platform composition must separately decide whether
/// the ingress may dispatch the command and bind its transaction principal.
pub fn decode_command(message: &MavMessage) -> Result<ConfigCommand, DecodeError> {
if matches!(message.target_system_id(), Some(0))
|| matches!(message.target_component_id(), Some(0))
{
return Err(DecodeError::InvalidTarget);
}
match message {
MavMessage::KAPTAN_CONFIG_INFO_REQUEST(data) => Ok(ConfigCommand::InfoRequest {
request_id: data.request_id,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(data) => {
Ok(ConfigCommand::DescriptorRequest {
request_id: data.request_id,
index: data.descriptor_index,
target: Target::new(data.target_system, data.target_component),
})
}
MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(data) => {
Ok(ConfigCommand::EnumOptionRequest {
request_id: data.request_id,
descriptor_index: data.descriptor_index,
option_index: data.option_index,
target: Target::new(data.target_system, data.target_component),
})
}
MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(data) => Ok(ConfigCommand::ValueRequest {
request_id: data.request_id,
session_id: data.session_id,
field: field_id(data.field_id)?,
source: value_source(data.source)?,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_BEGIN(data) => Ok(ConfigCommand::Begin {
request_id: data.request_id,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::F32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_BOOL(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::Bool(boolean(data.value)?),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_I32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::I32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_U32(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::U32(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_U64(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::U64(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_SET_F64(data) => Ok(ConfigCommand::Set {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
field: field_id(data.field_id)?,
value: ConfigValue::F64(data.value),
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_VALIDATE(data) => Ok(ConfigCommand::Validate {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_COMMIT(data) => Ok(ConfigCommand::Commit {
request_id: data.request_id,
session_id: data.session_id,
expected_revision: data.expected_revision,
target: Target::new(data.target_system, data.target_component),
}),
MavMessage::KAPTAN_CONFIG_ABORT(data) => Ok(ConfigCommand::Abort {
request_id: data.request_id,
session_id: data.session_id,
target: Target::new(data.target_system, data.target_component),
}),
_ => Err(DecodeError::UnsupportedMessage),
}
}
const fn field_id(value: u16) -> Result<FieldId, DecodeError> {
match FieldId::try_new(value) {
Some(value) => Ok(value),
None => Err(DecodeError::InvalidFieldId),
}
}
const fn value_source(value: KaptanConfigValueSource) -> Result<ValueSource, DecodeError> {
match value {
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_ACTIVE => Ok(ValueSource::Active),
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED => Ok(ValueSource::Committed),
KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_TRANSACTION => {
Ok(ValueSource::Transaction)
}
}
}
const fn boolean(value: u8) -> Result<bool, DecodeError> {
match value {
0 => Ok(false),
1 => Ok(true),
_ => Err(DecodeError::InvalidBoolean),
}
}
/// Encodes one authoritative service completion.
pub fn encode_result(reply: ConfigReply) -> KAPTAN_CONFIG_RESULT_DATA {
KAPTAN_CONFIG_RESULT_DATA {
request_id: reply.request_id,
session_id: reply.session_id,
revision: reply.revision,
primary_field: reply.primary_field(),
related_field: reply.related_field(),
validation_rule: reply.validation_rule(),
operation: operation(reply.operation),
result: result(reply.result),
error: error(reply.error),
config_state: state(reply.state),
}
}
/// Encodes configuration protocol/schema status for a Kaptan control station.
pub fn encode_info(info: ConfigInfo) -> KAPTAN_CONFIG_INFO_DATA {
KAPTAN_CONFIG_INFO_DATA {
request_id: info.request_id,
schema_id: info.schema_id,
field_count: info.field_count,
protocol_major: PROTOCOL_MAJOR,
protocol_minor: PROTOCOL_MINOR,
config_state: state(info.state),
}
}
/// Encodes self-describing metadata for one schema field.
pub fn encode_descriptor(
request_id: u32,
descriptor_index: u16,
descriptor: FieldDescriptor,
default: ConfigValue,
) -> KAPTAN_CONFIG_DESCRIPTOR_DATA {
KAPTAN_CONFIG_DESCRIPTOR_DATA {
minimum_bits: descriptor.minimum.map_or(0, ConfigValue::raw_bits),
maximum_bits: descriptor.maximum.map_or(0, ConfigValue::raw_bits),
default_bits: default.raw_bits(),
request_id,
descriptor_index,
field_id: descriptor.id.get(),
enum_option_count: u8::try_from(descriptor.enum_options.len())
.expect("validated enum option count fits u8"),
display_key: descriptor.display_key.into(),
unit: descriptor.unit.into(),
value_kind: value_kind(descriptor.kind),
flags: descriptor.minimum.map_or(0, |_| DESCRIPTOR_HAS_MINIMUM)
| descriptor.maximum.map_or(0, |_| DESCRIPTOR_HAS_MAXIMUM),
}
}
/// Encodes one generic named enum choice.
pub fn encode_enum_option(response: EnumOptionResponse) -> KAPTAN_CONFIG_ENUM_OPTION_DATA {
KAPTAN_CONFIG_ENUM_OPTION_DATA {
value: response.option.0,
request_id: response.request_id,
descriptor_index: response.descriptor_index,
field_id: response.field.get(),
option_index: response.option_index,
label: response.option.1.into(),
}
}
/// Encodes a typed scalar read response for the Kaptan control station.
pub fn encode_value(value: ValueResponse) -> KAPTAN_CONFIG_VALUE_DATA {
KAPTAN_CONFIG_VALUE_DATA {
value_bits: value.value.raw_bits(),
request_id: value.request_id,
session_id: value.session_id,
revision: value.revision,
field_id: value.field.get(),
value_kind: value_kind(value.value.kind()),
}
}
const fn value_kind(kind: ValueKind) -> KaptanConfigValueKind {
match kind {
ValueKind::Bool => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_BOOL,
ValueKind::I32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_I32,
ValueKind::U32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32,
ValueKind::U64 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U64,
ValueKind::F32 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F32,
ValueKind::F64 => KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F64,
}
}
const fn operation(operation: Operation) -> KaptanConfigOperation {
match operation {
Operation::Query => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_QUERY,
Operation::Begin => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN,
Operation::Set => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET,
Operation::Validate => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE,
Operation::Commit => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT,
Operation::Abort => KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT,
}
}
const fn result(result: ResultKind) -> KaptanConfigResult {
match result {
ResultKind::Accepted => KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED,
ResultKind::CommittedRestartRequired => {
KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED
}
ResultKind::Rejected => KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED,
}
}
const fn state(state: ConfigState) -> KaptanConfigState {
match state {
ConfigState::Active => KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE,
ConfigState::Editing => KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING,
ConfigState::RestartRequired => KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED,
}
}
const fn error(error: Option<ProtocolError>) -> KaptanConfigError {
match error {
None => KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE,
Some(ProtocolError::BadTarget) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_TARGET,
Some(ProtocolError::SessionBusy) => KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY,
Some(ProtocolError::InvalidSession) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_SESSION
}
Some(ProtocolError::RequestConflict) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_REQUEST_CONFLICT
}
Some(ProtocolError::StaleRevision) => KaptanConfigError::KAPTAN_CONFIG_ERROR_STALE_REVISION,
Some(ProtocolError::BadState) => KaptanConfigError::KAPTAN_CONFIG_ERROR_BAD_STATE,
Some(ProtocolError::UnknownField(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD
}
Some(ProtocolError::InvalidQuery) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_QUERY,
Some(ProtocolError::TypeMismatch(_)) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_TYPE_MISMATCH
}
Some(ProtocolError::Violation(violation)) => match violation.rule() {
ValidationRule::NonFinite => KaptanConfigError::KAPTAN_CONFIG_ERROR_NON_FINITE,
ValidationRule::BelowMinimum | ValidationRule::Zero => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_BELOW_MINIMUM
}
ValidationRule::AboveMaximum => KaptanConfigError::KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM,
ValidationRule::Inconsistent => KaptanConfigError::KAPTAN_CONFIG_ERROR_CROSS_FIELD,
ValidationRule::UnsupportedCapability => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY
}
ValidationRule::UnsupportedFormat => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
ValidationRule::InvalidEnumValue => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_INVALID_ENUM_VALUE
}
},
Some(ProtocolError::StorageFailure) => {
KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE
}
Some(ProtocolError::Internal) => KaptanConfigError::KAPTAN_CONFIG_ERROR_INTERNAL,
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{
ConfigState, ConfigValue, ConfigViolation, FieldDescriptor, ValidationRule, ValueKind,
};
use kaptan_mavlink::message::kaptan::{
KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA, KAPTAN_CONFIG_INFO_REQUEST_DATA,
KAPTAN_CONFIG_SET_F32_DATA, MavMessage,
};
use super::*;
#[derive(Debug, PartialEq)]
enum Emitted {
Info(KAPTAN_CONFIG_INFO_DATA),
Result(KAPTAN_CONFIG_RESULT_DATA),
Descriptor(KAPTAN_CONFIG_DESCRIPTOR_DATA),
EnumOption(KAPTAN_CONFIG_ENUM_OPTION_DATA),
Value(KAPTAN_CONFIG_VALUE_DATA),
}
#[derive(Default)]
struct RecordingVisitor {
emitted: std::vec::Vec<Emitted>,
}
impl ResponseVisitor for RecordingVisitor {
type Error = core::convert::Infallible;
fn info(&mut self, response: &KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Info(response.clone()));
Ok(())
}
fn result(&mut self, response: &KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Result(response.clone()));
Ok(())
}
fn descriptor(
&mut self,
response: &KAPTAN_CONFIG_DESCRIPTOR_DATA,
) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Descriptor(response.clone()));
Ok(())
}
fn enum_option(
&mut self,
response: &KAPTAN_CONFIG_ENUM_OPTION_DATA,
) -> Result<(), Self::Error> {
self.emitted.push(Emitted::EnumOption(response.clone()));
Ok(())
}
fn value(&mut self, response: &KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error> {
self.emitted.push(Emitted::Value(response.clone()));
Ok(())
}
}
const FIRST_DESCRIPTOR: FieldDescriptor = FieldDescriptor {
id: FieldId::new(17),
display_key: "future.sensor.rate_hz",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(200)),
enum_options: &[],
unit: "Hz",
};
#[test]
fn generated_set_message_preserves_typed_value_and_identifiers() {
let message = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::Set {
request_id: 7,
session_id: 8,
expected_revision: 9,
field: FieldId::new(10),
value: ConfigValue::F32(1.5),
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_info_request_is_correlated_and_exactly_targeted() {
let message = MavMessage::KAPTAN_CONFIG_INFO_REQUEST(KAPTAN_CONFIG_INFO_REQUEST_DATA {
request_id: 31,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::InfoRequest {
request_id: 31,
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_enum_option_request_preserves_the_explicit_index() {
let message =
MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA {
request_id: 41,
descriptor_index: 3,
option_index: 6,
target_system: 1,
target_component: 2,
});
assert_eq!(
decode_command(&message),
Ok(ConfigCommand::EnumOptionRequest {
request_id: 41,
descriptor_index: 3,
option_index: 6,
target: Target::new(1, 2),
})
);
}
#[test]
fn generated_config_requests_reject_broadcast_targets() {
let zero_system = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 0,
target_component: 2,
});
let zero_component = MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA {
request_id: 7,
session_id: 8,
expected_revision: 9,
field_id: 10,
value: 1.5,
target_system: 1,
target_component: 0,
});
assert_eq!(
decode_command(&zero_system),
Err(DecodeError::InvalidTarget)
);
assert_eq!(
decode_command(&zero_component),
Err(DecodeError::InvalidTarget)
);
}
#[test]
fn result_encodes_exact_validation_identity() {
let reply = ConfigReply {
request_id: 1,
session_id: 2,
revision: 3,
operation: Operation::Set,
result: ResultKind::Rejected,
error: Some(ProtocolError::Violation(ConfigViolation::field(
FieldId::new(11),
ValidationRule::NonFinite,
))),
state: ConfigState::Editing,
};
let encoded = encode_result(reply);
assert_eq!(encoded.primary_field, 11);
assert_eq!(encoded.validation_rule, ValidationRule::NonFinite.code());
assert_eq!(
encoded.error,
KaptanConfigError::KAPTAN_CONFIG_ERROR_NON_FINITE
);
}
#[test]
fn info_preserves_schema_and_protocol_identity() {
let info = encode_info(ConfigInfo {
request_id: 7,
schema_id: [0; 16],
field_count: 23,
state: ConfigState::Active,
});
assert_eq!(info.request_id, 7);
assert_eq!(info.schema_id, [0; 16]);
assert_eq!(
(info.protocol_major, info.protocol_minor),
(PROTOCOL_MAJOR, PROTOCOL_MINOR)
);
}
#[test]
fn descriptor_flags_identify_present_bounds() {
let descriptor = FieldDescriptor {
id: FieldId::new(9),
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
enum_options: &[],
unit: "count",
};
assert_eq!(
encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).flags,
DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM
);
assert_eq!(
encode_descriptor(1, 0, descriptor, ConfigValue::U32(2)).enum_option_count,
0
);
}
#[test]
fn response_visitor_projects_every_singleton_response_variant() {
let info = ConfigInfo {
request_id: 7,
schema_id: *b"UNKNOWN-SCHEMA01",
field_count: 2,
state: ConfigState::RestartRequired,
};
let result = ConfigReply {
request_id: 8,
session_id: 9,
revision: 2,
operation: Operation::Commit,
result: ResultKind::CommittedRestartRequired,
error: None,
state: ConfigState::RestartRequired,
};
let value = ValueResponse {
request_id: 10,
session_id: 0,
revision: 0,
field: FieldId::new(401),
value: ConfigValue::Bool(true),
};
let enum_option = crate::EnumOptionResponse {
request_id: 9,
descriptor_index: 12,
field: FieldId::new(17),
option_index: 1,
option: (3, "Error"),
};
let mut visitor = RecordingVisitor::default();
assert_eq!(
visit_response(ServiceResponse::Info(info), &mut visitor),
Ok(())
);
assert_eq!(
visit_response(ServiceResponse::Result(result), &mut visitor),
Ok(())
);
assert_eq!(
visit_response(
ServiceResponse::Descriptor(crate::DescriptorResponse {
request_id: 9,
index: 12,
descriptor: FIRST_DESCRIPTOR,
default: ConfigValue::U32(20),
}),
&mut visitor,
),
Ok(())
);
assert_eq!(
visit_response(ServiceResponse::EnumOption(enum_option), &mut visitor),
Ok(())
);
assert_eq!(
visit_response(ServiceResponse::Value(value), &mut visitor),
Ok(())
);
assert_eq!(
visitor.emitted,
std::vec![
Emitted::Info(encode_info(info)),
Emitted::Result(encode_result(result)),
Emitted::Descriptor(encode_descriptor(
9,
12,
FIRST_DESCRIPTOR,
ConfigValue::U32(20),
)),
Emitted::EnumOption(encode_enum_option(enum_option)),
Emitted::Value(encode_value(value)),
]
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
//! Allocation-free CRC-32C shared by transferable artifacts and flash records.
/// Incremental CRC-32C calculation.
pub(crate) struct Crc32c(u32);
impl Crc32c {
/// Starts a CRC-32C calculation.
pub(crate) const fn new() -> Self {
Self(!0)
}
/// Extends the calculation with one byte span.
pub(crate) fn update(&mut self, bytes: &[u8]) {
for byte in bytes {
self.update_byte(*byte);
}
}
/// Extends the calculation with one byte.
pub(crate) fn update_byte(&mut self, byte: u8) {
self.0 ^= u32::from(byte);
for _ in 0..8 {
self.0 = (self.0 >> 1) ^ (0x82f6_3b78 & 0_u32.wrapping_sub(self.0 & 1));
}
}
/// Returns the finalized CRC-32C value.
pub(crate) const fn finish(&self) -> u32 {
!self.0
}
}
/// Calculates CRC-32C over one byte span.
pub(crate) fn crc32c(bytes: &[u8]) -> u32 {
let mut crc = Crc32c::new();
crc.update(bytes);
crc.finish()
}
#[cfg(test)]
mod tests {
use super::{Crc32c, crc32c};
#[test]
fn incremental_and_contiguous_crc_match_the_standard_check_value() {
let mut incremental = Crc32c::new();
incremental.update(b"1234");
incremental.update(b"56789");
assert_eq!(incremental.finish(), 0xe306_9283);
assert_eq!(crc32c(b"123456789"), 0xe306_9283);
}
}
+16
View File
@@ -29,6 +29,22 @@ impl ValidationRule {
pub const fn code(self) -> u16 {
self as u16
}
/// Parses a stable nonzero code.
pub(crate) const fn try_from_code(code: u16) -> Option<Self> {
let rule = match code {
1 => Self::NonFinite,
2 => Self::Zero,
3 => Self::BelowMinimum,
4 => Self::AboveMaximum,
5 => Self::Inconsistent,
6 => Self::UnsupportedCapability,
7 => Self::UnsupportedFormat,
8 => Self::InvalidEnumValue,
_ => return None,
};
Some(rule)
}
}
/// An allocation-free validation issue.
+67 -57
View File
@@ -8,9 +8,9 @@ use kaptan_err_reportable::KaptanError;
/// Enum values and indexed discovery use their native `u8` domain. Options
/// are immutable flash metadata, so no smaller runtime resource limit is
/// necessary.
pub const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
pub(crate) const MAX_ENUM_OPTIONS_PER_FIELD: usize = u8::MAX as usize;
/// Maximum UTF-8 bytes advertised for one enum variant name.
pub const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
pub(crate) const MAX_ENUM_OPTION_LABEL_BYTES: usize = 32;
/// A stable, schema-local identity for one editable configuration field.
///
@@ -53,6 +53,10 @@ impl FieldId {
pub enum ConfigValue {
/// Boolean value.
Bool(bool),
/// Unsigned 8-bit integer.
U8(u8),
/// Unsigned 16-bit integer.
U16(u16),
/// Signed 32-bit integer.
I32(i32),
/// Unsigned 32-bit integer.
@@ -65,13 +69,6 @@ pub enum ConfigValue {
F64(f64),
}
/// One named value advertised for an enum-backed configuration field.
///
/// Enum metadata belongs to the compiled schema. Generic transports can
/// therefore render choices without knowing field keys or firmware-specific
/// enum types. The tuple is `(stable u8 representation, display label)`.
pub type EnumOption = (u8, &'static str);
/// Failure to project a generic configuration scalar into its native type.
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -84,9 +81,9 @@ pub enum ScalarValueError {
/// Native representation used by generated configuration fields.
///
/// The generic manager and transports retain their compact fixed scalar
/// protocol while board and runtime structures keep the narrow or enum type
/// that actually models the value.
/// Generic artifacts and private snapshots retain the native scalar kind while
/// board and runtime structures keep the narrow or enum type that models the
/// value.
#[doc(hidden)]
pub trait ConfigScalar: Copy {
/// Generic scalar kind used at schema and transport boundaries.
@@ -117,41 +114,21 @@ macro_rules! direct_config_scalar {
}
direct_config_scalar!(bool, Bool, Bool);
direct_config_scalar!(u8, U8, U8);
direct_config_scalar!(u16, U16, U16);
direct_config_scalar!(i32, I32, I32);
direct_config_scalar!(u32, U32, U32);
direct_config_scalar!(u64, U64, U64);
direct_config_scalar!(f32, F32, F32);
direct_config_scalar!(f64, F64, F64);
macro_rules! narrow_unsigned_config_scalar {
($type:ty) => {
impl ConfigScalar for $type {
const KIND: ValueKind = ValueKind::U32;
fn into_config_value(self) -> ConfigValue {
ConfigValue::U32(u32::from(self))
}
fn try_from_config_value(value: ConfigValue) -> Result<Self, ScalarValueError> {
match value {
ConfigValue::U32(value) => {
Self::try_from(value).map_err(|_| ScalarValueError::OutOfDomain)
}
value => Err(ScalarValueError::WrongKind(value.kind())),
}
}
}
};
}
narrow_unsigned_config_scalar!(u8);
narrow_unsigned_config_scalar!(u16);
impl ConfigValue {
/// The declared value kind.
pub const fn kind(self) -> ValueKind {
match self {
Self::Bool(_) => ValueKind::Bool,
Self::U8(_) => ValueKind::U8,
Self::U16(_) => ValueKind::U16,
Self::I32(_) => ValueKind::I32,
Self::U32(_) => ValueKind::U32,
Self::U64(_) => ValueKind::U64,
@@ -161,9 +138,11 @@ impl ConfigValue {
}
/// Canonical scalar bits for compact transport encodings.
pub const fn raw_bits(self) -> u64 {
pub(crate) const fn raw_bits(self) -> u64 {
match self {
Self::Bool(value) => value as u64,
Self::U8(value) => value as u64,
Self::U16(value) => value as u64,
Self::I32(value) => value as u32 as u64,
Self::U32(value) => value as u64,
Self::U64(value) => value,
@@ -172,28 +151,37 @@ impl ConfigValue {
}
}
/// Decodes the canonical scalar representation used by snapshots and the
/// configuration wire protocol.
pub const fn try_from_raw_bits(kind: ValueKind, bits: u64) -> Option<Self> {
pub(crate) fn write_le_bytes(self, output: &mut [u8]) {
let bytes = self.raw_bits().to_le_bytes();
let width = self.kind().width();
output[..width].copy_from_slice(&bytes[..width]);
}
pub(crate) fn from_le_bytes(kind: ValueKind, bytes: &[u8]) -> Option<Self> {
if bytes.len() != kind.width() {
return None;
}
let mut raw = [0; 8];
raw[..bytes.len()].copy_from_slice(bytes);
let bits = u64::from_le_bytes(raw);
match kind {
ValueKind::Bool => match bits {
0 => Some(Self::Bool(false)),
1 => Some(Self::Bool(true)),
_ => None,
},
ValueKind::I32 if bits <= u32::MAX as u64 => Some(Self::I32(bits as u32 as i32)),
ValueKind::U32 if bits <= u32::MAX as u64 => Some(Self::U32(bits as u32)),
ValueKind::U8 => Some(Self::U8(bits as u8)),
ValueKind::U16 => Some(Self::U16(bits as u16)),
ValueKind::I32 => Some(Self::I32(bits as u32 as i32)),
ValueKind::U32 => Some(Self::U32(bits as u32)),
ValueKind::U64 => Some(Self::U64(bits)),
ValueKind::F32 if bits <= u32::MAX as u64 => {
Some(Self::F32(f32::from_bits(bits as u32)))
}
ValueKind::F32 => Some(Self::F32(f32::from_bits(bits as u32))),
ValueKind::F64 => Some(Self::F64(f64::from_bits(bits))),
ValueKind::I32 | ValueKind::U32 | ValueKind::F32 => None,
}
}
}
/// The scalar kind carried in a descriptor or typed protocol message.
/// The scalar kind carried in a descriptor, KCFG field, or private snapshot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum ValueKind {
@@ -209,6 +197,36 @@ pub enum ValueKind {
F32 = 5,
/// Double-precision float.
F64 = 6,
/// Unsigned 8-bit integer.
U8 = 7,
/// Unsigned 16-bit integer.
U16 = 8,
}
impl ValueKind {
#[doc(hidden)]
pub const fn width(self) -> usize {
match self {
Self::Bool | Self::U8 => 1,
Self::U16 => 2,
Self::I32 | Self::U32 | Self::F32 => 4,
Self::U64 | Self::F64 => 8,
}
}
pub(crate) const fn try_from_code(code: u8) -> Option<Self> {
match code {
1 => Some(Self::Bool),
2 => Some(Self::I32),
3 => Some(Self::U32),
4 => Some(Self::U64),
5 => Some(Self::F32),
6 => Some(Self::F64),
7 => Some(Self::U8),
8 => Some(Self::U16),
_ => None,
}
}
}
/// Static metadata for one configuration field.
@@ -225,12 +243,12 @@ pub struct FieldDescriptor {
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// Named choices for an enum-backed field, otherwise empty.
pub enum_options: &'static [EnumOption],
pub enum_options: &'static [(u8, &'static str)],
/// Human-readable unit for schema metadata.
pub unit: &'static str,
}
/// Failure while changing one transaction field.
/// Failure while changing one candidate field.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum FieldWriteError {
/// No descriptor exposes the requested identity.
@@ -252,14 +270,6 @@ pub enum FieldWriteError {
}
impl FieldWriteError {
/// Returns the typed local validation issue, if this was one.
pub const fn violation(self) -> Option<ConfigViolation> {
match self {
Self::Violation(violation) => Some(violation),
Self::UnknownField(_) | Self::WrongType { .. } => None,
}
}
/// Returns a field-local non-finite error.
pub const fn non_finite(field: FieldId) -> Self {
Self::Violation(ConfigViolation::field(field, ValidationRule::NonFinite))
+147 -67
View File
@@ -5,9 +5,9 @@
//! the payload. The previous committed slot is never erased until the complete
//! replacement is programmed, verified, and marked committed.
use core::marker::PhantomData;
use crate::{ConfigModel, ConfigStore, decode_scalar_snapshot, encode_scalar_snapshot};
use crate::{
ConfigModel, ConfigStore, checksum::Crc32c, decode_scalar_snapshot, encode_scalar_snapshot,
};
const MAGIC: u32 = u32::from_le_bytes(*b"KJNL");
const RECORD_VERSION: u16 = 1;
@@ -15,6 +15,7 @@ const HEADER_LENGTH: usize = 32;
const CRC_OFFSET: usize = 24;
const COMMIT_OFFSET: usize = 28;
const COMMIT_MARKER: u32 = u32::from_le_bytes(*b"DONE");
const VERIFY_CHUNK_LENGTH: usize = 32;
/// Exactly two independently erasable NOR slots used by [`DualSlotJournal`].
pub trait DualSlotNor {
@@ -51,20 +52,18 @@ pub enum JournalError<E> {
}
/// Generic persistent store backed by two independently erasable NOR slots.
pub struct DualSlotJournal<C, F, const MAX_PAYLOAD: usize> {
pub struct DualSlotJournal<F, const MAX_PAYLOAD: usize> {
flash: F,
factory: C,
_payload: PhantomData<[u8; MAX_PAYLOAD]>,
// The committed slot selected during recovery must survive until its replacement commits.
protected_slot: Option<Slot>,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD> {
/// Creates a journal whose erased-flash decode base is the board factory
/// candidate. Construction performs no flash access.
pub const fn new(flash: F, factory: C) -> Self {
impl<F, const MAX_PAYLOAD: usize> DualSlotJournal<F, MAX_PAYLOAD> {
/// Creates a journal without accessing flash.
pub const fn new(flash: F) -> Self {
Self {
flash,
factory,
_payload: PhantomData,
protected_slot: None,
}
}
@@ -74,16 +73,36 @@ impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD> {
}
}
#[derive(Clone)]
struct Record<C> {
slot: usize,
slot: Slot,
sequence: u64,
candidate: C,
}
impl<C, F, const MAX_PAYLOAD: usize> DualSlotJournal<C, F, MAX_PAYLOAD>
#[derive(Clone, Copy)]
#[repr(u8)]
enum Slot {
Left = 0,
Right = 1,
}
impl Slot {
const fn index(self) -> usize {
self as usize
}
const fn other(self) -> Self {
match self {
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
const _: () = assert!(core::mem::size_of::<Option<Slot>>() == 1);
impl<F, const MAX_PAYLOAD: usize> DualSlotJournal<F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
fn geometry(&self) -> Result<(), JournalError<F::Error>> {
@@ -97,10 +116,14 @@ where
Ok(())
}
fn record(&mut self, slot: usize) -> Result<Option<Record<C>>, JournalError<F::Error>> {
fn record<C: ConfigModel>(
&mut self,
slot: Slot,
decode_base: &C,
) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let mut header = [0_u8; HEADER_LENGTH];
self.flash
.read(slot, 0, &mut header)
.read(slot.index(), 0, &mut header)
.map_err(JournalError::Storage)?;
if header[..4] == [0xff; 4] {
return Ok(None);
@@ -125,15 +148,14 @@ where
}
let mut payload = [0_u8; MAX_PAYLOAD];
self.flash
.read(slot, HEADER_LENGTH, &mut payload[..payload_length])
.read(slot.index(), HEADER_LENGTH, &mut payload[..payload_length])
.map_err(JournalError::Storage)?;
if record_crc(&header, &payload[..payload_length])
!= read_u32(&header[CRC_OFFSET..COMMIT_OFFSET])
{
return Ok(None);
}
let Ok(candidate) = decode_scalar_snapshot(&payload[..payload_length], &self.factory)
else {
let Ok(candidate) = decode_scalar_snapshot(&payload[..payload_length], decode_base) else {
return Ok(None);
};
Ok(Some(Record {
@@ -143,9 +165,12 @@ where
}))
}
fn newest(&mut self) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let left = self.record(0)?;
let right = self.record(1)?;
fn newest<C: ConfigModel>(
&mut self,
decode_base: &C,
) -> Result<Option<Record<C>>, JournalError<F::Error>> {
let left = self.record(Slot::Left, decode_base)?;
let right = self.record(Slot::Right, decode_base)?;
Ok(match (left, right) {
(Some(left), Some(right)) if right.sequence > left.sequence => Some(right),
(Some(left), Some(_)) | (Some(left), None) => Some(left),
@@ -156,7 +181,7 @@ where
fn program_bytes(
&mut self,
slot: usize,
slot: Slot,
offset: usize,
bytes: &[u8],
) -> Result<(), JournalError<F::Error>> {
@@ -167,32 +192,34 @@ where
let mut word = [0xff; 4];
word[..chunk.len()].copy_from_slice(chunk);
self.flash
.program_word(slot, offset + index * 4, u32::from_le_bytes(word))
.program_word(slot.index(), offset + index * 4, u32::from_le_bytes(word))
.map_err(JournalError::Storage)?;
}
Ok(())
}
}
impl<C, F, const MAX_PAYLOAD: usize> ConfigStore<C> for DualSlotJournal<C, F, MAX_PAYLOAD>
impl<C, F, const MAX_PAYLOAD: usize> ConfigStore<C> for DualSlotJournal<F, MAX_PAYLOAD>
where
C: ConfigModel,
F: DualSlotNor,
{
type Error = JournalError<F::Error>;
fn load(&mut self) -> Result<Option<C>, Self::Error> {
fn load(&mut self, factory: &C) -> Result<Option<C>, Self::Error> {
self.geometry()?;
Ok(self.newest()?.map(|record| record.candidate))
let selected = self.newest(factory)?;
self.protected_slot = selected.as_ref().map(|record| record.slot);
Ok(selected.map(|record| record.candidate))
}
fn load_matching<P>(&mut self, mut compatible: P) -> Result<Option<C>, Self::Error>
fn load_matching<P>(&mut self, factory: &C, mut compatible: P) -> Result<Option<C>, Self::Error>
where
P: FnMut(&C) -> bool,
{
self.geometry()?;
let left = self.record(0)?;
let right = self.record(1)?;
let left = self.record(Slot::Left, factory)?;
let right = self.record(Slot::Right, factory)?;
let left = left.filter(|record| compatible(&record.candidate));
let right = right.filter(|record| compatible(&record.candidate));
let selected = match (left, right) {
@@ -201,12 +228,13 @@ where
(None, Some(right)) => Some(right),
(None, None) => None,
};
self.protected_slot = selected.as_ref().map(|record| record.slot);
Ok(selected.map(|record| record.candidate))
}
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> {
self.geometry()?;
let current = self.newest()?;
let current = self.newest(candidate)?;
let sequence = current
.as_ref()
.map_or(1, |record| record.sequence.saturating_add(1));
@@ -217,7 +245,10 @@ where
{
return Err(JournalError::SequenceExhausted);
}
let target_slot = current.as_ref().map_or(0, |record| 1 - record.slot);
let target_slot = self
.protected_slot
.or_else(|| current.as_ref().map(|record| record.slot))
.map_or(Slot::Left, Slot::other);
let mut payload = [0_u8; MAX_PAYLOAD];
let payload = encode_scalar_snapshot(candidate, &mut payload)
@@ -233,49 +264,52 @@ where
header[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
self.flash
.erase_slot(target_slot)
.erase_slot(target_slot.index())
.map_err(JournalError::Storage)?;
self.program_bytes(target_slot, 0, &header[..COMMIT_OFFSET])?;
self.program_bytes(target_slot, HEADER_LENGTH, payload)?;
let mut verify_header = [0_u8; COMMIT_OFFSET];
self.flash
.read(target_slot, 0, &mut verify_header)
.read(target_slot.index(), 0, &mut verify_header)
.map_err(JournalError::Storage)?;
let mut verify_payload = [0_u8; MAX_PAYLOAD];
if verify_header != header[..COMMIT_OFFSET] {
return Err(JournalError::Verification);
}
let mut verify = [0_u8; VERIFY_CHUNK_LENGTH];
for (chunk_index, expected) in payload.chunks(VERIFY_CHUNK_LENGTH).enumerate() {
let actual = &mut verify[..expected.len()];
self.flash
.read(
target_slot,
HEADER_LENGTH,
&mut verify_payload[..payload.len()],
target_slot.index(),
HEADER_LENGTH + chunk_index * VERIFY_CHUNK_LENGTH,
actual,
)
.map_err(JournalError::Storage)?;
if verify_header != header[..COMMIT_OFFSET] || verify_payload[..payload.len()] != *payload {
if actual != expected {
return Err(JournalError::Verification);
}
}
self.flash
.program_word(target_slot, COMMIT_OFFSET, COMMIT_MARKER)
.program_word(target_slot.index(), COMMIT_OFFSET, COMMIT_MARKER)
.map_err(JournalError::Storage)?;
let Some(record) = self.record(target_slot)? else {
let Some(record) = self.record(target_slot, candidate)? else {
return Err(JournalError::Verification);
};
if record.sequence != sequence {
return Err(JournalError::Verification);
}
self.protected_slot = Some(target_slot);
Ok(())
}
}
fn record_crc(header: &[u8; HEADER_LENGTH], payload: &[u8]) -> u32 {
let mut crc = !0_u32;
for byte in header[..CRC_OFFSET].iter().chain(payload) {
crc ^= u32::from(*byte);
for _ in 0..8 {
crc = (crc >> 1) ^ (0x82f6_3b78 & 0_u32.wrapping_sub(crc & 1));
}
}
!crc
let mut crc = Crc32c::new();
crc.update(&header[..CRC_OFFSET]);
crc.update(payload);
crc.finish()
}
fn read_u16(bytes: &[u8]) -> u16 {
@@ -449,27 +483,28 @@ mod tests {
}
}
type Journal = DualSlotJournal<Candidate, FakeNor, 64>;
type Journal = DualSlotJournal<FakeNor, 64>;
const FACTORY: Candidate = Candidate(20);
#[test]
fn alternates_slots_and_recovers_the_newest_record() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
assert_eq!(journal.load().unwrap(), None);
let mut journal = Journal::new(FakeNor::erased());
assert_eq!(journal.load(&FACTORY).unwrap(), None);
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
let flash = journal.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(recovered.load().unwrap(), Some(Candidate(40)));
let mut recovered = Journal::new(flash);
assert_eq!(recovered.load(&FACTORY).unwrap(), Some(Candidate(40)));
}
#[test]
fn every_interrupted_erase_or_word_program_preserves_the_old_record() {
let mut initial = Journal::new(FakeNor::erased(), Candidate(20));
let mut initial = Journal::new(FakeNor::erased());
initial.persist(&Candidate(30)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone(), Candidate(20));
let mut successful = Journal::new(baseline.clone());
let start = successful.flash.operations;
successful.persist(&Candidate(40)).unwrap();
let operation_count = successful.flash.operations - start;
@@ -477,15 +512,15 @@ mod tests {
for interrupted_at in 0..operation_count {
let mut flash = baseline.clone();
flash.fail_at = Some(flash.operations + interrupted_at);
let mut interrupted = Journal::new(flash, Candidate(20));
let mut interrupted = Journal::new(flash);
assert!(matches!(
interrupted.persist(&Candidate(40)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let flash = interrupted.into_flash();
let mut recovered = Journal::new(flash, Candidate(20));
let mut recovered = Journal::new(flash);
assert_eq!(
recovered.load().unwrap(),
recovered.load(&FACTORY).unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} exposed a partial replacement"
);
@@ -494,31 +529,76 @@ mod tests {
#[test]
fn corrupt_newest_record_falls_back_to_the_previous_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
let mut journal = Journal::new(FakeNor::erased());
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
let mut flash = journal.into_flash();
flash.slots[1][HEADER_LENGTH + 2] ^= 1;
let mut recovered = Journal::new(flash, Candidate(20));
assert_eq!(recovered.load().unwrap(), Some(Candidate(30)));
let mut recovered = Journal::new(flash);
assert_eq!(recovered.load(&FACTORY).unwrap(), Some(Candidate(30)));
}
#[test]
fn compatibility_filter_can_select_the_older_structurally_valid_slot() {
let mut journal = Journal::new(FakeNor::erased(), Candidate(20));
let mut journal = Journal::new(FakeNor::erased());
journal.persist(&Candidate(30)).unwrap();
journal.persist(&Candidate(40)).unwrap();
assert_eq!(
journal
.load_matching(|candidate| candidate.0 <= 35)
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
assert_eq!(
journal.load_matching(|candidate| candidate.0 < 10).unwrap(),
journal
.load_matching(&FACTORY, |candidate| candidate.0 < 10)
.unwrap(),
None
);
}
#[test]
fn replacing_an_older_compatible_record_preserves_it_across_every_interruption() {
let mut initial = Journal::new(FakeNor::erased());
initial.persist(&Candidate(30)).unwrap();
initial.persist(&Candidate(40)).unwrap();
let baseline = initial.into_flash();
let mut successful = Journal::new(baseline.clone());
assert_eq!(
successful
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
let start = successful.flash.operations;
successful.persist(&Candidate(35)).unwrap();
let operation_count = successful.flash.operations - start;
for interrupted_at in 0..operation_count {
let mut journal = Journal::new(baseline.clone());
assert_eq!(
journal
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30))
);
journal.flash.fail_at = Some(journal.flash.operations + interrupted_at);
assert!(matches!(
journal.persist(&Candidate(35)),
Err(JournalError::Storage(FakeError::PowerLoss))
));
let mut recovered = Journal::new(journal.into_flash());
assert_eq!(
recovered
.load_matching(&FACTORY, |candidate| candidate.0 <= 35)
.unwrap(),
Some(Candidate(30)),
"mutation {interrupted_at} erased the selected compatible record"
);
}
}
}
+14 -8
View File
@@ -1,16 +1,18 @@
//! Runtime-configuration primitives shared by vehicle-specific schemas.
//!
//! This crate has no knowledge of MAVLink, filesystems, board routing, motors,
//! or a particular airframe. It owns the staged-versus-active lifecycle and
//! or a particular airframe. It owns the committed-versus-active lifecycle and
//! parameter metadata; vehicle crates provide the typed schema and complete
//! validation. Persistence framing and transports remain separate adapters;
//! this crate provides only the bounded scalar snapshot payload shared by
//! storage backends.
//! validation. It also owns the canonical public KCFG artifact and the compact
//! private scalar snapshot. Transport and storage framing remain separate
//! adapters.
#![no_std]
extern crate self as kaptan_config;
mod artifact;
mod checksum;
pub mod error;
pub mod field;
pub mod journal;
@@ -19,16 +21,20 @@ pub mod snapshot;
pub mod storage;
pub mod validation;
pub use artifact::{
KcfgArtifact, KcfgDecodeError, KcfgEncodeError, KcfgEnumOption, KcfgEnumOptions,
KcfgExportPlan, KcfgExporter, KcfgField, KcfgFields, KcfgImporter, KcfgResultDetail,
decode_kcfg, encode_edited_kcfg, kcfg_length,
};
pub use error::{ConfigViolation, ValidationRule};
pub use field::{
ConfigModel, ConfigScalar, ConfigValue, EnumOption, FieldDescriptor, FieldId, FieldWriteError,
MAX_ENUM_OPTION_LABEL_BYTES, MAX_ENUM_OPTIONS_PER_FIELD, ScalarValueError, ValueKind,
ConfigModel, ConfigScalar, ConfigValue, FieldDescriptor, FieldId, FieldWriteError,
ScalarValueError, ValueKind,
};
pub use journal::{DualSlotJournal, DualSlotNor, JournalError};
pub use kaptan_config_derive::{Config, ConfigEnum};
pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError};
pub use lifecycle::{ConfigManager, ReplaceError, ReplaceOutcome, StartupError};
pub use snapshot::{
SCALAR_SNAPSHOT_ENTRY_LENGTH, SCALAR_SNAPSHOT_HEADER_LENGTH, SCALAR_SNAPSHOT_VERSION,
ScalarSnapshotDecodeError, ScalarSnapshotEncodeError, SnapshotModelError,
decode_scalar_snapshot, encode_scalar_snapshot, scalar_snapshot_length,
};
+201 -231
View File
@@ -1,21 +1,8 @@
//! Transactional committed-versus-active configuration lifecycle.
//! Whole-candidate committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
FieldWriteError,
};
use kaptan_err_reportable::KaptanError;
/// Observable state of the bounded configuration transaction slot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigState {
/// The boot-active and committed snapshots match and no edit is open.
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after restart.
RestartRequired,
}
use crate::{ConfigModel, ConfigStore, ConfigViolation};
/// Failure while recovering a boot configuration from a store.
#[derive(Debug, KaptanError)]
@@ -26,55 +13,40 @@ pub enum StartupError<E> {
/// A committed record did not validate against this firmware's schema.
#[kaptan_error(id = 1102)]
InvalidPersisted(ConfigViolation),
/// The board's compiled factory candidate violates its immutable context.
#[kaptan_error(id = 1103)]
InvalidFactory(ConfigViolation),
}
/// Failure while beginning a transaction.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BeginError {
/// Exactly one transaction is already open.
#[kaptan_error(id = 1201)]
TransactionActive,
/// Result of replacing the complete committed configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReplaceOutcome {
/// Whether committed values differ from the immutable boot-active view.
pub restart_required: bool,
}
/// Failure while writing a scalar into the bounded transaction.
#[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),
}
/// Failure while validating or committing a transaction.
/// Failure while validating and persisting one complete candidate.
#[derive(Debug, PartialEq, KaptanError)]
pub enum CommitError<E> {
/// No transaction is open.
#[kaptan_error(id = 1401)]
NoTransaction,
pub enum ReplaceError<E> {
/// The complete candidate violates a relational or capability invariant.
#[kaptan_error(id = 1402)]
InvalidCandidate(ConfigViolation),
/// The storage backend could not commit the snapshot.
/// The storage backend could not commit the validated candidate.
#[kaptan_error(id = 1403)]
Store(E),
}
/// Owns the immutable boot-active configuration, the latest durable candidate, and
/// exactly one RAM edit transaction.
/// Owns the immutable boot-active configuration and latest durable candidate.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
/// changes after boot. `commit` atomically persists a complete validated
/// candidate, then reports [`ConfigState::RestartRequired`].
/// There is deliberately no in-place activation operation: [`Self::active`]
/// never changes after boot. A file endpoint owns its in-progress candidate;
/// this manager receives only complete candidates for validation and commit.
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
factory: C,
committed: C,
transaction: Option<C>,
context: C::Context,
store: S,
restart_required: bool,
}
impl<C, S> ConfigManager<C, S>
@@ -82,22 +54,6 @@ where
C: ConfigModel,
S: ConfigStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
/// is sufficient to arm; no recovery is implicit.
pub fn new(factory: C, context: C::Context, store: S) -> Result<Self, ConfigViolation> {
let active = factory.validate(&context)?;
Ok(Self {
active,
factory: factory.clone(),
committed: factory,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Recovers the newest valid committed snapshot or uses the supplied
/// factory candidate when the store is empty.
pub fn recover(
@@ -105,20 +61,24 @@ where
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let factory_active = factory
.validate(&context)
.map_err(StartupError::InvalidFactory)?;
let (active, committed) = match store.load(&factory).map_err(StartupError::Store)? {
Some(committed) => {
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
(active, committed)
}
None => (factory_active, factory.clone()),
};
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
@@ -133,23 +93,27 @@ where
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store
.load_matching(|candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?
else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let factory_active = factory
.validate(&context)
.map_err(StartupError::InvalidFactory)?;
let committed = store
.load_matching(&factory, |candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?;
let (active, committed) = match committed {
Some(committed) => {
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
(active, committed)
}
None => (factory_active, factory.clone()),
};
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
@@ -158,122 +122,92 @@ where
&self.active
}
/// Returns the last durably committed candidate value.
pub fn committed_value(&self, id: FieldId) -> Option<ConfigValue> {
self.committed.field_value(id)
/// Returns the complete last durably committed candidate.
pub const fn committed(&self) -> &C {
&self.committed
}
/// Returns the selected board/image factory value for descriptor metadata.
pub fn factory_value(&self, id: FieldId) -> Option<ConfigValue> {
self.factory.field_value(id)
/// Returns the complete board/image factory candidate.
pub const fn factory(&self) -> &C {
&self.factory
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
/// Whether committed values differ from the immutable boot-active view.
pub fn restart_required(&self) -> bool {
!active_matches_candidate::<C>(&self.active, &self.committed)
}
/// Returns a transaction value when an edit is open.
pub fn transaction_value(&self, id: FieldId) -> Option<ConfigValue> {
self.transaction.as_ref()?.field_value(id)
}
/// Returns descriptor metadata by stable schema index.
pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> {
C::descriptor(index)
}
/// Number of fields exposed by the compiled registry.
pub fn field_count(&self) -> u16 {
C::field_count()
}
/// Returns the current lifecycle state.
pub const fn state(&self) -> ConfigState {
if self.transaction.is_some() {
ConfigState::Editing
} else if self.restart_required {
ConfigState::RestartRequired
} else {
ConfigState::Active
}
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
return Err(BeginError::TransactionActive);
}
self.transaction = Some(self.committed.clone());
kaptan_log::debug!("edit transaction opened");
Ok(())
}
/// Applies one locally valid scalar in the open transaction.
/// Validates and atomically persists one complete replacement candidate.
///
/// Cross-field-invalid intermediate states are allowed so multi-field
/// edits can be made naturally. They are rejected by `validate`/`commit`.
pub fn set(&mut self, id: FieldId, value: ConfigValue) -> Result<ConfigValue, SetError> {
let candidate = self.transaction.as_mut().ok_or(SetError::NoTransaction)?;
candidate.set_field(id, value).map_err(SetError::Field)?;
/// An unchanged candidate does not consume a flash erase/write cycle. The
/// boot-active view remains immutable until a physical restart.
pub fn replace(&mut self, candidate: C) -> Result<ReplaceOutcome, ReplaceError<S::Error>> {
candidate
.field_value(id)
.ok_or(SetError::Field(FieldWriteError::UnknownField(id)))
.validate(&self.context)
.map_err(ReplaceError::InvalidCandidate)?;
if same_values(&candidate, &self.committed) {
return Ok(ReplaceOutcome {
restart_required: self.restart_required(),
});
}
/// Validates the complete open transaction without changing persistent or
/// active state.
pub fn validate(&self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
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.
pub fn commit(&mut self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
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;
self.store
.persist(&candidate)
.map_err(ReplaceError::Store)?;
self.committed = candidate;
let restart_required = self.restart_required();
if restart_required {
kaptan_log::info!("configuration saved; restart required");
Ok(())
} else {
kaptan_log::info!("configuration restored to active values");
}
Ok(ReplaceOutcome { restart_required })
}
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
let aborted = self.transaction.take().is_some();
if aborted {
kaptan_log::debug!("edit transaction aborted");
fn same_values<C: ConfigModel>(left: &C, right: &C) -> bool {
for index in 0..C::field_count() {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let (Some(left), Some(right)) = (
left.field_value(descriptor.id),
right.field_value(descriptor.id),
) else {
return false;
};
if left.kind() != right.kind() || left.raw_bits() != right.raw_bits() {
return false;
}
aborted
}
true
}
fn active_matches_candidate<C: ConfigModel>(active: &C::Active, candidate: &C) -> bool {
for index in 0..C::field_count() {
let Some(descriptor) = C::descriptor(index) else {
return false;
};
let (Some(active), Some(candidate)) = (
C::active_field_value(active, descriptor.id),
candidate.field_value(descriptor.id),
) else {
return false;
};
if active.kind() != candidate.kind() || active.raw_bits() != candidate.raw_bits() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use core::cell::Cell;
use super::*;
use crate::{ConfigValue, ValidationRule, ValueKind};
use crate::{
ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValidationRule, ValueKind,
};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
@@ -281,33 +215,33 @@ mod tests {
FieldDescriptor {
id: VALUE,
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
kind: ValueKind::U8,
minimum: Some(ConfigValue::U8(1)),
maximum: Some(ConfigValue::U8(10)),
enum_options: &[],
unit: "count",
},
FieldDescriptor {
id: OTHER,
display_key: "test.other",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
kind: ValueKind::U8,
minimum: Some(ConfigValue::U8(1)),
maximum: Some(ConfigValue::U8(10)),
enum_options: &[],
unit: "count",
},
];
#[derive(Clone)]
#[derive(Clone, Debug, Eq, PartialEq)]
struct Candidate {
value: u32,
other: u32,
value: u8,
other: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Active {
value: u32,
other: u32,
value: u8,
other: u8,
}
impl ConfigModel for Candidate {
@@ -325,34 +259,28 @@ mod tests {
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(self.value)),
OTHER => Some(ConfigValue::U32(self.other)),
VALUE => Some(ConfigValue::U8(self.value)),
OTHER => Some(ConfigValue::U8(self.other)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(active.value)),
OTHER => Some(ConfigValue::U32(active.other)),
VALUE => Some(ConfigValue::U8(active.value)),
OTHER => Some(ConfigValue::U8(active.other)),
_ => None,
}
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
let ConfigValue::U32(value) = value else {
let ConfigValue::U8(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
expected: ValueKind::U8,
actual: value.kind(),
});
};
if !(1..=10).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
match id {
VALUE => self.value = value,
OTHER => self.other = value,
@@ -376,16 +304,17 @@ mod tests {
}
}
struct MemoryStore;
struct MemoryStore<'a>(&'a Cell<usize>);
impl ConfigStore<Candidate> for MemoryStore {
impl ConfigStore<Candidate> for MemoryStore<'_> {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
self.0.set(self.0.get() + 1);
Ok(())
}
}
@@ -395,7 +324,7 @@ mod tests {
impl ConfigStore<Candidate> for IncompatibleStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 9, other: 1 }))
}
@@ -404,44 +333,72 @@ mod tests {
}
}
#[test]
fn invalid_intermediate_transaction_never_changes_active_or_committed() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
assert_eq!(
manager.set(VALUE, ConfigValue::U32(5)),
Ok(ConfigValue::U32(5))
);
assert_eq!(
manager.validate(),
Err(CommitError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent
)))
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(2)));
assert_eq!(manager.transaction_value(VALUE), Some(ConfigValue::U32(5)));
assert_eq!(manager.state(), ConfigState::Editing);
struct ValidStore;
impl ConfigStore<Candidate> for ValidStore {
type Error = ();
fn load(&mut self, _: &Candidate) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 2, other: 4 }))
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
#[test]
fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() {
fn whole_replacement_is_validated_and_unchanged_values_skip_flash() {
let writes = Cell::new(0);
let factory = Candidate { value: 2, other: 4 };
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(()));
ConfigManager::recover(factory.clone(), (), MemoryStore(&writes)).unwrap();
assert_eq!(
manager.replace(Candidate { value: 5, other: 4 }),
Err(ReplaceError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent,
)))
);
assert_eq!(manager.committed(), &factory);
assert_eq!(writes.get(), 0);
assert_eq!(
manager.replace(factory.clone()),
Ok(ReplaceOutcome {
restart_required: false,
})
);
assert_eq!(writes.get(), 0);
}
#[test]
fn restart_requirement_is_derived_and_clears_when_active_values_are_restored() {
let writes = Cell::new(0);
let factory = Candidate { value: 2, other: 4 };
let mut manager =
ConfigManager::recover(factory.clone(), (), MemoryStore(&writes)).unwrap();
assert_eq!(
manager.replace(Candidate { value: 3, other: 4 }),
Ok(ReplaceOutcome {
restart_required: true,
})
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
assert!(manager.abort());
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
assert!(manager.restart_required());
assert_eq!(
manager.replace(factory.clone()),
Ok(ReplaceOutcome {
restart_required: false,
})
);
assert_eq!(manager.committed(), &factory);
assert_eq!(manager.factory(), &factory);
assert_eq!(writes.get(), 2);
}
#[test]
@@ -456,4 +413,17 @@ mod tests {
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
}
#[test]
fn recovery_rejects_an_invalid_factory_even_when_flash_contains_a_valid_record() {
let invalid_factory = Candidate { value: 9, other: 1 };
assert!(matches!(
ConfigManager::recover(invalid_factory.clone(), (), ValidStore),
Err(StartupError::InvalidFactory(_))
));
assert!(matches!(
ConfigManager::recover_compatible_or_factory(invalid_factory, (), ValidStore),
Err(StartupError::InvalidFactory(_))
));
}
}
+154 -172
View File
@@ -4,23 +4,23 @@
//! storage backend remains responsible for private ordering metadata, integrity,
//! power-loss-safe commit markers, and selecting the newest valid record.
//!
//! The fixed-width little-endian layout is:
//! The native-width little-endian layout is:
//!
//! ```text
//! magic[4] | version:u8 | schema_id[16] | field_count:u16
//! magic[4]="KCSP" | version:u8 | schema_id[16] | field_count:u16
//! repeated field_count times:
//! field_id:u16 | value_kind:u8 | value_bits:u64
//! field_id:u16 | value_kind:u8 | value:[value_kind.width]
//! ```
//!
//! Field entries are emitted in descriptor order. Decoding uses only stable
//! field IDs and therefore also accepts a complete, uniquely identified set in
//! another order.
//! Field entries are emitted and decoded in descriptor order. The schema
//! fingerprint already fixes that order, so accepting permutations would add
//! work without adding a compatible persistence state.
use kaptan_err_reportable::KaptanError;
use crate::{ConfigModel, ConfigValue, FieldId, FieldWriteError, ValueKind};
const MAGIC: [u8; 4] = *b"KCFG";
const MAGIC: [u8; 4] = *b"KCSP";
const MAGIC_OFFSET: usize = 0;
const VERSION_OFFSET: usize = 4;
const SCHEMA_OFFSET: usize = 5;
@@ -28,20 +28,22 @@ const FIELD_COUNT_OFFSET: usize = 21;
const ENTRIES_OFFSET: usize = 23;
/// Current format of the generic scalar snapshot payload.
pub const SCALAR_SNAPSHOT_VERSION: u8 = 1;
pub(crate) const SCALAR_SNAPSHOT_VERSION: u8 = 2;
/// Bytes preceding the first scalar entry.
pub const SCALAR_SNAPSHOT_HEADER_LENGTH: usize = ENTRIES_OFFSET;
pub(crate) const SCALAR_SNAPSHOT_HEADER_LENGTH: usize = ENTRIES_OFFSET;
/// Fixed bytes occupied by one scalar entry.
pub const SCALAR_SNAPSHOT_ENTRY_LENGTH: usize = 11;
/// Bytes preceding the native-width value in every scalar entry.
pub(crate) const SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH: usize = 3;
/// Returns the exact encoded length for a declared number of scalar fields.
/// Returns the exact encoded length for a schema's field and value widths.
///
/// The `u16` field-count domain makes this arithmetic safe on all targets
/// supported by this crate, including 32-bit microcontrollers.
pub const fn scalar_snapshot_length(field_count: u16) -> usize {
SCALAR_SNAPSHOT_HEADER_LENGTH + field_count as usize * SCALAR_SNAPSHOT_ENTRY_LENGTH
pub const fn scalar_snapshot_length(field_count: u16, value_bytes: usize) -> usize {
SCALAR_SNAPSHOT_HEADER_LENGTH
+ field_count as usize * SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH
+ value_bytes
}
/// A broken [`ConfigModel`] registry detected before encoding or decoding.
@@ -134,15 +136,14 @@ pub enum ScalarSnapshotDecodeError {
/// An entry used a reserved stable field identity.
#[kaptan_error(id = 1708)]
InvalidFieldId(u16),
/// An entry names no field in the target schema.
/// An entry differs from the field fixed at this schema position.
#[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),
UnexpectedField {
/// Field fixed at this position by the schema.
expected: FieldId,
/// Field carried by the private snapshot.
actual: FieldId,
},
/// An entry contains no supported scalar-kind discriminator.
#[kaptan_error(id = 1712)]
InvalidValueKind(u8),
@@ -179,7 +180,7 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
) -> Result<&'a [u8], ScalarSnapshotEncodeError> {
validate_model::<C>().map_err(ScalarSnapshotEncodeError::InvalidModel)?;
let field_count = C::field_count();
let required = scalar_snapshot_length(field_count);
let required = model_snapshot_length::<C>().map_err(ScalarSnapshotEncodeError::InvalidModel)?;
if output.len() < required {
return Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
@@ -192,6 +193,7 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
output[SCHEMA_OFFSET..FIELD_COUNT_OFFSET].copy_from_slice(&C::SCHEMA_ID);
output[FIELD_COUNT_OFFSET..ENTRIES_OFFSET].copy_from_slice(&field_count.to_le_bytes());
let mut entry = ENTRIES_OFFSET;
for index in 0..field_count {
let descriptor = C::descriptor(index).ok_or(ScalarSnapshotEncodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index),
@@ -211,11 +213,11 @@ pub fn encode_scalar_snapshot<'a, C: ConfigModel>(
},
));
}
let entry = entry_offset(index);
output[entry..entry + 2].copy_from_slice(&descriptor.id.get().to_le_bytes());
output[entry + 2] = descriptor.kind as u8;
output[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]
.copy_from_slice(&value.raw_bits().to_le_bytes());
let width = descriptor.kind.width();
value.write_le_bytes(&mut output[entry + 3..entry + 3 + width]);
entry += SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + width;
}
Ok(&output[..required])
@@ -265,13 +267,7 @@ pub fn decode_scalar_snapshot<C: ConfigModel>(
actual: actual_count,
});
}
let expected_length = scalar_snapshot_length(actual_count);
if input.len() < expected_length {
return Err(ScalarSnapshotDecodeError::Truncated {
required: expected_length,
available: input.len(),
});
}
let expected_length = validate_entries::<C>(input, actual_count)?;
if input.len() > expected_length {
return Err(ScalarSnapshotDecodeError::TrailingData {
expected: expected_length,
@@ -279,24 +275,25 @@ pub fn decode_scalar_snapshot<C: ConfigModel>(
});
}
validate_entries::<C>(input, actual_count)?;
let mut candidate = factory.clone();
for index in 0..actual_count {
let entry = entry_offset(index);
let mut entry = ENTRIES_OFFSET;
for _ in 0..actual_count {
let id = FieldId::try_new(read_u16(&input[entry..entry + 2]))
.expect("entry identities were structurally validated");
let kind = decode_kind(input[entry + 2]).expect("entry kinds were structurally validated");
let bits = read_u64(&input[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]);
let value = decode_value(id, kind, bits)?;
let kind = ValueKind::try_from_code(input[entry + 2])
.expect("entry kinds were structurally validated");
let width = kind.width();
let value = ConfigValue::from_le_bytes(kind, &input[entry + 3..entry + 3 + width])
.ok_or(ScalarSnapshotDecodeError::InvalidValueBits { field: id, kind })?;
candidate
.set_field(id, value)
.map_err(ScalarSnapshotDecodeError::InvalidWrite)?;
entry += SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + width;
}
Ok(candidate)
}
fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
pub(crate) fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
let count = C::field_count();
for index in 0..count {
let descriptor =
@@ -318,17 +315,25 @@ fn validate_model<C: ConfigModel>() -> Result<(), SnapshotModelError> {
fn validate_entries<C: ConfigModel>(
input: &[u8],
count: u16,
) -> Result<(), ScalarSnapshotDecodeError> {
) -> Result<usize, ScalarSnapshotDecodeError> {
let mut entry = ENTRIES_OFFSET;
for index in 0..count {
let entry = entry_offset(index);
require_bytes(input, entry + SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH)?;
let raw_id = read_u16(&input[entry..entry + 2]);
let id =
FieldId::try_new(raw_id).ok_or(ScalarSnapshotDecodeError::InvalidFieldId(raw_id))?;
let descriptor =
descriptor_by_id::<C>(id).ok_or(ScalarSnapshotDecodeError::UnknownField(id))?;
let descriptor = C::descriptor(index).ok_or(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(index),
))?;
if id != descriptor.id {
return Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: descriptor.id,
actual: id,
});
}
let raw_kind = input[entry + 2];
let kind =
decode_kind(raw_kind).ok_or(ScalarSnapshotDecodeError::InvalidValueKind(raw_kind))?;
let kind = ValueKind::try_from_code(raw_kind)
.ok_or(ScalarSnapshotDecodeError::InvalidValueKind(raw_kind))?;
if kind != descriptor.kind {
return Err(ScalarSnapshotDecodeError::WrongValueKind {
field: id,
@@ -336,78 +341,41 @@ fn validate_entries<C: ConfigModel>(
actual: kind,
});
}
let bits = read_u64(&input[entry + 3..entry + SCALAR_SNAPSHOT_ENTRY_LENGTH]);
let _ = decode_value(id, kind, bits)?;
for previous in 0..index {
let previous_entry = entry_offset(previous);
if read_u16(&input[previous_entry..previous_entry + 2]) == raw_id {
return Err(ScalarSnapshotDecodeError::DuplicateField(id));
}
}
let width = kind.width();
let next = entry + SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + width;
require_bytes(input, next)?;
let _ = ConfigValue::from_le_bytes(kind, &input[entry + 3..next])
.ok_or(ScalarSnapshotDecodeError::InvalidValueBits { field: id, kind })?;
entry = next;
}
Ok(entry)
}
for descriptor_index in 0..count {
let descriptor =
C::descriptor(descriptor_index).ok_or(ScalarSnapshotDecodeError::InvalidModel(
SnapshotModelError::MissingDescriptor(descriptor_index),
))?;
let mut present = false;
for entry_index in 0..count {
let entry = entry_offset(entry_index);
present |= read_u16(&input[entry..entry + 2]) == descriptor.id.get();
}
if !present {
return Err(ScalarSnapshotDecodeError::MissingField(descriptor.id));
}
}
fn require_bytes(input: &[u8], required: usize) -> Result<(), ScalarSnapshotDecodeError> {
if input.len() < required {
Err(ScalarSnapshotDecodeError::Truncated {
required,
available: input.len(),
})
} else {
Ok(())
}
}
fn descriptor_by_id<C: ConfigModel>(id: FieldId) -> Option<&'static crate::FieldDescriptor> {
fn model_snapshot_length<C: ConfigModel>() -> Result<usize, SnapshotModelError> {
let mut value_bytes = 0;
for index in 0..C::field_count() {
let descriptor = C::descriptor(index)?;
if descriptor.id == id {
return Some(descriptor);
let descriptor =
C::descriptor(index).ok_or(SnapshotModelError::MissingDescriptor(index))?;
value_bytes += descriptor.kind.width();
}
}
None
}
const fn entry_offset(index: u16) -> usize {
ENTRIES_OFFSET + index as usize * SCALAR_SNAPSHOT_ENTRY_LENGTH
}
const fn decode_kind(value: u8) -> Option<ValueKind> {
match value {
1 => Some(ValueKind::Bool),
2 => Some(ValueKind::I32),
3 => Some(ValueKind::U32),
4 => Some(ValueKind::U64),
5 => Some(ValueKind::F32),
6 => Some(ValueKind::F64),
_ => None,
}
}
fn decode_value(
field: FieldId,
kind: ValueKind,
bits: u64,
) -> Result<ConfigValue, ScalarSnapshotDecodeError> {
let invalid = || ScalarSnapshotDecodeError::InvalidValueBits { field, kind };
ConfigValue::try_from_raw_bits(kind, bits).ok_or_else(invalid)
Ok(scalar_snapshot_length(C::field_count(), value_bytes))
}
fn read_u16(bytes: &[u8]) -> u16 {
u16::from_le_bytes([bytes[0], bytes[1]])
}
fn read_u64(bytes: &[u8]) -> u64 {
u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -419,14 +387,20 @@ mod tests {
const U64: FieldId = FieldId::new(4);
const F32: FieldId = FieldId::new(5);
const F64: FieldId = FieldId::new(6);
const U8: FieldId = FieldId::new(7);
const U16: FieldId = FieldId::new(8);
const SCHEMA: [u8; 16] = *b"SCALAR-SNAPSHOT1";
const DESCRIPTORS: [FieldDescriptor; 6] = [
const SNAPSHOT_VALUE_BYTES: usize = 1 + 4 + 4 + 8 + 4 + 8 + 1 + 2;
const SNAPSHOT_LENGTH: usize = scalar_snapshot_length(8, SNAPSHOT_VALUE_BYTES);
const DESCRIPTORS: [FieldDescriptor; 8] = [
descriptor(BOOL, ValueKind::Bool),
descriptor(I32, ValueKind::I32),
descriptor(U32, ValueKind::U32),
descriptor(U64, ValueKind::U64),
descriptor(F32, ValueKind::F32),
descriptor(F64, ValueKind::F64),
descriptor(U8, ValueKind::U8),
descriptor(U16, ValueKind::U16),
];
const fn descriptor(id: FieldId, kind: ValueKind) -> FieldDescriptor {
@@ -449,18 +423,20 @@ mod tests {
wide: u64,
float: f32,
double: f64,
byte: u8,
short: u16,
}
impl Default for Candidate {
fn default() -> Self {
Self {
fn factory() -> Candidate {
Candidate {
boolean: false,
signed: -1,
unsigned: 1,
wide: 1,
float: 1.0,
double: 1.0,
}
byte: 1,
short: 1,
}
}
@@ -486,6 +462,8 @@ mod tests {
U64 => Some(ConfigValue::U64(self.wide)),
F32 => Some(ConfigValue::F32(self.float)),
F64 => Some(ConfigValue::F64(self.double)),
U8 => Some(ConfigValue::U8(self.byte)),
U16 => Some(ConfigValue::U16(self.short)),
_ => None,
}
}
@@ -502,6 +480,8 @@ mod tests {
(U64, ConfigValue::U64(value)) => self.wide = value,
(F32, ConfigValue::F32(value)) if value.is_finite() => self.float = value,
(F64, ConfigValue::F64(value)) if value.is_finite() => self.double = value,
(U8, ConfigValue::U8(value)) => self.byte = value,
(U16, ConfigValue::U16(value)) => self.short = value,
(U32, ConfigValue::U32(_)) => {
return Err(FieldWriteError::Violation(ConfigViolation::field(
U32,
@@ -527,8 +507,8 @@ mod tests {
}
}
fn encoded(candidate: &Candidate) -> ([u8; scalar_snapshot_length(6)], usize) {
let mut bytes = [0xa5; scalar_snapshot_length(6)];
fn encoded(candidate: &Candidate) -> ([u8; SNAPSHOT_LENGTH], usize) {
let mut bytes = [0xa5; SNAPSHOT_LENGTH];
let length = encode_scalar_snapshot(candidate, &mut bytes)
.expect("candidate encodes")
.len();
@@ -536,7 +516,11 @@ mod tests {
}
fn entry(index: u16) -> usize {
entry_offset(index)
let mut offset = ENTRIES_OFFSET;
for descriptor in &DESCRIPTORS[..usize::from(index)] {
offset += SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + descriptor.kind.width();
}
offset
}
#[test]
@@ -548,26 +532,25 @@ mod tests {
wide: u64::MAX,
float: -0.0,
double: f64::MIN_POSITIVE,
byte: u8::MAX,
short: u16::MAX,
};
let mut storage = [0xa5; scalar_snapshot_length(6) + 7];
let mut storage = [0xa5; SNAPSHOT_LENGTH + 7];
let payload = encode_scalar_snapshot(&candidate, &mut storage).unwrap();
assert_eq!(payload.len(), scalar_snapshot_length(6));
assert_eq!(&payload[..4], b"KCFG");
assert_eq!(payload.len(), SNAPSHOT_LENGTH);
assert_eq!(&payload[..4], b"KCSP");
assert_eq!(payload[4], SCALAR_SNAPSHOT_VERSION);
assert_eq!(&payload[5..21], &SCHEMA);
assert_eq!(read_u16(&payload[21..23]), 6);
assert_eq!(
decode_scalar_snapshot(payload, &Candidate::default()),
Ok(candidate)
);
assert_eq!(read_u16(&payload[21..23]), 8);
assert_eq!(decode_scalar_snapshot(payload, &factory()), Ok(candidate));
}
#[test]
fn encoder_rejects_a_short_destination_without_writing_it() {
let required = scalar_snapshot_length(6);
let mut output = [0xa5; scalar_snapshot_length(6) - 1];
let required = SNAPSHOT_LENGTH;
let mut output = [0xa5; SNAPSHOT_LENGTH - 1];
assert_eq!(
encode_scalar_snapshot(&Candidate::default(), &mut output),
encode_scalar_snapshot(&factory(), &mut output),
Err(ScalarSnapshotEncodeError::BufferTooSmall {
required,
available: required - 1,
@@ -578,14 +561,14 @@ mod tests {
#[test]
fn decoder_rejects_every_truncation_and_trailing_data() {
let (bytes, length) = encoded(&Candidate::default());
let (bytes, length) = encoded(&factory());
for end in 0..length {
assert!(decode_scalar_snapshot(&bytes[..end], &Candidate::default()).is_err());
assert!(decode_scalar_snapshot(&bytes[..end], &factory()).is_err());
}
let mut trailing = [0_u8; scalar_snapshot_length(6) + 1];
let mut trailing = [0_u8; SNAPSHOT_LENGTH + 1];
trailing[..length].copy_from_slice(&bytes);
assert_eq!(
decode_scalar_snapshot(&trailing, &Candidate::default()),
decode_scalar_snapshot(&trailing, &factory()),
Err(ScalarSnapshotDecodeError::TrailingData {
expected: length,
actual: length + 1,
@@ -595,20 +578,20 @@ mod tests {
#[test]
fn decoder_rejects_wrong_magic_version_schema_and_count() {
let (source, _) = encoded(&Candidate::default());
let (source, _) = encoded(&factory());
let mut bytes = source;
bytes[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidMagic)
);
let mut bytes = source;
bytes[VERSION_OFFSET] += 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnsupportedVersion(2))
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnsupportedVersion(3))
);
let mut bytes = source;
@@ -616,7 +599,7 @@ mod tests {
let mut actual = SCHEMA;
actual[0] ^= 1;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::WrongSchema {
expected: SCHEMA,
actual,
@@ -626,60 +609,62 @@ mod tests {
let mut bytes = source;
bytes[FIELD_COUNT_OFFSET..ENTRIES_OFFSET].copy_from_slice(&5_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::WrongFieldCount {
expected: 6,
expected: 8,
actual: 5,
})
);
}
#[test]
fn decoder_rejects_invalid_unknown_duplicate_and_missing_ids() {
let (source, _) = encoded(&Candidate::default());
fn decoder_rejects_invalid_and_noncanonical_field_ids() {
let (source, _) = encoded(&factory());
let mut bytes = source;
bytes[entry(0)..entry(0) + 2].copy_from_slice(&0_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidFieldId(0))
);
let mut bytes = source;
bytes[entry(0)..entry(0) + 2].copy_from_slice(&100_u16.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::UnknownField(FieldId::new(100)))
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: BOOL,
actual: FieldId::new(100),
})
);
let mut bytes = source;
let first_id = bytes[entry(0)..entry(0) + 2].to_vec();
bytes[entry(1)..entry(1) + 2].copy_from_slice(&first_id);
bytes[entry(1) + 2] = ValueKind::Bool as u8;
bytes[entry(1) + 3..entry(1) + 11].copy_from_slice(&0_u64.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::DuplicateField(BOOL))
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: I32,
actual: BOOL,
})
);
// With an exact count and only known IDs, a duplicate necessarily also
// omits a schema field. Rejecting it proves incomplete sets cannot pass.
}
#[test]
fn decoder_rejects_invalid_and_wrong_kinds_and_noncanonical_bits() {
let (source, _) = encoded(&Candidate::default());
let (source, _) = encoded(&factory());
let mut bytes = source;
bytes[entry(0) + 2] = 0xff;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidValueKind(0xff))
);
let mut bytes = source;
bytes[entry(0) + 2] = ValueKind::U32 as u8;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::WrongValueKind {
field: BOOL,
expected: ValueKind::Bool,
@@ -688,29 +673,21 @@ mod tests {
);
let mut bytes = source;
bytes[entry(0) + 3..entry(0) + 11].copy_from_slice(&2_u64.to_le_bytes());
bytes[entry(0) + 3] = 2;
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidValueBits {
field: BOOL,
kind: ValueKind::Bool,
})
);
let mut bytes = source;
bytes[entry(1) + 3..entry(1) + 11]
.copy_from_slice(&(u64::from(u32::MAX) + 1).to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Err(ScalarSnapshotDecodeError::InvalidValueBits {
field: I32,
kind: ValueKind::I32,
})
);
// Every numeric kind now occupies its native width, so unlike the old
// widened u64 representation it cannot carry out-of-width high bits.
}
#[test]
fn decoder_accepts_reordered_entries_because_ids_are_authoritative() {
fn decoder_rejects_reordered_entries_as_noncanonical() {
let candidate = Candidate {
boolean: true,
signed: -72,
@@ -718,23 +695,28 @@ mod tests {
wide: 987_654_321,
float: 0.25,
double: -19.5,
byte: 72,
short: 4861,
};
let (mut bytes, _) = encoded(&candidate);
for offset in 0..SCALAR_SNAPSHOT_ENTRY_LENGTH {
bytes.swap(entry(0) + offset, entry(5) + offset);
for offset in 0..SCALAR_SNAPSHOT_ENTRY_PREFIX_LENGTH + 4 {
bytes.swap(entry(1) + offset, entry(2) + offset);
}
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
Ok(candidate)
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::UnexpectedField {
expected: I32,
actual: U32,
})
);
}
#[test]
fn decoder_routes_values_through_local_model_validation() {
let (mut bytes, _) = encoded(&Candidate::default());
bytes[entry(2) + 3..entry(2) + 11].copy_from_slice(&101_u64.to_le_bytes());
let (mut bytes, _) = encoded(&factory());
bytes[entry(2) + 3..entry(2) + 7].copy_from_slice(&101_u32.to_le_bytes());
assert_eq!(
decode_scalar_snapshot(&bytes, &Candidate::default()),
decode_scalar_snapshot(&bytes, &factory()),
Err(ScalarSnapshotDecodeError::InvalidWrite(
FieldWriteError::Violation(ConfigViolation::field(
U32,
@@ -784,7 +766,7 @@ mod tests {
#[test]
fn encoder_and_decoder_reject_a_broken_model_registry() {
let mut output = [0_u8; scalar_snapshot_length(1)];
let mut output = [0_u8; scalar_snapshot_length(1, 8)];
assert_eq!(
encode_scalar_snapshot(&MissingDescriptor, &mut output),
Err(ScalarSnapshotEncodeError::InvalidModel(
@@ -792,7 +774,7 @@ mod tests {
))
);
let (bytes, _) = encoded(&Candidate::default());
let (bytes, _) = encoded(&factory());
assert!(matches!(
decode_scalar_snapshot(&bytes, &MissingDescriptor),
Err(ScalarSnapshotDecodeError::InvalidModel(
+5 -3
View File
@@ -12,18 +12,20 @@ pub trait ConfigStore<C> {
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<C>, Self::Error>;
fn load(&mut self, factory: &C) -> Result<Option<C>, Self::Error>;
/// Loads the newest structurally valid candidate accepted by `compatible`.
///
/// Single-record stores inherit this implementation. Journals should
/// override it so an incompatible newest record can fall back to an older
/// compatible slot without weakening structural integrity checks.
fn load_matching<F>(&mut self, mut compatible: F) -> Result<Option<C>, Self::Error>
fn load_matching<F>(&mut self, factory: &C, mut compatible: F) -> Result<Option<C>, Self::Error>
where
F: FnMut(&C) -> bool,
{
Ok(self.load()?.filter(|candidate| compatible(candidate)))
Ok(self
.load(factory)?
.filter(|candidate| compatible(candidate)))
}
/// Persists one complete already-validated candidate atomically.
@@ -11,6 +11,9 @@ pub trait Config {
/// Number of scalar fields in the flattened tree.
const FIELD_COUNT: usize;
/// Exact value bytes in the flattened private snapshot.
const SNAPSHOT_VALUE_BYTES: usize;
/// Fingerprint generated from the complete declared schema.
const SCHEMA_ID: [u8; 16];
@@ -79,13 +82,13 @@ pub fn __unique_display_keys<C: Config>() -> bool {
#[doc(hidden)]
pub fn __valid_enum_options(descriptors: &[FieldDescriptor]) -> bool {
for descriptor in descriptors {
if descriptor.enum_options.len() > crate::MAX_ENUM_OPTIONS_PER_FIELD {
if descriptor.enum_options.len() > crate::field::MAX_ENUM_OPTIONS_PER_FIELD {
return false;
}
for (index, option) in descriptor.enum_options.iter().enumerate() {
if option.1.is_empty()
|| option.1.len() > crate::MAX_ENUM_OPTION_LABEL_BYTES
|| descriptor.kind != crate::ValueKind::U32
|| option.1.len() > crate::field::MAX_ENUM_OPTION_LABEL_BYTES
|| descriptor.kind != crate::ValueKind::U8
{
return false;
}
+2 -1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-mavlink = { path = "../kaptan-mavlink" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["sensor-telemetry"] }
kaptan-sensor = { path = "../kaptan-sensor" }
kaptan-time = { path = "../kaptan-time" }
mavlink-core = { version = "0.18", default-features = false }
+36 -68
View File
@@ -2,21 +2,21 @@
//!
//! This crate owns fixed-rate scheduling, mailbox consumption, vertical-data
//! freshness, MAVLink encoding, and bounded queue classification. A platform
//! supplies only mailbox references, a declared MAVLink state, arming state,
//! and a transport sink.
//! supplies only mailbox references, vertical-data freshness, and a transport
//! sink.
#![no_std]
use kaptan_dma::TxCompletion;
use kaptan_err_reportable::KaptanError;
use kaptan_mavlink::message::MessageData;
use kaptan_mavlink::{
MavlinkFrame,
encoder::{DistanceSensorConfig, MavlinkEncoder},
message::MavState,
encoder::MavlinkEncoder,
generated::kaptan::MavMessage,
transmitter::{EnqueueResult, FrameClass},
};
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use mavlink_core::MessageData;
mod mailbox;
@@ -29,10 +29,8 @@ pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox
/// after validating the active user configuration against immutable hardware.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataChannelRates {
/// Frequency of the platform service tick.
pub service_hz: usize,
/// Raw-IMU publication rate.
pub raw_imu_hz: usize,
/// IMU publication rate.
pub imu_hz: usize,
/// Range publication rate; `None` disables the stream.
pub range_hz: Option<usize>,
/// Optical-flow publication rate; `None` disables the stream.
@@ -65,17 +63,6 @@ pub struct DataSources<'a> {
pub optical_flow: &'a LatestOpticalFlowMailbox,
}
/// Vertical-sensor metadata and freshness policy for an outbound data channel.
#[derive(Clone, Copy)]
pub struct VerticalDataConfig {
/// Metadata required to encode `DISTANCE_SENSOR`; `None` disables it.
pub distance: Option<DistanceSensorConfig>,
/// MTF-01 sensor ID for `OPTICAL_FLOW`; `None` disables it.
pub optical_flow_sensor_id: Option<u8>,
/// Maximum age, in service ticks, for cached vertical observations.
pub maximum_age_ticks: u8,
}
/// Data-channel construction failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum DataChannelError {
@@ -96,7 +83,7 @@ pub struct DataChannel<'a, Sink, const SERVICE_HZ: usize> {
encoder: MavlinkEncoder,
schedule: Schedule<SERVICE_HZ>,
sources: DataSources<'a>,
vertical: VerticalDataConfig,
maximum_vertical_age_ticks: u8,
observed_imu: u32,
observed_range: u32,
last_range: Option<RangeSnapshot>,
@@ -116,7 +103,7 @@ where
system_id: u8,
component_id: u8,
rates: DataChannelRates,
vertical: VerticalDataConfig,
maximum_vertical_age_ticks: u8,
sources: DataSources<'a>,
) -> Result<Self, DataChannelError> {
let schedule = Schedule::from_rates(rates)?;
@@ -125,7 +112,7 @@ where
encoder: MavlinkEncoder::new(system_id, component_id),
schedule,
sources,
vertical,
maximum_vertical_age_ticks,
observed_imu: 0,
observed_range: 0,
last_range: None,
@@ -141,43 +128,36 @@ where
/// No schedule token escapes this method. It refreshes cached vertical
/// observations, selects due data, encodes the MAVLink message, and admits
/// it with the correct bounded-queue class.
pub fn on_service_tick(&mut self, system_status: MavState, motors_armed: bool) {
pub fn on_service_tick(&mut self) {
self.refresh_vertical_data();
match self.schedule.next_due() {
Some(DueData::RawImu) => {
Some(DueData::Imu) => {
if let Some(sample) = self.sources.imu.take_newer(&mut self.observed_imu) {
let _ = self.sink.send(
FrameClass::BestEffort,
self.encoder.raw_imu_snapshot(&sample),
);
let _ = self
.sink
.send(FrameClass::BestEffort, self.encoder.imu_snapshot(&sample));
}
}
Some(DueData::Distance) if self.range_age_ticks <= self.vertical.maximum_age_ticks => {
if let (Some(range), Some(config)) = (self.last_range, self.vertical.distance) {
let _ = self.sink.send(
FrameClass::BestEffort,
self.encoder.distance_sensor_snapshot(&range, config),
);
Some(DueData::Distance) if self.range_age_ticks <= self.maximum_vertical_age_ticks => {
if let Some(range) = self.last_range {
let _ = self
.sink
.send(FrameClass::BestEffort, self.encoder.range_snapshot(&range));
}
}
Some(DueData::OpticalFlow)
if self.optical_flow_age_ticks <= self.vertical.maximum_age_ticks =>
{
if let (Some(flow), Some(sensor_id)) =
(self.last_optical_flow, self.vertical.optical_flow_sensor_id)
if self.optical_flow_age_ticks <= self.maximum_vertical_age_ticks =>
{
if let Some(flow) = self.last_optical_flow {
let _ = self.sink.send(
FrameClass::BestEffort,
self.encoder.optical_flow_snapshot(&flow, sensor_id),
self.encoder.optical_flow_snapshot(&flow),
);
}
}
Some(DueData::Heartbeat) => {
let _ = self.sink.send(
FrameClass::Vital,
self.encoder.heartbeat(system_status, motors_armed),
);
let _ = self.sink.send(FrameClass::Vital, self.encoder.heartbeat());
}
Some(DueData::Distance | DueData::OpticalFlow) | None => {}
}
@@ -190,8 +170,12 @@ where
/// Encodes an adapter-owned generated MAVLink data record through the
/// channel's sole encoder and bounded transport sink.
pub fn send_custom<M: MessageData>(&mut self, class: FrameClass, message: &M) -> EnqueueResult {
self.sink.send(class, self.encoder.custom(message))
pub fn send_message<M: MessageData<Message = MavMessage>>(
&mut self,
class: FrameClass,
message: &M,
) -> EnqueueResult {
self.sink.send(class, self.encoder.encode(message))
}
fn refresh_vertical_data(&mut self) {
@@ -217,7 +201,7 @@ where
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DueData {
Heartbeat,
RawImu,
Imu,
Distance,
OpticalFlow,
}
@@ -233,15 +217,11 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
SERVICE_HZ != 0,
"data-channel service frequency must be nonzero"
);
if rates.service_hz != SERVICE_HZ {
return Err(DataChannelError::RateExceedsServiceFrequency);
}
let schedule = Self {
phase: 0,
due: [None; SERVICE_HZ],
}
.with(DueData::RawImu, rates.raw_imu_hz)?
.with(DueData::Imu, rates.imu_hz)?
.with(DueData::Heartbeat, rates.heartbeat_hz)?;
let schedule = match rates.range_hz {
Some(hertz) => schedule.with(DueData::Distance, hertz)?,
@@ -297,12 +277,11 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
mod tests {
use super::{
DataChannel, DataChannelRates, DataSources, FrameSink, LatestImuMailbox,
LatestOpticalFlowMailbox, LatestRangeMailbox, VerticalDataConfig,
LatestOpticalFlowMailbox, LatestRangeMailbox,
};
use kaptan_dma::TxCompletion;
use kaptan_mavlink::{
MavlinkFrame,
message::MavState,
transmitter::{EnqueueResult, FrameClass},
};
use kaptan_sensor::{
@@ -332,8 +311,7 @@ mod tests {
}
const RATES: DataChannelRates = DataChannelRates {
service_hz: 100,
raw_imu_hz: 20,
imu_hz: 20,
range_hz: Some(20),
optical_flow_hz: Some(20),
heartbeat_hz: 2,
@@ -353,17 +331,7 @@ mod tests {
1,
1,
RATES,
VerticalDataConfig {
distance: Some(kaptan_mavlink::encoder::DistanceSensorConfig {
sensor_id: 0,
orientation:
kaptan_mavlink::message::MavSensorOrientation::MAV_SENSOR_ROTATION_NONE,
minimum_distance_centimetres: 1,
maximum_distance_centimetres: 1_000,
}),
optical_flow_sensor_id: Some(0),
maximum_age_ticks: 10,
},
10,
DataSources {
imu: &imu,
range: &range,
@@ -386,7 +354,7 @@ mod tests {
100,
100,
));
channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
channel.on_service_tick();
}
channel.on_dma_complete(TxCompletion::Complete);
let sink = channel.sink;
@@ -11,7 +11,4 @@ workspace = true
[dependencies]
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-log = { path = "../kaptan-log" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
[dev-dependencies]
mavlink = { version = "0.18", default-features = false, features = ["embedded"] }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false }
@@ -10,30 +10,27 @@
extern crate std;
use kaptan_err_reportable::ErrorRecord;
use kaptan_log::{Level, LogRecord, MAXIMUM_LOG_TEXT_BYTES};
use kaptan_mavlink::message::kaptan::{KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KaptanLogFlags};
use kaptan_log::{Level, LogRecord};
use kaptan_mavlink::generated::kaptan::{ERROR_DATA, LOG_DATA, LogFlags, LogLevel};
/// 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(),
pub fn encode_log(record: &LogRecord) -> LOG_DATA {
LOG_DATA {
level: wire_level(record.level()),
flags: if record.message().is_truncated() {
LogFlags::LOG_FLAG_TRUNCATED
} else {
LogFlags::empty()
},
message_length: record.message().byte_length(),
message: *record.message().bytes(),
}
}
/// Encodes one reportable firmware error as its sole wire message.
pub fn encode_error(record: &ErrorRecord) -> KAPTAN_ERROR_DATA {
pub fn encode_error(record: &ErrorRecord) -> 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) {
@@ -42,37 +39,44 @@ pub fn encode_error(record: &ErrorRecord) -> KAPTAN_ERROR_DATA {
let mut raised_at = [0; ERROR_RAISED_AT_BYTES];
raised_at[..length].copy_from_slice(&source[..length]);
KAPTAN_ERROR_DATA {
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 {
/// Projects the five conventional levels into Kaptan's stable wire encoding.
const fn wire_level(level: Level) -> LogLevel {
match level {
Level::Error => 3,
Level::Warn => 4,
Level::Info => 6,
Level::Debug | Level::Trace => 7,
Level::Error => LogLevel::LOG_LEVEL_ERROR,
Level::Warn => LogLevel::LOG_LEVEL_WARN,
Level::Info => LogLevel::LOG_LEVEL_INFO,
Level::Debug => LogLevel::LOG_LEVEL_DEBUG,
Level::Trace => LogLevel::LOG_LEVEL_TRACE,
}
}
#[cfg(test)]
mod tests {
use super::{LOG_MESSAGE_BYTES, encode_error, encode_log};
use super::{encode_error, encode_log};
use kaptan_err_reportable::KaptanError;
use kaptan_log::{Level, LogFlags, LogRecord, LogText};
use kaptan_mavlink::{
MavlinkVersion,
MavlinkFrame,
encoder::MavlinkEncoder,
message::{
Message, MessageData,
kaptan::{KAPTAN_ERROR_DATA, KAPTAN_LOG_DATA, KaptanLogFlags, MavMessage},
},
generated::kaptan::{ERROR_DATA, LogFlags, LogLevel, MavMessage},
receiver::IntegrityMavlinkReceiver,
};
use std::sync::Mutex;
static LOG_TEST: Mutex<()> = Mutex::new(());
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received()?.decode()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
enum TestError {
@@ -86,40 +90,35 @@ mod tests {
}
#[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);
fn truncated_text_sets_its_wire_flag() {
let _guard = LOG_TEST.lock().unwrap();
while kaptan_log::drain_one(|_| ()).is_some() {}
kaptan_log::info!("{}", "x".repeat(161));
let data = kaptan_log::drain_one(encode_log).unwrap();
assert_eq!(data.level, LogLevel::LOG_LEVEL_INFO);
assert_eq!(usize::from(data.message_length), data.message.len());
assert_eq!(data.message, [b'x'; 160]);
assert_eq!(data.flags, LogFlags::LOG_FLAG_TRUNCATED);
}
#[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));
fn trace_is_a_first_class_wire_level() {
let _guard = LOG_TEST.lock().unwrap();
while kaptan_log::drain_one(|_| ()).is_some() {}
kaptan_log::trace!("scheduler tick");
let data = kaptan_log::drain_one(encode_log).unwrap();
assert_eq!(data.level, LogLevel::LOG_LEVEL_TRACE);
assert!(data.flags.is_empty());
}
#[test]
fn error_macro_remains_an_error_level_log() {
let _guard = LOG_TEST.lock().unwrap();
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.level, LogLevel::LOG_LEVEL_ERROR);
assert_eq!(
&payload.message[..usize::from(payload.message_length)],
b"flight fault"
@@ -128,24 +127,17 @@ mod tests {
#[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 _guard = LOG_TEST.lock().unwrap();
while kaptan_log::drain_one(|_| ()).is_some() {}
kaptan_log::info!("{}", "x".repeat(160));
let data = kaptan_log::drain_one(encode_log).unwrap();
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.custom(&data);
assert!(frame.has_valid_crc::<MavMessage>());
let frame = encoder.encode(&data);
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
parse_test_message(&frame),
Some(MavMessage::LOG(decoded)) if decoded == data
));
while kaptan_err_reportable::take().is_some() {}
@@ -153,7 +145,6 @@ mod tests {
let error = kaptan_err_reportable::take().expect("reportable 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()
@@ -168,35 +159,26 @@ mod tests {
.raised_at()
.contains("kaptan-diagnostics-mavlink/src/lib.rs:")
);
let error_frame = encoder.custom(&error_data);
let error_frame = encoder.encode(&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
parse_test_message(&error_frame),
Some(MavMessage::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 {
let data = 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]);
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.encode(&data);
assert_eq!(&frame.payload()[..2], &[210, 4]);
assert_eq!(&frame.payload()[2..], &[b'A'; 96]);
}
}
@@ -1,5 +1,5 @@
[package]
name = "kaptan-config-mavlink"
name = "kaptan-file-mavlink"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
@@ -9,6 +9,4 @@ version.workspace = true
workspace = true
[dependencies]
kaptan-config = { path = "../kaptan-config" }
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = ["dialect-kaptan"] }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false }
@@ -0,0 +1,28 @@
//! Bounded Kaptan virtual-file service over two custom MAVLink messages.
//!
//! The service owns transfer correlation, strict sequential writes, retry
//! replay, and complete-file CRC-32C verification. File formats, resource
//! routing, validation, persistence, and activation policy belong to the
//! caller-provided [`FileProvider`].
#![no_std]
#[cfg(test)]
extern crate std;
mod protocol;
mod service;
pub mod wire;
pub use protocol::{
Content, ControlRequest, ControlResponse, DataRequest, DataResponse, FileFailure, FileMetadata,
FileOperation, FilePath, FileRequest, FileResponse, FileStatus, Principal, Target,
};
pub use service::{FileProvider, FileService, crc32c};
/// Bytes transported by one `FILE_DATA` record.
pub const DATA_BYTES: usize = 246;
/// Maximum bytes in a canonical virtual-file path or bounded response detail.
pub const CONTENT_BYTES: usize = 64;
/// Time callers retain an open transfer or completed-response replay state.
pub const TRANSFER_REPLAY_LEASE_SECONDS: u8 = 10;
@@ -0,0 +1,350 @@
//! Transport-sized request, response, and provider boundary types.
use crate::{CONTENT_BYTES, DATA_BYTES};
/// Exact MAVLink endpoint that owns the virtual-file service.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Target {
system: u8,
component: u8,
}
impl Target {
/// Creates one exact, non-broadcast endpoint.
pub const fn new(system: u8, component: u8) -> Self {
assert!(system != 0 && component != 0, "file target must be exact");
Self { system, component }
}
/// Parses an exact endpoint from an untrusted request.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self { system, component })
}
}
/// Target system identity.
pub const fn system(self) -> u8 {
self.system
}
/// Target component identity.
pub const fn component(self) -> u8 {
self.component
}
}
/// Exact MAVLink source that owns an open transfer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Principal {
system: u8,
component: u8,
}
impl Principal {
/// Creates one exact source identity.
pub const fn new(system: u8, component: u8) -> Self {
assert!(
system != 0 && component != 0,
"file principal must be exact"
);
Self { system, component }
}
/// Parses an exact source identity from an untrusted frame.
pub const fn try_new(system: u8, component: u8) -> Option<Self> {
if system == 0 || component == 0 {
None
} else {
Some(Self { system, component })
}
}
pub(crate) const fn target(self) -> Target {
Target {
system: self.system,
component: self.component,
}
}
/// Source system identity.
pub const fn system(self) -> u8 {
self.system
}
/// Source component identity.
pub const fn component(self) -> u8 {
self.component
}
}
/// File operation carried by `FILE_CONTROL`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum FileOperation {
/// Opens a stable read snapshot selected by the supplied path.
OpenRead = 1,
/// Opens a bounded write staging area selected by the supplied path.
OpenWrite = 2,
/// Requests the chunk beginning at `position`.
Read = 3,
/// Acknowledges one accepted upload chunk.
WriteAck = 4,
/// Completes an open transfer.
Close = 5,
/// Discards an open transfer.
Abort = 6,
}
/// Authoritative result carried by a control response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum FileStatus {
/// The operation succeeded.
Ok = 1,
/// Another principal owns the single transfer slot.
Busy = 2,
/// The transfer identity is absent, stale, or owned by another principal.
InvalidTransfer = 3,
/// The operation is not valid for the transfer's current mode or state.
InvalidState = 4,
/// The open request did not carry one canonical bounded path.
InvalidPath = 5,
/// No virtual file exists at the supplied path.
NotFound = 6,
/// The declared complete file exceeds the provider's bound.
TooLarge = 7,
/// A chunk or read did not start at an admissible position.
InvalidPosition = 8,
/// The complete sequential upload disagrees with its declared CRC-32C.
ChecksumMismatch = 9,
/// The provider rejected the complete file contents.
ContentRejected = 10,
/// Durable storage failed; the resulting durable state may be unknown.
StorageFailure = 11,
/// A retry reused an operation identity with different content.
RequestConflict = 12,
/// A provider or service invariant failed.
Internal = 13,
}
/// Bounded opaque response detail or open-request path storage.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Content {
length: u8,
bytes: [u8; CONTENT_BYTES],
}
impl Content {
/// Empty content.
pub const EMPTY: Self = Self {
length: 0,
bytes: [0; CONTENT_BYTES],
};
/// Copies a bounded byte sequence.
pub fn try_new(bytes: &[u8]) -> Option<Self> {
if bytes.len() > CONTENT_BYTES {
return None;
}
let mut content = Self::EMPTY;
content.bytes[..bytes.len()].copy_from_slice(bytes);
content.length = bytes.len() as u8;
Some(content)
}
/// Parses canonical length-delimited wire content.
pub(crate) fn from_wire(length: u8, bytes: [u8; CONTENT_BYTES]) -> Option<Self> {
let length = usize::from(length);
if length > CONTENT_BYTES || bytes[length..].iter().any(|byte| *byte != 0) {
return None;
}
Some(Self {
length: length as u8,
bytes,
})
}
/// Logical initialized bytes.
pub fn as_bytes(&self) -> &[u8] {
&self.bytes[..usize::from(self.length)]
}
/// Logical byte count.
pub(crate) const fn len(self) -> u8 {
self.length
}
/// Whether the logical content is empty.
pub(crate) const fn is_empty(self) -> bool {
self.length == 0
}
/// Zero-padded wire storage.
pub(crate) const fn wire_bytes(self) -> [u8; CONTENT_BYTES] {
self.bytes
}
}
/// Canonical absolute ASCII virtual-file path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FilePath(Content);
impl FilePath {
/// Validates a bounded absolute path without normalization or allocation.
pub(crate) fn try_from_content(content: Content) -> Option<Self> {
let bytes = content.as_bytes();
if bytes.len() < 2 || bytes[0] != b'/' || bytes[bytes.len() - 1] == b'/' {
return None;
}
let mut segment_start = 1;
for (index, byte) in bytes.iter().copied().enumerate().skip(1) {
if byte == b'/' {
if index == segment_start || reserved_segment(&bytes[segment_start..index]) {
return None;
}
segment_start = index + 1;
} else if !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-')
{
return None;
}
}
if reserved_segment(&bytes[segment_start..]) {
return None;
}
Some(Self(content))
}
/// Canonical path bytes.
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
fn reserved_segment(segment: &[u8]) -> bool {
segment == b"." || segment == b".."
}
/// One decoded control request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ControlRequest {
/// Request-selected operation.
pub operation: FileOperation,
/// Exact addressed service endpoint.
pub target: Target,
/// Transfer-relative byte position.
pub position: u32,
/// Complete file size declared by an open-write request.
pub file_size: u32,
/// Complete CRC-32C declared by an open-write request.
pub checksum_crc32c: u32,
/// Nonzero client-selected transfer identity.
pub transfer_id: u16,
/// Canonical path bytes for opens; empty for all other requests.
pub content: Content,
}
/// One decoded upload data request.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataRequest {
/// Exact addressed service endpoint.
pub target: Target,
/// Transfer-relative byte position.
pub position: u32,
/// Nonzero client-selected transfer identity.
pub transfer_id: u16,
/// Fixed wire chunk. Its logical length comes from the open file size.
pub data: [u8; DATA_BYTES],
}
/// Request accepted by [`crate::FileService`].
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileRequest {
/// Control operation.
Control(ControlRequest),
/// Write data.
Data(DataRequest),
}
/// One control response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ControlResponse {
/// Operation whose outcome is reported.
pub operation: FileOperation,
/// Requester addressed by the response.
pub target: Target,
/// Next accepted position or request position on failure.
pub position: u32,
/// Complete opened file size.
pub file_size: u32,
/// Complete opened file CRC-32C.
pub checksum_crc32c: u32,
/// Client-selected transfer identity.
pub transfer_id: u16,
/// Authoritative outcome.
pub status: FileStatus,
/// Bounded provider detail.
pub content: Content,
}
/// One read-data response.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataResponse {
/// Requester addressed by the response.
pub target: Target,
/// Position of the first logical byte.
pub position: u32,
/// Transfer identity.
pub transfer_id: u16,
/// Zero-padded wire data; logical length derives from the opened file size.
pub data: [u8; DATA_BYTES],
}
/// Response produced by [`crate::FileService`].
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileResponse {
/// Control outcome.
Control(ControlResponse),
/// Read data.
Data(DataResponse),
}
/// Stable metadata returned when a provider pins a readable file.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FileMetadata {
/// Exact complete byte length.
pub size: u32,
/// CRC-32C of the complete pinned bytes.
pub checksum_crc32c: u32,
}
/// Format-agnostic provider rejection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FileFailure {
/// Stable generic failure classification.
status: FileStatus,
/// Bounded format-specific detail.
content: Content,
}
impl FileFailure {
/// Constructs a provider failure. Success statuses are rejected.
pub const fn new(status: FileStatus, content: Content) -> Option<Self> {
match status {
FileStatus::Ok => None,
_ => Some(Self { status, content }),
}
}
/// Stable generic failure classification.
pub(crate) const fn status(self) -> FileStatus {
self.status
}
/// Bounded format-specific detail.
pub(crate) const fn content(self) -> Content {
self.content
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,317 @@
//! Generated Kaptan-dialect translation for the two file messages.
use kaptan_mavlink::generated::kaptan::{
FILE_CONTROL_DATA, FILE_DATA_DATA, FileOperation as WireFileOperation,
FileStatus as WireFileStatus, MavMessage,
};
use crate::{
Content, ControlRequest, ControlResponse, DataRequest, DataResponse, FileOperation,
FileRequest, FileResponse, FileStatus, Target,
};
/// Failure while decoding a generated custom message as a service request.
#[derive(Debug, PartialEq)]
pub enum DecodeError {
/// The message is not one of the two file records.
UnsupportedMessage,
/// A response was presented to the request decoder.
WrongDirection,
/// The request used a broadcast or zero target.
InvalidTarget,
/// The control content was not canonically zero-padded.
InvalidContent,
}
/// Decodes one generated file message into the bounded service request.
pub fn decode_request(message: &MavMessage) -> Result<FileRequest, DecodeError> {
match message {
MavMessage::FILE_CONTROL(data) => {
if data.status != WireFileStatus::FILE_STATUS_UNSPECIFIED {
return Err(DecodeError::WrongDirection);
}
let target = target(data.target_system, data.target_component)?;
let content = Content::from_wire(data.content_length, data.content)
.ok_or(DecodeError::InvalidContent)?;
Ok(FileRequest::Control(ControlRequest {
operation: decode_operation(data.operation),
target,
position: data.position,
file_size: data.file_size,
checksum_crc32c: data.checksum_crc32c,
transfer_id: data.transfer_id,
content,
}))
}
MavMessage::FILE_DATA(data) => {
if data.status != WireFileStatus::FILE_STATUS_UNSPECIFIED {
return Err(DecodeError::WrongDirection);
}
Ok(FileRequest::Data(DataRequest {
target: target(data.target_system, data.target_component)?,
position: data.position,
transfer_id: data.transfer_id,
data: data.data,
}))
}
_ => Err(DecodeError::UnsupportedMessage),
}
}
/// Decodes one generated file message as a host-side service response.
pub fn decode_response(message: &MavMessage) -> Result<FileResponse, DecodeError> {
match message {
MavMessage::FILE_CONTROL(data) => {
if data.status == WireFileStatus::FILE_STATUS_UNSPECIFIED {
return Err(DecodeError::WrongDirection);
}
let target = target(data.target_system, data.target_component)?;
let content = Content::from_wire(data.content_length, data.content)
.ok_or(DecodeError::InvalidContent)?;
Ok(FileResponse::Control(ControlResponse {
operation: decode_operation(data.operation),
target,
position: data.position,
file_size: data.file_size,
checksum_crc32c: data.checksum_crc32c,
transfer_id: data.transfer_id,
status: decode_status(data.status),
content,
}))
}
MavMessage::FILE_DATA(data) => {
if data.status != WireFileStatus::FILE_STATUS_OK {
return Err(DecodeError::WrongDirection);
}
Ok(FileResponse::Data(DataResponse {
target: target(data.target_system, data.target_component)?,
position: data.position,
transfer_id: data.transfer_id,
data: data.data,
}))
}
_ => Err(DecodeError::UnsupportedMessage),
}
}
/// Encodes one service response as a generated Kaptan-dialect message.
pub fn encode_response(response: FileResponse) -> MavMessage {
match response {
FileResponse::Control(response) => {
MavMessage::FILE_CONTROL(encode_control_response(response))
}
FileResponse::Data(response) => MavMessage::FILE_DATA(encode_data_response(response)),
}
}
/// Encodes one host request as a generated Kaptan-dialect message.
pub fn encode_request(request: FileRequest) -> MavMessage {
match request {
FileRequest::Control(request) => MavMessage::FILE_CONTROL(FILE_CONTROL_DATA {
position: request.position,
file_size: request.file_size,
checksum_crc32c: request.checksum_crc32c,
transfer_id: request.transfer_id,
target_system: request.target.system(),
target_component: request.target.component(),
operation: encode_operation(request.operation),
status: WireFileStatus::FILE_STATUS_UNSPECIFIED,
content_length: request.content.len(),
content: request.content.wire_bytes(),
}),
FileRequest::Data(request) => MavMessage::FILE_DATA(FILE_DATA_DATA {
position: request.position,
transfer_id: request.transfer_id,
target_system: request.target.system(),
target_component: request.target.component(),
status: WireFileStatus::FILE_STATUS_UNSPECIFIED,
data: request.data,
}),
}
}
fn encode_control_response(response: ControlResponse) -> FILE_CONTROL_DATA {
FILE_CONTROL_DATA {
position: response.position,
file_size: response.file_size,
checksum_crc32c: response.checksum_crc32c,
transfer_id: response.transfer_id,
target_system: response.target.system(),
target_component: response.target.component(),
operation: encode_operation(response.operation),
status: encode_status(response.status),
content_length: response.content.len(),
content: response.content.wire_bytes(),
}
}
fn encode_data_response(response: DataResponse) -> FILE_DATA_DATA {
FILE_DATA_DATA {
position: response.position,
transfer_id: response.transfer_id,
target_system: response.target.system(),
target_component: response.target.component(),
status: WireFileStatus::FILE_STATUS_OK,
data: response.data,
}
}
fn target(system: u8, component: u8) -> Result<Target, DecodeError> {
Target::try_new(system, component).ok_or(DecodeError::InvalidTarget)
}
const fn decode_operation(operation: WireFileOperation) -> FileOperation {
match operation {
WireFileOperation::FILE_OPERATION_OPEN_READ => FileOperation::OpenRead,
WireFileOperation::FILE_OPERATION_OPEN_WRITE => FileOperation::OpenWrite,
WireFileOperation::FILE_OPERATION_READ => FileOperation::Read,
WireFileOperation::FILE_OPERATION_WRITE_ACK => FileOperation::WriteAck,
WireFileOperation::FILE_OPERATION_CLOSE => FileOperation::Close,
WireFileOperation::FILE_OPERATION_ABORT => FileOperation::Abort,
}
}
const fn encode_operation(operation: FileOperation) -> WireFileOperation {
match operation {
FileOperation::OpenRead => WireFileOperation::FILE_OPERATION_OPEN_READ,
FileOperation::OpenWrite => WireFileOperation::FILE_OPERATION_OPEN_WRITE,
FileOperation::Read => WireFileOperation::FILE_OPERATION_READ,
FileOperation::WriteAck => WireFileOperation::FILE_OPERATION_WRITE_ACK,
FileOperation::Close => WireFileOperation::FILE_OPERATION_CLOSE,
FileOperation::Abort => WireFileOperation::FILE_OPERATION_ABORT,
}
}
const fn encode_status(status: FileStatus) -> WireFileStatus {
match status {
FileStatus::Ok => WireFileStatus::FILE_STATUS_OK,
FileStatus::Busy => WireFileStatus::FILE_STATUS_BUSY,
FileStatus::InvalidTransfer => WireFileStatus::FILE_STATUS_INVALID_TRANSFER,
FileStatus::InvalidState => WireFileStatus::FILE_STATUS_INVALID_STATE,
FileStatus::InvalidPath => WireFileStatus::FILE_STATUS_INVALID_PATH,
FileStatus::NotFound => WireFileStatus::FILE_STATUS_NOT_FOUND,
FileStatus::TooLarge => WireFileStatus::FILE_STATUS_TOO_LARGE,
FileStatus::InvalidPosition => WireFileStatus::FILE_STATUS_INVALID_POSITION,
FileStatus::ChecksumMismatch => WireFileStatus::FILE_STATUS_CHECKSUM_MISMATCH,
FileStatus::ContentRejected => WireFileStatus::FILE_STATUS_CONTENT_REJECTED,
FileStatus::StorageFailure => WireFileStatus::FILE_STATUS_STORAGE_FAILURE,
FileStatus::RequestConflict => WireFileStatus::FILE_STATUS_REQUEST_CONFLICT,
FileStatus::Internal => WireFileStatus::FILE_STATUS_INTERNAL,
}
}
fn decode_status(status: WireFileStatus) -> FileStatus {
match status {
WireFileStatus::FILE_STATUS_UNSPECIFIED => {
unreachable!("response direction was checked before status decoding")
}
WireFileStatus::FILE_STATUS_OK => FileStatus::Ok,
WireFileStatus::FILE_STATUS_BUSY => FileStatus::Busy,
WireFileStatus::FILE_STATUS_INVALID_TRANSFER => FileStatus::InvalidTransfer,
WireFileStatus::FILE_STATUS_INVALID_STATE => FileStatus::InvalidState,
WireFileStatus::FILE_STATUS_INVALID_PATH => FileStatus::InvalidPath,
WireFileStatus::FILE_STATUS_NOT_FOUND => FileStatus::NotFound,
WireFileStatus::FILE_STATUS_TOO_LARGE => FileStatus::TooLarge,
WireFileStatus::FILE_STATUS_INVALID_POSITION => FileStatus::InvalidPosition,
WireFileStatus::FILE_STATUS_CHECKSUM_MISMATCH => FileStatus::ChecksumMismatch,
WireFileStatus::FILE_STATUS_CONTENT_REJECTED => FileStatus::ContentRejected,
WireFileStatus::FILE_STATUS_STORAGE_FAILURE => FileStatus::StorageFailure,
WireFileStatus::FILE_STATUS_REQUEST_CONFLICT => FileStatus::RequestConflict,
WireFileStatus::FILE_STATUS_INTERNAL => FileStatus::Internal,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_enum_wire_values_are_stable() {
assert_eq!(WireFileOperation::FILE_OPERATION_OPEN_READ as u8, 1);
assert_eq!(WireFileOperation::FILE_OPERATION_OPEN_WRITE as u8, 2);
assert_eq!(WireFileOperation::FILE_OPERATION_READ as u8, 3);
assert_eq!(WireFileOperation::FILE_OPERATION_WRITE_ACK as u8, 4);
assert_eq!(WireFileOperation::FILE_OPERATION_CLOSE as u8, 5);
assert_eq!(WireFileOperation::FILE_OPERATION_ABORT as u8, 6);
assert_eq!(WireFileStatus::FILE_STATUS_UNSPECIFIED as u8, 0);
assert_eq!(WireFileStatus::FILE_STATUS_OK as u8, 1);
assert_eq!(WireFileStatus::FILE_STATUS_BUSY as u8, 2);
assert_eq!(WireFileStatus::FILE_STATUS_INVALID_TRANSFER as u8, 3);
assert_eq!(WireFileStatus::FILE_STATUS_INVALID_STATE as u8, 4);
assert_eq!(WireFileStatus::FILE_STATUS_INVALID_PATH as u8, 5);
assert_eq!(WireFileStatus::FILE_STATUS_NOT_FOUND as u8, 6);
assert_eq!(WireFileStatus::FILE_STATUS_TOO_LARGE as u8, 7);
assert_eq!(WireFileStatus::FILE_STATUS_INVALID_POSITION as u8, 8);
assert_eq!(WireFileStatus::FILE_STATUS_CHECKSUM_MISMATCH as u8, 9);
assert_eq!(WireFileStatus::FILE_STATUS_CONTENT_REJECTED as u8, 10);
assert_eq!(WireFileStatus::FILE_STATUS_STORAGE_FAILURE as u8, 11);
assert_eq!(WireFileStatus::FILE_STATUS_REQUEST_CONFLICT as u8, 12);
assert_eq!(WireFileStatus::FILE_STATUS_INTERNAL as u8, 13);
assert_eq!(FileStatus::Ok as u8, 1);
assert_eq!(FileStatus::Busy as u8, 2);
assert_eq!(FileStatus::InvalidTransfer as u8, 3);
assert_eq!(FileStatus::InvalidState as u8, 4);
assert_eq!(FileStatus::InvalidPath as u8, 5);
assert_eq!(FileStatus::NotFound as u8, 6);
assert_eq!(FileStatus::TooLarge as u8, 7);
assert_eq!(FileStatus::InvalidPosition as u8, 8);
assert_eq!(FileStatus::ChecksumMismatch as u8, 9);
assert_eq!(FileStatus::ContentRejected as u8, 10);
assert_eq!(FileStatus::StorageFailure as u8, 11);
assert_eq!(FileStatus::RequestConflict as u8, 12);
assert_eq!(FileStatus::Internal as u8, 13);
}
#[test]
fn control_request_and_response_round_trip_without_untyped_fields() {
let request = FileRequest::Control(ControlRequest {
operation: FileOperation::OpenRead,
target: Target::new(1, 2),
position: 0,
file_size: 0,
checksum_crc32c: 0,
transfer_id: 7,
content: Content::try_new(b"/test/data.bin").unwrap(),
});
let encoded = encode_request(request);
assert_eq!(decode_request(&encoded), Ok(request));
assert_eq!(decode_response(&encoded), Err(DecodeError::WrongDirection));
let response = FileResponse::Control(ControlResponse {
operation: FileOperation::OpenRead,
target: Target::new(42, 7),
position: 0,
file_size: 901,
checksum_crc32c: 0x1234_5678,
transfer_id: 7,
status: FileStatus::Ok,
content: Content::EMPTY,
});
let encoded = encode_response(response);
assert_eq!(decode_response(&encoded), Ok(response));
assert_eq!(decode_request(&encoded), Err(DecodeError::WrongDirection));
}
#[test]
fn data_direction_is_explicit_and_round_trips() {
let request = FileRequest::Data(DataRequest {
target: Target::new(1, 2),
position: 246,
transfer_id: 7,
data: [0x5a; crate::DATA_BYTES],
});
let encoded = encode_request(request);
assert_eq!(decode_request(&encoded), Ok(request));
assert_eq!(decode_response(&encoded), Err(DecodeError::WrongDirection));
let response = FileResponse::Data(DataResponse {
target: Target::new(42, 7),
position: 246,
transfer_id: 7,
data: [0xa5; crate::DATA_BYTES],
});
let encoded = encode_response(response);
assert_eq!(decode_response(&encoded), Ok(response));
assert_eq!(decode_request(&encoded), Err(DecodeError::WrongDirection));
}
}
@@ -173,6 +173,8 @@ where
{
match value {
ConfigValue::Bool(value) => encoder.bool(value)?,
ConfigValue::U8(value) => encoder.u8(value)?,
ConfigValue::U16(value) => encoder.u16(value)?,
ConfigValue::I32(value) => encoder.i32(value)?,
ConfigValue::U32(value) => encoder.u32(value)?,
ConfigValue::U64(value) => encoder.u64(value)?,
@@ -185,6 +187,8 @@ where
fn decode_value(decoder: &mut Decoder<'_>, kind: ValueKind) -> Result<ConfigValue, CborError> {
match kind {
ValueKind::Bool => decoder.bool().map(ConfigValue::Bool),
ValueKind::U8 => decoder.u8().map(ConfigValue::U8),
ValueKind::U16 => decoder.u16().map(ConfigValue::U16),
ValueKind::I32 => decoder.i32().map(ConfigValue::I32),
ValueKind::U32 => decoder.u32().map(ConfigValue::U32),
ValueKind::U64 => decoder.u64().map(ConfigValue::U64),
@@ -224,7 +228,7 @@ mod tests {
}
#[test]
fn encoding_rejects_a_cross_field_invalid_transaction_before_writing() {
fn encoding_rejects_a_cross_field_invalid_candidate_before_writing() {
let capabilities = VehicleCapabilities::quad_x();
let mut candidate = CandidateProfile::factory();
candidate
@@ -229,7 +229,7 @@ impl ConfigModel for CandidateProfile {
}
fn field_count() -> u16 {
u16::try_from(Self::FIELD_COUNT).expect("field registry exceeds the protocol limit")
u16::try_from(Self::FIELD_COUNT).expect("field registry exceeds the artifact limit")
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
@@ -263,8 +263,8 @@ mod tests {
assert_eq!(
crate::SCHEMA_ID,
[
0xa1, 0x4c, 0x28, 0xba, 0x08, 0xc0, 0xfd, 0xc3, 0xe2, 0x54, 0x7d, 0x77, 0x40, 0x3c,
0xed, 0x94,
0xb9, 0x8b, 0xf9, 0xa0, 0x62, 0x3f, 0x6e, 0xef, 0x4e, 0x88, 0xc6, 0xd8, 0x89, 0x4c,
0x76, 0x8b,
]
);
}
@@ -355,7 +355,7 @@ mod tests {
candidate
.set_field(
descriptor_id("mavlink.logging.level_filter"),
ConfigValue::U32(LevelFilter::Off.wire_value().into()),
ConfigValue::U8(LevelFilter::Off.wire_value()),
)
.unwrap();
let active = candidate.validate(&CAPABILITIES).unwrap();
@@ -363,7 +363,7 @@ mod tests {
candidate
.set_field(
descriptor_id("mavlink.logging.level_filter"),
ConfigValue::U32(LevelFilter::Trace.wire_value().into()),
ConfigValue::U8(LevelFilter::Trace.wire_value()),
)
.unwrap();
assert_eq!(
@@ -373,7 +373,7 @@ mod tests {
assert!(matches!(
candidate.set_field(
descriptor_id("mavlink.logging.level_filter"),
ConfigValue::U32(6),
ConfigValue::U8(6),
),
Err(FieldWriteError::Violation(issue))
if issue.rule() == ValidationRule::InvalidEnumValue
@@ -387,7 +387,7 @@ mod tests {
candidate
.set_field(
descriptor_id("mavlink.logging.level_filter"),
ConfigValue::U32(raw),
ConfigValue::U8(raw),
)
.expect("descriptor accepts the stable level filter");
assert_eq!(
@@ -395,7 +395,7 @@ mod tests {
.validate(&CAPABILITIES)
.expect("valid complete profile")
.mavlink_logging(),
LevelFilter::try_from_wire(u8::try_from(raw).unwrap()).unwrap()
LevelFilter::try_from_wire(raw).unwrap()
);
}
}
+39 -36
View File
@@ -7,13 +7,16 @@
#![no_std]
#[cfg(test)]
extern crate std;
use kaptan_config::{ConfigManager, ConfigStore, StartupError};
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities};
use kaptan_log::{LogRecord, LogSinkQueue};
/// Default bounded allocation for the MAVLink diagnostic sink. Error records
/// are partitioned from optional detail.
pub type RuntimeMavlinkLogQueue = LogSinkQueue<8, 16>;
type RuntimeMavlinkLogQueue = LogSinkQueue<8, 16>;
/// One boot-active vehicle runtime. It deliberately has no MAVLink dependency.
pub struct FlightRuntime<S> {
@@ -45,20 +48,9 @@ where
&self.configuration
}
/// Mutably borrows the sole configuration manager for protocol dispatch.
pub fn configuration_mut(&mut self) -> &mut ConfigManager<CandidateProfile, S> {
&mut self.configuration
}
/// Composition-owned fan-out boundary for one preformatted event.
/// A future blackbox adds its independent sink admission here.
/// Composition-owned admission boundary for one preformatted event.
pub fn publish_log_event(&mut self, record: LogRecord) {
let _ = self.mavlink_logs.publish_record(record);
}
/// Peeks the highest-priority pending text log for the sole telemetry link.
pub fn pending_mavlink_report(&self) -> Option<LogRecord> {
self.mavlink_logs.peek()
self.mavlink_logs.publish_record(record);
}
/// Submits the next record and removes it only after transport admission.
@@ -68,25 +60,26 @@ where
) -> Option<R> {
self.mavlink_logs.submit_next(submit)
}
/// Immutable boot-active MAVLink log policy copied from the validated profile.
pub const fn mavlink_log_filter(&self) -> kaptan_log::LevelFilter {
self.mavlink_logs.filter()
}
}
#[cfg(test)]
mod tests {
use kaptan_config::{ConfigModel, ConfigStore, ConfigValue};
use kaptan_log::{Level, LevelFilter, LogFlags, LogRecord, LogText};
use kaptan_log::TransportAdmission;
use std::sync::Mutex;
use super::FlightRuntime;
static LOG_TEST: Mutex<()> = Mutex::new(());
struct Store;
impl ConfigStore<kaptan_flight_config::CandidateProfile> for Store {
type Error = ();
fn load(&mut self) -> Result<Option<kaptan_flight_config::CandidateProfile>, Self::Error> {
fn load(
&mut self,
_: &kaptan_flight_config::CandidateProfile,
) -> Result<Option<kaptan_flight_config::CandidateProfile>, Self::Error> {
Ok(None)
}
@@ -100,6 +93,8 @@ mod tests {
#[test]
fn runtime_uses_the_active_mavlink_policy_and_owns_one_sink_queue() {
let _guard = LOG_TEST.lock().unwrap();
while kaptan_log::drain_one(|_| ()).is_some() {}
let mut runtime = FlightRuntime::recover(
kaptan_flight_config::CandidateProfile::factory(),
kaptan_flight_config::VehicleCapabilities::quad_x(),
@@ -108,24 +103,29 @@ mod tests {
.expect("factory configuration validates");
assert_eq!(
runtime.configuration().active().mavlink_logging(),
runtime.mavlink_log_filter()
kaptan_log::LevelFilter::Info
);
kaptan_log::info!("runtime ready");
runtime.publish_log_event(kaptan_log::drain_one(|record| *record).unwrap());
assert_eq!(
runtime.submit_next_mavlink(|record| {
assert_eq!(record.level(), kaptan_log::Level::Info);
(TransportAdmission::Accepted, ())
}),
Some(())
);
runtime.publish_log_event(LogRecord::new(
Level::Info,
LogFlags::NONE,
LogText::from_message("runtime ready"),
));
assert!(runtime.pending_mavlink_report().is_some());
}
#[test]
fn disabled_mavlink_sink_filters_every_level() {
let _guard = LOG_TEST.lock().unwrap();
while kaptan_log::drain_one(|_| ()).is_some() {}
let mut factory = kaptan_flight_config::CandidateProfile::factory();
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(level_filter.id, ConfigValue::U32(0))
.set_field(level_filter.id, ConfigValue::U8(0))
.unwrap();
let mut runtime = FlightRuntime::recover(
factory,
@@ -134,12 +134,15 @@ mod tests {
)
.expect("disabled MAVLink sink is valid");
assert_eq!(runtime.mavlink_log_filter(), LevelFilter::Off);
runtime.publish_log_event(LogRecord::new(
Level::Error,
LogFlags::NONE,
LogText::from_message("not routed to MAVLink"),
));
assert!(runtime.pending_mavlink_report().is_none());
assert_eq!(
runtime.configuration().active().mavlink_logging(),
kaptan_log::LevelFilter::Off
);
kaptan_log::error!("not routed to MAVLink");
runtime.publish_log_event(kaptan_log::drain_one(|record| *record).unwrap());
assert_eq!(
runtime.submit_next_mavlink(|_| (TransportAdmission::Accepted, ())),
None
);
}
}
+26 -41
View File
@@ -6,7 +6,7 @@ use core::{
sync::atomic::{AtomicBool, Ordering},
};
use crate::{Level, LogFlags, LogRecord, LogText};
use crate::{Level, LogRecord, LogText};
const GLOBAL_INGRESS_CAPACITY: usize = 2;
static GLOBAL_INGRESS: LogIngress<GLOBAL_INGRESS_CAPACITY> = LogIngress::new();
@@ -33,11 +33,11 @@ impl<const N: usize> LogIngress<N> {
}
}
fn publish(&self, level: Level, flags: LogFlags, arguments: fmt::Arguments<'_>) {
fn publish(&self, level: Level, 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 record = LogRecord::new(level, message);
let _ = self.with_state(|entries, head, len| {
if *len < N {
entries[(*head + *len) % N] = Some(record);
@@ -110,8 +110,8 @@ impl<const N: usize> LogIngress<N> {
/// 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);
pub fn publish(level: Level, arguments: fmt::Arguments<'_>) {
GLOBAL_INGRESS.publish(level, arguments);
}
/// Visits and removes the oldest pending log event.
@@ -128,11 +128,16 @@ mod tests {
use core::sync::atomic::Ordering;
use super::{LogIngress, drain_one};
use crate::{Level, LogFlags};
use crate::{Level, LogRecord};
use std::sync::Mutex;
static GLOBAL_INGRESS_TEST: Mutex<()> = Mutex::new(());
fn message(record: &LogRecord) -> &str {
let text = record.message();
core::str::from_utf8(&text.bytes()[..usize::from(text.byte_length())]).unwrap()
}
#[test]
fn every_macro_produces_the_same_log_record_kind() {
let _guard = GLOBAL_INGRESS_TEST
@@ -144,7 +149,7 @@ mod tests {
assert_eq!(drain_one(|record| record.level()), Some(Level::Info));
assert_eq!(
drain_one(|record| {
assert_eq!(record.message().as_str(), "transfer failed");
assert_eq!(message(record), "transfer failed");
record.level()
}),
Some(Level::Error)
@@ -154,29 +159,21 @@ mod tests {
#[test]
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"));
ingress.publish(Level::Info, core::format_args!("advance"));
assert_eq!(
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "advance")),
ingress.drain_one(|record| assert_eq!(message(record), "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"),
);
ingress.publish(Level::Info, core::format_args!("optional"));
ingress.publish(Level::Error, core::format_args!("first error"));
ingress.publish(Level::Error, core::format_args!("second error"));
assert_eq!(
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "first error")),
ingress.drain_one(|record| assert_eq!(message(record), "first error")),
Some(())
);
assert_eq!(
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "second error")),
ingress.drain_one(|record| assert_eq!(message(record), "second error")),
Some(())
);
assert!(ingress.drain_one(|_| ()).is_none());
@@ -185,28 +182,16 @@ mod tests {
#[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"),
);
ingress.publish(Level::Error, core::format_args!("first error"));
ingress.publish(Level::Error, core::format_args!("second error"));
ingress.publish(Level::Error, core::format_args!("discarded error"));
assert_eq!(
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "first error")),
ingress.drain_one(|record| assert_eq!(message(record), "first error")),
Some(())
);
assert_eq!(
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "second error")),
ingress.drain_one(|record| assert_eq!(message(record), "second error")),
Some(())
);
assert!(ingress.drain_one(|_| ()).is_none());
@@ -215,7 +200,7 @@ mod tests {
#[test]
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"));
ingress.publish(Level::Info, core::format_args!("pending"));
ingress.busy.store(true, Ordering::Relaxed);
let mut called = false;
@@ -224,7 +209,7 @@ mod tests {
ingress.busy.store(false, Ordering::Relaxed);
assert_eq!(
ingress.drain_one(|record| assert_eq!(record.message().as_str(), "pending")),
ingress.drain_one(|record| assert_eq!(message(record), "pending")),
Some(())
);
}
+14 -53
View File
@@ -1,21 +1,6 @@
//! Bounded priority handling for log records.
use core::fmt;
use crate::{DeliveryClass, Level, LevelFilter, LogFlags, LogRecord, LogText};
/// Outcome from one bounded log admission attempt.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PublishOutcome {
/// The record was retained.
Queued,
/// The configured level filter excluded the record.
Filtered,
/// A best-effort record was replaced by newer optional information.
ReplacedOldest,
/// The critical lane kept its earlier record.
RejectedFull,
}
use crate::{DeliveryClass, LevelFilter, LogRecord};
/// Result returned after attempting lower-transport admission.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -42,19 +27,17 @@ impl<const N: usize> Lane<N> {
}
}
fn publish(&mut self, record: LogRecord, replace_oldest: bool) -> PublishOutcome {
fn publish(&mut self, record: LogRecord, replace_oldest: bool) {
if self.len == N {
if replace_oldest {
self.entries[self.head] = Some(record);
self.head = (self.head + 1) % N;
return PublishOutcome::ReplacedOldest;
}
return PublishOutcome::RejectedFull;
return;
}
let index = (self.head + self.len) % N;
self.entries[index] = Some(record);
self.len += 1;
PublishOutcome::Queued
}
fn peek(&self) -> Option<LogRecord> {
@@ -89,30 +72,10 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BES
}
}
/// Immutable sink policy.
pub const fn filter(&self) -> LevelFilter {
self.filter
}
/// Formats and retains one log event.
pub fn publish_arguments(
&mut self,
level: Level,
flags: LogFlags,
arguments: fmt::Arguments<'_>,
) -> PublishOutcome {
self.publish_text(level, flags, LogText::from_arguments(arguments))
}
/// 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))
}
/// Applies this sink's filter and retains one record.
pub fn publish_record(&mut self, record: LogRecord) -> PublishOutcome {
pub fn publish_record(&mut self, record: LogRecord) {
if !self.filter.allows(record.level()) {
return PublishOutcome::Filtered;
return;
}
match record.delivery() {
DeliveryClass::Critical => self.critical.publish(record, false),
@@ -145,21 +108,19 @@ impl<const CRITICAL: usize, const BEST_EFFORT: usize> LogSinkQueue<CRITICAL, BES
#[cfg(test)]
mod tests {
use super::{LogSinkQueue, PublishOutcome, TransportAdmission};
use crate::{DeliveryClass, Level, LevelFilter, LogFlags, LogText};
use super::{LogSinkQueue, TransportAdmission};
use crate::{DeliveryClass, Level, LevelFilter, LogRecord, LogText};
#[test]
fn filter_and_priority_apply_only_to_logs() {
let mut queue = LogSinkQueue::<1, 1>::new(LevelFilter::Info);
assert_eq!(
queue.publish_text(Level::Debug, LogFlags::NONE, LogText::from_message("debug")),
PublishOutcome::Filtered
);
assert_eq!(
queue.publish_text(Level::Error, LogFlags::NONE, LogText::from_message("error")),
PublishOutcome::Queued
);
assert_eq!(queue.peek().unwrap().delivery(), DeliveryClass::Critical);
queue.publish_record(LogRecord::new(Level::Debug, LogText::from_message("debug")));
assert!(queue.peek().is_none());
queue.publish_record(LogRecord::new(Level::Error, LogText::from_message("error")));
assert!(matches!(
queue.peek().unwrap().delivery(),
DeliveryClass::Critical
));
assert_eq!(
queue.submit_next(|_| (TransportAdmission::Accepted, ())),
Some(())
+32 -21
View File
@@ -1,19 +1,19 @@
//! Compact conventional log levels and filtering.
/// Severity of one emitted log event.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum Level {
/// A runtime operation failed.
Error = 1,
/// Recoverable warning.
Warn = 2,
/// Fine-grained diagnostic output.
Trace = 1,
/// Diagnostic output.
Debug = 2,
/// Informational operation.
Info = 3,
/// Diagnostic output.
Debug = 4,
/// Fine-grained diagnostic output.
Trace = 5,
/// Recoverable warning.
Warn = 4,
/// A runtime operation failed.
Error = 5,
}
impl Level {
@@ -29,28 +29,28 @@ impl Level {
}
}
/// Conventional maximum log level, including the disabled state.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, kaptan_config_derive::ConfigEnum)]
/// Minimum retained log severity, including the disabled state.
#[derive(Clone, Copy, Debug, Eq, PartialEq, kaptan_config_derive::ConfigEnum)]
#[repr(u8)]
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,
Trace = 1,
/// Accept debug through error events.
Debug = 2,
/// Accept informational through error events.
Info = 3,
/// Accept warning and error events.
Warn = 4,
/// Accept error events only.
Error = 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
!matches!(self, Self::Off) && level as u8 >= self as u8
}
}
@@ -64,6 +64,17 @@ mod tests {
fn levels_are_one_byte_and_filter_conventionally() {
assert_eq!(size_of::<Level>(), 1);
assert_eq!(size_of::<LevelFilter>(), 1);
assert_eq!(LevelFilter::Off as u8, 0);
assert_eq!(LevelFilter::Trace as u8, 1);
assert_eq!(LevelFilter::Debug as u8, 2);
assert_eq!(LevelFilter::Info as u8, 3);
assert_eq!(LevelFilter::Warn as u8, 4);
assert_eq!(LevelFilter::Error as u8, 5);
assert_eq!(Level::Trace as u8, 1);
assert_eq!(Level::Debug as u8, 2);
assert_eq!(Level::Info as u8, 3);
assert_eq!(Level::Warn as u8, 4);
assert_eq!(Level::Error as u8, 5);
assert!(!LevelFilter::Off.allows(Level::Error));
assert!(LevelFilter::Info.allows(Level::Error));
assert!(LevelFilter::Info.allows(Level::Info));
+13 -33
View File
@@ -14,16 +14,16 @@ extern crate self as kaptan_log;
#[cfg(test)]
extern crate std;
pub mod facade;
mod facade;
mod hub;
pub mod level;
pub mod report;
pub mod text;
mod level;
mod report;
mod text;
pub use hub::{LogSinkQueue, PublishOutcome, TransportAdmission};
pub use hub::{LogSinkQueue, TransportAdmission};
pub use level::{Level, LevelFilter};
pub use report::{DeliveryClass, LogFlags, LogRecord};
pub use text::{LogText, MAXIMUM_LOG_TEXT_BYTES};
pub use report::{DeliveryClass, LogRecord};
pub use text::LogText;
pub use facade::drain_one;
#[doc(hidden)]
@@ -33,11 +33,7 @@ pub use facade::publish as __publish;
#[macro_export]
macro_rules! debug {
($($argument:tt)+) => {
$crate::__publish(
$crate::Level::Debug,
$crate::LogFlags::NONE,
core::format_args!($($argument)+),
)
$crate::__publish($crate::Level::Debug, core::format_args!($($argument)+))
};
}
@@ -45,11 +41,7 @@ macro_rules! debug {
#[macro_export]
macro_rules! info {
($($argument:tt)+) => {
$crate::__publish(
$crate::Level::Info,
$crate::LogFlags::NONE,
core::format_args!($($argument)+),
)
$crate::__publish($crate::Level::Info, core::format_args!($($argument)+))
};
}
@@ -57,11 +49,7 @@ macro_rules! info {
#[macro_export]
macro_rules! warn {
($($argument:tt)+) => {
$crate::__publish(
$crate::Level::Warn,
$crate::LogFlags::NONE,
core::format_args!($($argument)+),
)
$crate::__publish($crate::Level::Warn, core::format_args!($($argument)+))
};
}
@@ -69,22 +57,14 @@ macro_rules! warn {
#[macro_export]
macro_rules! error {
($($argument:tt)+) => {
$crate::__publish(
$crate::Level::Error,
$crate::LogFlags::NONE,
core::format_args!($($argument)+),
)
$crate::__publish($crate::Level::Error, core::format_args!($($argument)+))
};
}
/// Emits a trace event, represented as MAVLink Debug plus the trace flag.
/// Emits a trace event.
#[macro_export]
macro_rules! trace {
($($argument:tt)+) => {
$crate::__publish(
$crate::Level::Trace,
$crate::LogFlags::NONE,
core::format_args!($($argument)+),
)
$crate::__publish($crate::Level::Trace, core::format_args!($($argument)+))
};
}
+2 -38
View File
@@ -2,30 +2,7 @@
use crate::{Level, LogText};
/// Flags belonging exclusively to a log record.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(transparent)]
pub struct LogFlags(u8);
impl LogFlags {
/// No optional flags.
pub const NONE: Self = Self(0);
/// Message text exceeded its fixed capacity.
pub const TRUNCATED: Self = Self(1 << 1);
/// Raw transport bitmask.
pub const fn bits(self) -> u8 {
self.0
}
/// Adds a bit.
pub const fn with(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
/// Internal transport priority selected from a conventional log level.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DeliveryClass {
/// Error-level logs.
Critical,
@@ -37,21 +14,13 @@ pub enum DeliveryClass {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogRecord {
level: Level,
flags: LogFlags,
message: LogText,
}
impl LogRecord {
/// 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 {
level,
flags,
message,
}
pub(crate) const fn new(level: Level, message: LogText) -> Self {
Self { level, message }
}
/// Conventional log level.
@@ -59,11 +28,6 @@ impl LogRecord {
self.level
}
/// Log-specific flag bitmask.
pub const fn flags(&self) -> LogFlags {
self.flags
}
/// Fixed human-readable message.
pub const fn message(&self) -> &LogText {
&self.message
+10 -25
View File
@@ -3,7 +3,7 @@
use core::fmt::{self, Write};
/// Maximum bytes retained in one log message.
pub const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
pub(crate) const MAXIMUM_LOG_TEXT_BYTES: usize = 160;
/// Transport-neutral bounded log message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -18,8 +18,7 @@ pub struct LogText {
}
impl LogText {
/// Creates empty fixed text storage.
pub const fn new() -> Self {
const fn new() -> Self {
Self {
bytes: [0; MAXIMUM_LOG_TEXT_BYTES],
length: 0,
@@ -28,14 +27,14 @@ impl LogText {
}
/// Formats one message without allocation.
pub fn from_arguments(arguments: fmt::Arguments<'_>) -> Self {
pub(crate) fn from_arguments(arguments: fmt::Arguments<'_>) -> Self {
let mut text = Self::new();
let _ = fmt::write(&mut text, arguments);
text
}
/// Copies UTF-8 text without splitting a code point at the fixed boundary.
pub fn from_message(message: &str) -> Self {
pub(crate) fn from_message(message: &str) -> Self {
let mut text = Self::new();
let _ = text.write_str(message);
text
@@ -47,32 +46,14 @@ impl LogText {
}
/// Number of meaningful UTF-8 bytes.
pub const fn len(&self) -> u8 {
pub const fn byte_length(&self) -> u8 {
self.length
}
/// Whether this message contains no text.
pub const fn is_empty(&self) -> bool {
self.length == 0
}
/// Whether the input exceeded fixed storage.
pub const fn is_truncated(&self) -> bool {
self.truncated != 0
}
/// Returns the valid UTF-8 prefix.
pub fn as_str(&self) -> &str {
// `write_str` copies only complete code points from an input `&str`.
// SAFETY: every populated byte belongs to that valid UTF-8 prefix.
unsafe { core::str::from_utf8_unchecked(&self.bytes[..usize::from(self.length)]) }
}
}
impl Default for LogText {
fn default() -> Self {
Self::new()
}
}
impl fmt::Write for LogText {
@@ -108,6 +89,10 @@ mod tests {
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);
let retained = &text.bytes()[..usize::from(text.byte_length())];
assert_eq!(
core::str::from_utf8(retained).unwrap().len(),
MAXIMUM_LOG_TEXT_BYTES - 1
);
}
}
-4
View File
@@ -9,18 +9,14 @@ version.workspace = true
workspace = true
[features]
# Common MAVLink telemetry/arm acknowledgement is the default surface.
default = ["dialect-kaptan", "sensor-telemetry"]
# Sensor-domain adapters are physical telemetry only.
sensor-telemetry = ["dep:kaptan-sensor"]
dialect-kaptan = []
[dependencies]
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
kaptan-tx = { path = "../kaptan-tx" }
mavlink = { version = "0.18", default-features = false, features = ["embedded", "dialect-common"] }
mavlink-core = { version = "0.18", default-features = false, features = ["embedded"] }
bitflags = "2"
num-derive = "0.4"
+17 -14
View File
@@ -2,26 +2,29 @@ use std::path::PathBuf;
fn main() {
let manifest_directory = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let dialect = manifest_directory.join("definitions/kaptan.xml");
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");
for definition in [&dialect, &config, &log, &error] {
println!("cargo::rerun-if-changed={}", definition.display());
}
println!("cargo::rustc-check-cfg=cfg(kaptan_has_messages)");
let definitions = manifest_directory.join("definitions");
let dialect = definitions.join("kaptan.xml");
println!("cargo::rerun-if-changed={}", definitions.display());
// The upstream generator conditionally emits derives for these optional
// ecosystem crates. Kaptan deliberately does not expose those incomplete
// features, so tell rustc the generated inactive cfgs are intentional.
println!("cargo::rustc-check-cfg=cfg(feature, values(\"serde\", \"arbitrary\", \"ts-rs\"))");
if std::env::var_os("CARGO_FEATURE_DIALECT_KAPTAN").is_some() {
let output_directory = std::env::var("OUT_DIR").unwrap();
let generated = mavlink_bindgen::generate(
mavlink_bindgen::generate(
mavlink_bindgen::XmlDefinitions::Files(vec![dialect]),
output_directory,
&output_directory,
)
.unwrap();
// Bindgen's index feature-gates every dialect module. Kaptan has exactly
// one unconditional dialect, so load its generated file as a normal Rust
// module instead of exposing a redundant Cargo feature.
std::fs::write(
PathBuf::from(output_directory).join("kaptan_module.rs"),
"#[allow(non_camel_case_types, non_snake_case)]\n\
#[allow(clippy::derive_partial_eq_without_eq, clippy::field_reassign_with_default)]\n\
#[allow(clippy::unnecessary_cast, clippy::bad_bit_mask)]\n\
#[allow(clippy::suspicious_else_formatting)]\n\
#[path = \"kaptan.rs\"]\npub mod kaptan;\n",
)
.unwrap();
mavlink_bindgen::emit_cargo_build_messages(&generated);
println!("cargo::rustc-cfg=kaptan_has_messages");
}
}
Whitespace-only changes.
@@ -1,8 +1,13 @@
<?xml version="1.0"?>
<mavlink>
<!-- The root is the only bindgen input. Feature domains remain independent
fragments so their message catalogs can evolve without a monolith. -->
<include>kaptan/config.xml</include>
<include>kaptan/log.xml</include>
<include>kaptan/error.xml</include>
<!-- The root is the only bindgen input. Every current message has one leaf
under its interaction contract. exchange/ intentionally has no message. -->
<include>publish/log.xml</include>
<include>publish/error.xml</include>
<include>publish/heartbeat.xml</include>
<include>publish/imu.xml</include>
<include>publish/range.xml</include>
<include>publish/optical_flow.xml</include>
<include>transfer/file_control.xml</include>
<include>transfer/file_data.xml</include>
</mavlink>
@@ -1,237 +0,0 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<!-- Kaptan configuration dialect. Register the development IDs before release. 42000
belongs to Icarous and is deliberately not used here. -->
<enums>
<enum name="KAPTAN_CONFIG_VALUE_KIND">
<entry value="1" name="KAPTAN_CONFIG_VALUE_KIND_BOOL"/>
<entry value="2" name="KAPTAN_CONFIG_VALUE_KIND_I32"/>
<entry value="3" name="KAPTAN_CONFIG_VALUE_KIND_U32"/>
<entry value="4" name="KAPTAN_CONFIG_VALUE_KIND_U64"/>
<entry value="5" name="KAPTAN_CONFIG_VALUE_KIND_F32"/>
<entry value="6" name="KAPTAN_CONFIG_VALUE_KIND_F64"/>
</enum>
<enum name="KAPTAN_CONFIG_STATE">
<entry value="0" name="KAPTAN_CONFIG_STATE_ACTIVE"/>
<entry value="1" name="KAPTAN_CONFIG_STATE_EDITING"/>
<entry value="2" name="KAPTAN_CONFIG_STATE_RESTART_REQUIRED"/>
</enum>
<enum name="KAPTAN_CONFIG_VALUE_SOURCE">
<entry value="0" name="KAPTAN_CONFIG_VALUE_SOURCE_ACTIVE"/>
<entry value="1" name="KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED"/>
<entry value="2" name="KAPTAN_CONFIG_VALUE_SOURCE_TRANSACTION"/>
</enum>
<enum name="KAPTAN_CONFIG_OPERATION">
<entry value="1" name="KAPTAN_CONFIG_OPERATION_QUERY"/>
<entry value="2" name="KAPTAN_CONFIG_OPERATION_BEGIN"/>
<entry value="3" name="KAPTAN_CONFIG_OPERATION_SET"/>
<entry value="4" name="KAPTAN_CONFIG_OPERATION_VALIDATE"/>
<entry value="5" name="KAPTAN_CONFIG_OPERATION_COMMIT"/>
<entry value="6" name="KAPTAN_CONFIG_OPERATION_ABORT"/>
</enum>
<enum name="KAPTAN_CONFIG_RESULT">
<entry value="0" name="KAPTAN_CONFIG_RESULT_ACCEPTED"/>
<entry value="1" name="KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED"/>
<entry value="2" name="KAPTAN_CONFIG_RESULT_REJECTED"/>
</enum>
<enum name="KAPTAN_CONFIG_ERROR">
<entry value="0" name="KAPTAN_CONFIG_ERROR_NONE"/>
<entry value="1" name="KAPTAN_CONFIG_ERROR_BAD_TARGET"/>
<entry value="4" name="KAPTAN_CONFIG_ERROR_SESSION_BUSY">
<description>The target configuration lease or transaction slot belongs to a client.</description>
</entry>
<entry value="5" name="KAPTAN_CONFIG_ERROR_INVALID_SESSION"/>
<entry value="6" name="KAPTAN_CONFIG_ERROR_STALE_REVISION"/>
<entry value="7" name="KAPTAN_CONFIG_ERROR_BAD_STATE"/>
<entry value="8" name="KAPTAN_CONFIG_ERROR_UNKNOWN_FIELD"/>
<entry value="9" name="KAPTAN_CONFIG_ERROR_TYPE_MISMATCH"/>
<entry value="10" name="KAPTAN_CONFIG_ERROR_NON_FINITE"/>
<entry value="11" name="KAPTAN_CONFIG_ERROR_BELOW_MINIMUM"/>
<entry value="12" name="KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM"/>
<entry value="13" name="KAPTAN_CONFIG_ERROR_CROSS_FIELD"/>
<entry value="14" name="KAPTAN_CONFIG_ERROR_UNSUPPORTED_CAPABILITY"/>
<entry value="16" name="KAPTAN_CONFIG_ERROR_STORAGE_FAILURE"/>
<entry value="17" name="KAPTAN_CONFIG_ERROR_INTERNAL"/>
<entry value="18" name="KAPTAN_CONFIG_ERROR_REQUEST_CONFLICT"/>
<entry value="24" name="KAPTAN_CONFIG_ERROR_INVALID_QUERY"/>
<entry value="25" name="KAPTAN_CONFIG_ERROR_INVALID_ENUM_VALUE"/>
</enum>
</enums>
<messages>
<message id="51000" name="KAPTAN_CONFIG_INFO">
<description>Correlated configuration protocol and schema metadata.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint8_t[16]" name="schema_id"/>
<field type="uint16_t" name="field_count"/>
<field type="uint8_t" name="protocol_major"/>
<field type="uint8_t" name="protocol_minor"/>
<field type="uint8_t" name="config_state" enum="KAPTAN_CONFIG_STATE"/>
</message>
<message id="51014" name="KAPTAN_CONFIG_INFO_REQUEST">
<description>Requests correlated configuration protocol and schema metadata.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51001" name="KAPTAN_CONFIG_DESCRIPTOR_REQUEST">
<description>Requests one field descriptor by stable schema index.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint16_t" name="descriptor_index"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51002" name="KAPTAN_CONFIG_DESCRIPTOR">
<description>One self-describing editable scalar field.</description>
<field type="uint64_t" name="minimum_bits"/>
<field type="uint64_t" name="maximum_bits"/>
<field type="uint64_t" name="default_bits"/>
<field type="uint32_t" name="request_id"/>
<field type="uint16_t" name="descriptor_index"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="enum_option_count">Number of named enum choices available for indexed retrieval.</field>
<field type="char[48]" name="display_key"/>
<field type="char[16]" name="unit"/>
<field type="uint8_t" name="value_kind" enum="KAPTAN_CONFIG_VALUE_KIND"/>
<field type="uint8_t" name="flags">Bit 0 marks minimum_bits present; bit 1 marks maximum_bits present. Other bits are reserved.</field>
</message>
<message id="51023" name="KAPTAN_CONFIG_ENUM_OPTION">
<description>One named choice for the explicitly identified enum-backed descriptor.</description>
<field type="uint8_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint16_t" name="descriptor_index"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="option_index"/>
<field type="char[32]" name="label"/>
</message>
<message id="51024" name="KAPTAN_CONFIG_ENUM_OPTION_REQUEST">
<description>Requests one named choice by descriptor and option index.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint16_t" name="descriptor_index"/>
<field type="uint8_t" name="option_index"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51003" name="KAPTAN_CONFIG_VALUE_REQUEST">
<description>Reads one active, committed, or transaction value.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="source" enum="KAPTAN_CONFIG_VALUE_SOURCE"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51004" name="KAPTAN_CONFIG_VALUE">
<description>One typed configuration scalar as canonical raw bits.</description>
<field type="uint64_t" name="value_bits"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="value_kind" enum="KAPTAN_CONFIG_VALUE_KIND"/>
</message>
<message id="51005" name="KAPTAN_CONFIG_BEGIN">
<description>Opens the one lease-bound edit transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51006" name="KAPTAN_CONFIG_SET_F32">
<description>Writes one f32 field in the open transaction.</description>
<field type="float" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51007" name="KAPTAN_CONFIG_SET_I32">
<description>Writes one i32 field in the open transaction.</description>
<field type="int32_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51008" name="KAPTAN_CONFIG_SET_U32">
<description>Writes one u32 field in the open transaction.</description>
<field type="uint32_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51009" name="KAPTAN_CONFIG_SET_U64">
<description>Writes one u64 field in the open transaction.</description>
<field type="uint64_t" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51010" name="KAPTAN_CONFIG_VALIDATE">
<description>Validates the complete open transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51011" name="KAPTAN_CONFIG_COMMIT">
<description>Validates then atomically persists the open transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51012" name="KAPTAN_CONFIG_ABORT">
<description>Discards the open RAM transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51013" name="KAPTAN_CONFIG_RESULT">
<description>Authoritative typed completion for a configuration request.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="revision"/>
<field type="uint16_t" name="primary_field"/>
<field type="uint16_t" name="related_field"/>
<field type="uint16_t" name="validation_rule"/>
<field type="uint8_t" name="operation" enum="KAPTAN_CONFIG_OPERATION"/>
<field type="uint8_t" name="result" enum="KAPTAN_CONFIG_RESULT"/>
<field type="uint8_t" name="error" enum="KAPTAN_CONFIG_ERROR"/>
<field type="uint8_t" name="config_state" enum="KAPTAN_CONFIG_STATE"/>
</message>
<message id="51015" name="KAPTAN_CONFIG_SET_BOOL">
<description>Writes one boolean field in the open transaction.</description>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="value"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
<message id="51016" name="KAPTAN_CONFIG_SET_F64">
<description>Writes one f64 field in the open transaction.</description>
<field type="double" name="value"/>
<field type="uint32_t" name="request_id"/>
<field type="uint32_t" name="session_id"/>
<field type="uint32_t" name="expected_revision"/>
<field type="uint16_t" name="field_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
</message>
</messages>
</mavlink>
@@ -2,10 +2,9 @@
<mavlink>
<version>3</version>
<dialect>0</dialect>
<!-- Kaptan-reserved development block. Register these IDs before release. -->
<messages>
<message id="51021" name="KAPTAN_ERROR">
<description>Reportable firmware error emitted when an Err leaves an annotated reporting point. 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>
<message id="51021" name="ERROR">
<description>Reportable firmware error emitted when an Err leaves an annotated reporting point. This is distinct from every logging macro, which emits LOG. The stable ID is explicitly assigned to the Rust error variant. Protocol failures remain authoritative in their owning response.</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 reporting point.</field>
</message>
@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<enums>
<enum name="HEARTBEAT_STATE">
<entry value="1" name="HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED">
<description>Initialization completed and the physical output routes remain inhibited.</description>
</entry>
</enum>
</enums>
<messages>
<message id="51027" name="HEARTBEAT">
<description>Periodic liveness and discovery publication for the initialized output-inhibited firmware composition.</description>
<field type="uint8_t" name="state" enum="HEARTBEAT_STATE"/>
</message>
</messages>
</mavlink>
@@ -0,0 +1,14 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<messages>
<message id="51028" name="IMU">
<description>One accepted body-frame inertial observation in the native bounded sensor representation.</description>
<field type="uint64_t" name="time_us">Flight-controller monotonic acquisition time in microseconds.</field>
<field type="int16_t[3]" name="acceleration_milligravity">Body-frame acceleration in milligravity.</field>
<field type="int16_t[3]" name="angular_rate_milliradians_per_second">Body-frame angular rate in milliradians per second.</field>
<field type="int16_t" name="temperature_centidegrees_celsius">Sensor temperature in centidegrees Celsius.</field>
</message>
</messages>
</mavlink>
@@ -2,22 +2,28 @@
<mavlink>
<version>3</version>
<dialect>0</dialect>
<!-- Kaptan-reserved development block. Register these IDs before release. -->
<enums>
<enum name="KAPTAN_LOG_FLAGS" bitmask="true">
<entry value="1" name="KAPTAN_LOG_FLAG_TRACE">
<description>Record originated at Kaptan trace level and uses MAVLink DEBUG severity.</description>
<enum name="LOG_LEVEL">
<entry value="0" name="LOG_LEVEL_OFF">
<description>Filter-only sentinel. A LOG event must not use this value.</description>
</entry>
<entry value="2" name="KAPTAN_LOG_FLAG_TRUNCATED">
<entry value="1" name="LOG_LEVEL_TRACE"/>
<entry value="2" name="LOG_LEVEL_DEBUG"/>
<entry value="3" name="LOG_LEVEL_INFO"/>
<entry value="4" name="LOG_LEVEL_WARN"/>
<entry value="5" name="LOG_LEVEL_ERROR"/>
</enum>
<enum name="LOG_FLAGS" bitmask="true">
<entry value="1" name="LOG_FLAG_TRUNCATED">
<description>Message text was truncated to the bounded on-wire buffer.</description>
</entry>
</enum>
</enums>
<messages>
<message id="51020" name="KAPTAN_LOG">
<message id="51020" name="LOG">
<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="level" enum="LOG_LEVEL">Kaptan wire level.</field>
<field type="uint8_t" name="flags" enum="LOG_FLAGS">TRUNCATED=1. Receivers ignore unrecognized 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>
</message>
@@ -0,0 +1,14 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<messages>
<message id="51030" name="OPTICAL_FLOW">
<description>One accepted ground-referenced optical-flow observation from the board's selected vertical sensor route.</description>
<field type="uint64_t" name="time_us">Flight-controller monotonic reception time in microseconds.</field>
<field type="int32_t[2]" name="velocity_millimetres_per_second">Ground-referenced sensor-frame X/Y velocity in millimetres per second.</field>
<field type="uint32_t" name="ground_distance_millimetres">Ground distance in millimetres.</field>
<field type="uint8_t" name="quality">Device-reported observation quality.</field>
</message>
</messages>
</mavlink>
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<messages>
<message id="51029" name="RANGE">
<description>One accepted downward range observation from the board's selected vertical sensor route.</description>
<field type="uint64_t" name="time_us">Flight-controller monotonic measurement-request time in microseconds.</field>
<field type="uint32_t" name="distance_millimetres">Measured downward distance in millimetres.</field>
</message>
</messages>
</mavlink>
@@ -0,0 +1,46 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<enums>
<enum name="FILE_OPERATION">
<entry value="1" name="FILE_OPERATION_OPEN_READ"/>
<entry value="2" name="FILE_OPERATION_OPEN_WRITE"/>
<entry value="3" name="FILE_OPERATION_READ"/>
<entry value="4" name="FILE_OPERATION_WRITE_ACK"/>
<entry value="5" name="FILE_OPERATION_CLOSE"/>
<entry value="6" name="FILE_OPERATION_ABORT"/>
</enum>
<enum name="FILE_STATUS">
<entry value="0" name="FILE_STATUS_UNSPECIFIED"/>
<entry value="1" name="FILE_STATUS_OK"/>
<entry value="2" name="FILE_STATUS_BUSY"/>
<entry value="3" name="FILE_STATUS_INVALID_TRANSFER"/>
<entry value="4" name="FILE_STATUS_INVALID_STATE"/>
<entry value="5" name="FILE_STATUS_INVALID_PATH"/>
<entry value="6" name="FILE_STATUS_NOT_FOUND"/>
<entry value="7" name="FILE_STATUS_TOO_LARGE"/>
<entry value="8" name="FILE_STATUS_INVALID_POSITION"/>
<entry value="9" name="FILE_STATUS_CHECKSUM_MISMATCH"/>
<entry value="10" name="FILE_STATUS_CONTENT_REJECTED"/>
<entry value="11" name="FILE_STATUS_STORAGE_FAILURE"/>
<entry value="12" name="FILE_STATUS_REQUEST_CONFLICT"/>
<entry value="13" name="FILE_STATUS_INTERNAL"/>
</enum>
</enums>
<messages>
<message id="51025" name="FILE_CONTROL">
<description>Opens, advances, completes, or aborts one bounded Kaptan virtual-file transfer.</description>
<field type="uint32_t" name="position">Requested or next accepted byte position.</field>
<field type="uint32_t" name="file_size">Exact complete file size advertised when the transfer is opened.</field>
<field type="uint32_t" name="checksum_crc32c">CRC-32C of the complete file.</field>
<field type="uint16_t" name="transfer_id">Client-selected nonzero transfer identity retained for the complete transfer and not reused by that source during the ten-second replay lease.</field>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
<field type="uint8_t" name="operation" enum="FILE_OPERATION"/>
<field type="uint8_t" name="status" enum="FILE_STATUS">Requests use UNSPECIFIED; responses carry the authoritative outcome.</field>
<field type="uint8_t" name="content_length">Logical bytes initialized in content.</field>
<field type="uint8_t[64]" name="content">Canonical path for open requests or bounded outcome detail in responses.</field>
</message>
</messages>
</mavlink>
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<mavlink>
<version>3</version>
<dialect>0</dialect>
<messages>
<message id="51026" name="FILE_DATA">
<description>One directed file chunk. Its logical length is min(246, file_size - position) from the open transfer.</description>
<field type="uint32_t" name="position"/>
<field type="uint16_t" name="transfer_id"/>
<field type="uint8_t" name="target_system"/>
<field type="uint8_t" name="target_component"/>
<field type="uint8_t" name="status" enum="FILE_STATUS">Upload requests use UNSPECIFIED; read responses use OK.</field>
<field type="uint8_t[246]" name="data"/>
</message>
</messages>
</mavlink>
@@ -1,102 +0,0 @@
//! Narrow MAVLink arm/disarm command decoding.
//!
//! This module deliberately accepts no actuator-value or motor-test command.
//! A board must pass accepted requests to its single actuator-state owner; this
//! decoder has no access to output hardware.
use mavlink::Message;
use crate::{MavlinkFrame, message};
/// A requested change to the vehicle's arming state.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ArmRequest {
/// Request the armed-idle state.
Arm,
/// Request electrically inactive outputs.
Disarm,
}
/// Decodes a targeted MAVLink `MAV_CMD_COMPONENT_ARM_DISARM` command.
///
/// Only `param1` values exactly equal to `0.0` (disarm) or `1.0` (arm) are
/// accepted. Broadcast targets are intentionally rejected: an actuator command
/// must name this vehicle and its component explicitly.
pub fn decode_arm_request(
frame: &MavlinkFrame,
system_id: u8,
component_id: u8,
) -> Option<ArmRequest> {
let message = message::MavMessage::parse(
mavlink::MavlinkVersion::V2,
frame.message_id(),
frame.payload(),
)
.ok()?;
let message::MavMessage::COMMAND_LONG(command) = message else {
return None;
};
if command.command != message::MavCmd::MAV_CMD_COMPONENT_ARM_DISARM
|| command.target_system != system_id
|| command.target_component != component_id
{
return None;
}
match command.param1 {
0.0 => Some(ArmRequest::Disarm),
1.0 => Some(ArmRequest::Arm),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::{ArmRequest, decode_arm_request};
use crate::{MavlinkFrame, message};
fn command(target_system: u8, target_component: u8, value: f32) -> MavlinkFrame {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
message::MavHeader {
sequence: 0,
system_id: 42,
component_id: 7,
},
&message::COMMAND_LONG_DATA {
param1: value,
param2: 0.0,
param3: 0.0,
param4: 0.0,
param5: 0.0,
param6: 0.0,
param7: 0.0,
command: message::MavCmd::MAV_CMD_COMPONENT_ARM_DISARM,
target_system,
target_component,
confirmation: 0,
},
);
frame
}
#[test]
fn accepts_explicit_targeted_arm_and_disarm() {
assert_eq!(
decode_arm_request(&command(1, 1, 1.0), 1, 1),
Some(ArmRequest::Arm)
);
assert_eq!(
decode_arm_request(&command(1, 1, 0.0), 1, 1),
Some(ArmRequest::Disarm)
);
}
#[test]
fn rejects_broadcast_wrong_target_and_invalid_parameter() {
assert_eq!(decode_arm_request(&command(0, 0, 1.0), 1, 1), None);
assert_eq!(decode_arm_request(&command(2, 1, 1.0), 1, 1), None);
assert_eq!(decode_arm_request(&command(1, 1, 0.5), 1, 1), None);
}
}
@@ -1,4 +0,0 @@
//! MAVLink command decoders.
/// Targeted arm/disarm command decoding.
pub mod arm;
+154 -149
View File
@@ -6,23 +6,14 @@ use kaptan_sensor::imu::ImuSnapshot;
use kaptan_sensor::optical_flow::OpticalFlowSnapshot;
#[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::range::RangeSnapshot;
use mavlink::MessageData;
use mavlink_core::{MavHeader, MessageData};
use crate::{MavlinkFrame, message};
/// Board-selected MAVLink metadata for a range sensor.
#[cfg(feature = "sensor-telemetry")]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DistanceSensorConfig {
/// MAVLink sensor instance identifier.
pub sensor_id: u8,
/// Sensor mounting orientation.
pub orientation: message::MavSensorOrientation,
/// Lower valid measurement bound, in centimetres.
pub minimum_distance_centimetres: u16,
/// Upper valid measurement bound, in centimetres.
pub maximum_distance_centimetres: u16,
}
use crate::generated::kaptan::{IMU_DATA, OPTICAL_FLOW_DATA, RANGE_DATA};
use crate::{
MavlinkFrame,
generated::kaptan::{HEARTBEAT_DATA, HeartbeatState, MavMessage},
};
/// Serializes FC-domain data with MAVLink sender identity and sequence state.
pub struct MavlinkEncoder {
@@ -34,6 +25,10 @@ pub struct MavlinkEncoder {
impl MavlinkEncoder {
/// Creates an encoder beginning with MAVLink packet sequence zero.
pub const fn new(system_id: u8, component_id: u8) -> Self {
assert!(
system_id != 0 && component_id != 0,
"MAVLink source must be exact"
);
Self {
system_id,
component_id,
@@ -41,124 +36,78 @@ impl MavlinkEncoder {
}
}
fn serialize<M: MessageData>(&mut self, message: &M) -> MavlinkFrame {
let mut raw = MavlinkFrame::new();
raw.serialize_message_data(
crate::message::MavHeader {
fn header(&mut self) -> MavHeader {
let header = MavHeader {
sequence: self.next_sequence,
system_id: self.system_id,
component_id: self.component_id,
},
message,
);
};
self.next_sequence = self.next_sequence.wrapping_add(1);
header
}
/// Encodes one concrete generated Kaptan record through this endpoint's
/// sole header-sequence owner.
pub fn encode<M: MessageData<Message = MavMessage>>(&mut self, data: &M) -> MavlinkFrame {
let mut raw = MavlinkFrame::new();
raw.serialize_message_data(self.header(), data);
raw
}
/// Encodes one generated MAVLink data record through this endpoint's sole
/// header-sequence owner. Runtime-log/config adapters return data records
/// and must use this method rather than create a competing encoder.
pub fn custom<M: MessageData>(&mut self, message: &M) -> MavlinkFrame {
self.serialize(message)
}
/// Encodes a board-I/O heartbeat with its current arming state.
pub fn heartbeat(&mut self, system_status: message::MavState, armed: bool) -> MavlinkFrame {
let heartbeat = message::HEARTBEAT_DATA {
custom_mode: 0,
mavtype: message::MavType::MAV_TYPE_ONBOARD_CONTROLLER,
autopilot: message::MavAutopilot::MAV_AUTOPILOT_INVALID,
base_mode: if armed {
message::MavModeFlag::MAV_MODE_FLAG_SAFETY_ARMED
} else {
message::MavModeFlag::empty()
},
system_status,
mavlink_version: 3,
/// Encodes the current initialized, output-inhibited heartbeat.
pub fn heartbeat(&mut self) -> MavlinkFrame {
let heartbeat = HEARTBEAT_DATA {
state: HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED,
};
self.serialize(&heartbeat)
self.encode(&heartbeat)
}
/// Encodes a standard `RAW_IMU` frame.
/// Encodes one native Kaptan inertial observation.
#[cfg(feature = "sensor-telemetry")]
pub fn raw_imu_snapshot(&mut self, snapshot: &ImuSnapshot) -> MavlinkFrame {
let raw_imu = message::RAW_IMU_DATA {
time_usec: snapshot.timestamp.as_microseconds(),
xacc: snapshot.acceleration_milligravity[0],
yacc: snapshot.acceleration_milligravity[1],
zacc: snapshot.acceleration_milligravity[2],
xgyro: snapshot.angular_rate_milliradians_per_second[0],
ygyro: snapshot.angular_rate_milliradians_per_second[1],
zgyro: snapshot.angular_rate_milliradians_per_second[2],
xmag: 0,
ymag: 0,
zmag: 0,
pub fn imu_snapshot(&mut self, snapshot: &ImuSnapshot) -> MavlinkFrame {
let imu = IMU_DATA {
time_us: snapshot.timestamp.as_microseconds(),
acceleration_milligravity: snapshot.acceleration_milligravity,
angular_rate_milliradians_per_second: snapshot.angular_rate_milliradians_per_second,
temperature_centidegrees_celsius: snapshot.temperature_centidegrees_celsius,
};
self.serialize(&raw_imu)
self.encode(&imu)
}
/// Encodes a standard `DISTANCE_SENSOR` frame for a laser rangefinder.
///
/// MAVLink represents distance in centimetres; the sensor domain keeps
/// millimetres so this conversion happens only at the protocol boundary.
/// Encodes one native Kaptan downward-range observation.
#[cfg(feature = "sensor-telemetry")]
pub fn distance_sensor_snapshot(
&mut self,
snapshot: &RangeSnapshot,
config: DistanceSensorConfig,
) -> MavlinkFrame {
let current_distance = (snapshot.distance_millimetres / 10).clamp(
u32::from(config.minimum_distance_centimetres),
u32::from(config.maximum_distance_centimetres),
) as u16;
let distance_sensor = message::DISTANCE_SENSOR_DATA {
time_boot_ms: (snapshot.timestamp.as_microseconds() / 1_000) as u32,
min_distance: config.minimum_distance_centimetres,
max_distance: config.maximum_distance_centimetres,
current_distance,
mavtype: message::MavDistanceSensor::MAV_DISTANCE_SENSOR_LASER,
id: config.sensor_id,
orientation: config.orientation,
covariance: u8::MAX,
pub fn range_snapshot(&mut self, snapshot: &RangeSnapshot) -> MavlinkFrame {
let range = RANGE_DATA {
time_us: snapshot.timestamp.as_microseconds(),
distance_millimetres: snapshot.distance_millimetres,
};
self.serialize(&distance_sensor)
self.encode(&range)
}
/// Encodes a standard `OPTICAL_FLOW` frame from a ground-referenced flow
/// observation.
/// Encodes one native Kaptan ground-referenced optical-flow observation.
#[cfg(feature = "sensor-telemetry")]
pub fn optical_flow_snapshot(
&mut self,
snapshot: &OpticalFlowSnapshot,
sensor_id: u8,
) -> MavlinkFrame {
let optical_flow = message::OPTICAL_FLOW_DATA {
time_usec: snapshot.timestamp.as_microseconds(),
sensor_id,
// Kaptan's sensor domain contains ground-referenced velocity,
// not unscaled image-pixel displacement.
flow_x: 0,
flow_y: 0,
flow_comp_m_x: snapshot.velocity_millimetres_per_second[0] as f32 / 1_000.0,
flow_comp_m_y: snapshot.velocity_millimetres_per_second[1] as f32 / 1_000.0,
pub fn optical_flow_snapshot(&mut self, snapshot: &OpticalFlowSnapshot) -> MavlinkFrame {
let optical_flow = OPTICAL_FLOW_DATA {
time_us: snapshot.timestamp.as_microseconds(),
velocity_millimetres_per_second: snapshot.velocity_millimetres_per_second,
ground_distance_millimetres: snapshot.ground_distance_millimetres,
quality: snapshot.quality,
ground_distance: snapshot.ground_distance_millimetres as f32 / 1_000.0,
};
self.serialize(&optical_flow)
self.encode(&optical_flow)
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "sensor-telemetry")]
use super::DistanceSensorConfig;
use super::MavlinkEncoder;
use crate::message::MavState;
use crate::{
MavlinkFrame,
generated::kaptan::{
ERROR_DATA, FILE_CONTROL_DATA, FILE_DATA_DATA, HEARTBEAT_DATA, HeartbeatState,
IMU_DATA, LOG_DATA, LogFlags, LogLevel, MavMessage, OPTICAL_FLOW_DATA, RANGE_DATA,
},
receiver::IntegrityMavlinkReceiver,
};
#[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::imu::ImuSnapshot;
#[cfg(feature = "sensor-telemetry")]
@@ -168,11 +117,20 @@ mod tests {
#[cfg(feature = "sensor-telemetry")]
use kaptan_time::Timestamp;
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received()?.decode()
}
#[test]
fn heartbeat_has_the_configured_sender_and_sequence() {
let mut encoder = MavlinkEncoder::new(42, 7);
let frame = encoder.heartbeat(MavState::MAV_STATE_STANDBY, false);
let bytes = frame.raw_bytes();
let first = encoder.heartbeat();
let second = encoder.heartbeat();
let bytes = first.raw_bytes();
assert_eq!(bytes[0], 0xFD);
assert_eq!(bytes[2], 0);
@@ -180,15 +138,14 @@ mod tests {
assert_eq!(bytes[4], 0);
assert_eq!(bytes[5], 42);
assert_eq!(bytes[6], 7);
assert_eq!(&bytes[7..10], &[0, 0, 0]);
}
#[test]
fn heartbeat_preserves_the_requested_health_state() {
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.heartbeat(MavState::MAV_STATE_CRITICAL, false);
assert_eq!(frame.raw_bytes()[17], MavState::MAV_STATE_CRITICAL as u8);
assert_eq!(&bytes[7..10], &[0x53, 0xc7, 0]);
assert_eq!(second.raw_bytes()[4], 1);
assert!(matches!(
parse_test_message(&first),
Some(MavMessage::HEARTBEAT(data))
if data.state
== HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED
));
}
#[cfg(feature = "sensor-telemetry")]
@@ -202,45 +159,36 @@ mod tests {
2_500,
);
let frame = encoder.raw_imu_snapshot(&snapshot);
let frame = encoder.imu_snapshot(&snapshot);
assert_eq!(&frame.raw_bytes()[7..10], &[27, 0, 0]);
}
#[test]
fn armed_heartbeat_sets_the_standard_armed_flag() {
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.heartbeat(MavState::MAV_STATE_ACTIVE, true);
assert_ne!(frame.raw_bytes()[16] & 0x80, 0);
assert!(matches!(
parse_test_message(&frame),
Some(MavMessage::IMU(data))
if data.time_us == 123
&& data.acceleration_milligravity == [1, 2, 3]
&& data.angular_rate_milliradians_per_second == [4, 5, 6]
&& data.temperature_centidegrees_celsius == 2_500
));
}
#[cfg(feature = "sensor-telemetry")]
#[test]
fn range_snapshot_uses_distance_sensor_centimetres() {
fn range_snapshot_preserves_native_time_and_distance() {
let mut encoder = MavlinkEncoder::new(1, 1);
let snapshot = RangeSnapshot::new(Timestamp::from_microseconds(123_000), 2_910);
let frame = encoder.distance_sensor_snapshot(
&snapshot,
DistanceSensorConfig {
sensor_id: 0,
orientation: crate::message::MavSensorOrientation::MAV_SENSOR_ROTATION_PITCH_270,
minimum_distance_centimetres: 20,
maximum_distance_centimetres: 4_000,
},
);
let bytes = frame.raw_bytes();
let frame = encoder.range_snapshot(&snapshot);
assert_eq!(&bytes[7..10], &[132, 0, 0]);
// MAVLink payload starts after the ten-byte MAVLink 2 header. The
// current distance field follows time, minimum, and maximum range.
assert_eq!(&bytes[18..20], &291u16.to_le_bytes());
assert!(matches!(
parse_test_message(&frame),
Some(MavMessage::RANGE(data))
if data.time_us == 123_000 && data.distance_millimetres == 2_910
));
}
#[cfg(feature = "sensor-telemetry")]
#[test]
fn optical_flow_snapshot_uses_the_optical_flow_message() {
fn optical_flow_snapshot_preserves_native_integer_observation() {
let mut encoder = MavlinkEncoder::new(1, 1);
let snapshot = OpticalFlowSnapshot::new(
Timestamp::from_microseconds(123_000),
@@ -249,13 +197,70 @@ mod tests {
200,
);
let frame = encoder.optical_flow_snapshot(&snapshot, 3);
let bytes = frame.raw_bytes();
let frame = encoder.optical_flow_snapshot(&snapshot);
assert_eq!(&bytes[7..10], &[100, 0, 0]);
// Optical-flow payload begins after the MAVLink 2 header.
assert_eq!(&bytes[10..18], &123_000_u64.to_le_bytes());
assert_eq!(&bytes[18..22], &1.0_f32.to_le_bytes());
assert_eq!(&bytes[22..26], &(-0.5_f32).to_le_bytes());
assert!(matches!(
parse_test_message(&frame),
Some(MavMessage::OPTICAL_FLOW(data))
if data.time_us == 123_000
&& data.velocity_millimetres_per_second == [1_000, -500]
&& data.ground_distance_millimetres == 2_000
&& data.quality == 200
));
}
#[test]
fn dialect_wire_contract_is_stable() {
use mavlink_core::MessageData;
assert_eq!(HEARTBEAT_DATA::NAME, "HEARTBEAT");
assert_eq!(HEARTBEAT_DATA::ID, 51_027);
assert_eq!(HEARTBEAT_DATA::EXTRA_CRC, 161);
assert_eq!(HEARTBEAT_DATA::ENCODED_LEN, 1);
assert_eq!(IMU_DATA::NAME, "IMU");
assert_eq!(IMU_DATA::ID, 51_028);
assert_eq!(IMU_DATA::EXTRA_CRC, 194);
assert_eq!(IMU_DATA::ENCODED_LEN, 22);
assert_eq!(RANGE_DATA::NAME, "RANGE");
assert_eq!(RANGE_DATA::ID, 51_029);
assert_eq!(RANGE_DATA::EXTRA_CRC, 44);
assert_eq!(RANGE_DATA::ENCODED_LEN, 12);
assert_eq!(OPTICAL_FLOW_DATA::NAME, "OPTICAL_FLOW");
assert_eq!(OPTICAL_FLOW_DATA::ID, 51_030);
assert_eq!(OPTICAL_FLOW_DATA::EXTRA_CRC, 202);
assert_eq!(OPTICAL_FLOW_DATA::ENCODED_LEN, 21);
assert_eq!(LOG_DATA::NAME, "LOG");
assert_eq!(LOG_DATA::ID, 51_020);
assert_eq!(LOG_DATA::EXTRA_CRC, 120);
assert_eq!(LOG_DATA::ENCODED_LEN, 163);
assert_eq!(ERROR_DATA::NAME, "ERROR");
assert_eq!(ERROR_DATA::ID, 51_021);
assert_eq!(ERROR_DATA::EXTRA_CRC, 134);
assert_eq!(ERROR_DATA::ENCODED_LEN, 98);
assert_eq!(FILE_CONTROL_DATA::NAME, "FILE_CONTROL");
assert_eq!(FILE_CONTROL_DATA::ID, 51_025);
assert_eq!(FILE_CONTROL_DATA::EXTRA_CRC, 161);
assert_eq!(FILE_CONTROL_DATA::ENCODED_LEN, 83);
assert_eq!(FILE_DATA_DATA::NAME, "FILE_DATA");
assert_eq!(FILE_DATA_DATA::ID, 51_026);
assert_eq!(FILE_DATA_DATA::EXTRA_CRC, 20);
assert_eq!(FILE_DATA_DATA::ENCODED_LEN, 255);
assert_eq!(
MavMessage::all_ids(),
&[
51_020, 51_021, 51_025, 51_026, 51_027, 51_028, 51_029, 51_030
]
);
assert_eq!(
HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED as u8,
1
);
assert_eq!(LogLevel::LOG_LEVEL_OFF as u8, 0);
assert_eq!(LogLevel::LOG_LEVEL_TRACE as u8, 1);
assert_eq!(LogLevel::LOG_LEVEL_DEBUG as u8, 2);
assert_eq!(LogLevel::LOG_LEVEL_INFO as u8, 3);
assert_eq!(LogLevel::LOG_LEVEL_WARN as u8, 4);
assert_eq!(LogLevel::LOG_LEVEL_ERROR as u8, 5);
assert_eq!(LogFlags::LOG_FLAG_TRUNCATED.bits(), 1);
}
}
+14 -14
View File
@@ -2,8 +2,6 @@
#![no_std]
/// MAVLink command decoders.
pub mod commands;
/// MAVLink frame encoding API for boards.
pub mod encoder;
/// Serial-byte MAVLink receive API for boards.
@@ -12,18 +10,20 @@ pub mod receiver;
pub mod transmitter;
/// An owned MAVLink 2 frame suitable for UART transmission.
pub type MavlinkFrame = message::MAVLinkV2MessageRaw;
/// MAVLink framing version used when parsing an integrity-checked payload.
pub use mavlink_core::MavlinkVersion;
pub type MavlinkFrame = mavlink_core::MAVLinkV2MessageRaw;
/// Supported MAVLink message types for Kaptan.
pub mod message {
pub use mavlink::MAVLinkV2MessageRaw;
pub use mavlink::MavHeader;
pub use mavlink::Message;
pub use mavlink::MessageData;
pub use mavlink::dialects::common::*;
/// Generated wire types for Kaptan's sole MAVLink dialect.
pub mod generated {
include!(concat!(env!("OUT_DIR"), "/kaptan_module.rs"));
}
#[cfg(kaptan_has_messages)]
include!(concat!(env!("OUT_DIR"), "/mod.rs"));
fn decode_message(frame: &MavlinkFrame) -> Option<generated::kaptan::MavMessage> {
use mavlink_core::Message;
generated::kaptan::MavMessage::parse(
mavlink_core::MavlinkVersion::V2,
frame.message_id(),
frame.payload(),
)
.ok()
}
+163 -46
View File
@@ -1,6 +1,36 @@
//! MAVLink serial-byte receive API.
use crate::{MavlinkFrame, message};
use crate::{MavlinkFrame, decode_message, generated::kaptan::MavMessage};
/// An unsigned MAVLink 2 frame whose ID and CRC match the Kaptan dialect.
///
/// The payload is deliberately not decoded during byte admission. Consumers
/// decode it once after taking the frame from the bounded receive queue.
#[repr(transparent)]
pub struct IntegrityCheckedFrame(MavlinkFrame);
const _: [(); core::mem::size_of::<MavlinkFrame>()] =
[(); core::mem::size_of::<IntegrityCheckedFrame>()];
const _: [(); core::mem::align_of::<MavlinkFrame>()] =
[(); core::mem::align_of::<IntegrityCheckedFrame>()];
impl IntegrityCheckedFrame {
fn try_new(frame: MavlinkFrame) -> Option<Self> {
let message_id = frame.message_id();
(MavMessage::all_ids().contains(&message_id) && frame.has_valid_crc::<MavMessage>())
.then_some(Self(frame))
}
/// Returns the admitted raw frame without copying it.
pub const fn raw(&self) -> &MavlinkFrame {
&self.0
}
/// Decodes the admitted Kaptan payload without repeating its CRC check.
pub fn decode(&self) -> Option<MavMessage> {
decode_message(&self.0)
}
}
struct IntegrityFramer {
frame: MavlinkFrame,
@@ -17,7 +47,7 @@ impl IntegrityFramer {
}
}
fn receive_byte(&mut self, byte: u8) -> Option<MavlinkFrame> {
fn receive_byte(&mut self, byte: u8) -> Option<IntegrityCheckedFrame> {
if self.received == 0 {
if byte == 0xfd {
self.frame.as_mut_slice()[0] = byte;
@@ -48,7 +78,7 @@ impl IntegrityFramer {
let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new());
self.received = 0;
self.expected = 0;
has_valid_known_crc(&frame).then_some(frame)
IntegrityCheckedFrame::try_new(frame)
}
fn reset(&mut self) {
@@ -62,10 +92,10 @@ impl IntegrityFramer {
///
/// It rejects signed, incompatible, corrupt, and MAVLink 1 frames, but an
/// accepted frame is still unauthenticated. The composition decides whether
/// and how a received command may be admitted.
/// and how a received request may be admitted.
pub struct IntegrityMavlinkReceiver<const N: usize> {
framer: IntegrityFramer,
frames: [Option<MavlinkFrame>; N],
frames: [Option<IntegrityCheckedFrame>; N],
front: usize,
length: usize,
}
@@ -76,7 +106,7 @@ impl<const N: usize> IntegrityMavlinkReceiver<N> {
assert!(N != 0, "integrity receiver capacity must be nonzero");
Self {
framer: IntegrityFramer::new(),
frames: [None; N],
frames: [const { None }; N],
front: 0,
length: 0,
}
@@ -99,13 +129,13 @@ impl<const N: usize> IntegrityMavlinkReceiver<N> {
/// Discards partial framing state and all queued unauthenticated frames.
pub fn reset(&mut self) {
self.framer.reset();
self.frames = [None; N];
self.frames = [const { None }; N];
self.front = 0;
self.length = 0;
}
/// Takes the oldest integrity-checked frame.
pub fn take_received(&mut self) -> Option<MavlinkFrame> {
pub fn take_received(&mut self) -> Option<IntegrityCheckedFrame> {
if self.length == 0 {
return None;
}
@@ -122,59 +152,107 @@ impl<const N: usize> Default for IntegrityMavlinkReceiver<N> {
}
}
fn has_valid_known_crc(frame: &MavlinkFrame) -> bool {
let common_valid = frame.has_valid_crc::<message::MavMessage>();
#[cfg(feature = "dialect-kaptan")]
{
common_valid || frame.has_valid_crc::<message::kaptan::MavMessage>()
}
#[cfg(not(feature = "dialect-kaptan"))]
{
common_valid
}
}
#[cfg(test)]
mod tests {
use super::IntegrityMavlinkReceiver;
use crate::{MavlinkFrame, message};
use super::{IntegrityCheckedFrame, IntegrityMavlinkReceiver};
use crate::{
MavlinkFrame,
generated::kaptan::{HEARTBEAT_DATA, HeartbeatState, MavMessage},
};
use mavlink_core::{
MAVLinkV1MessageRaw, MavHeader, MavlinkVersion, MessageData, error::ParserError,
};
struct ForeignHeartbeat;
impl MessageData for ForeignHeartbeat {
type Message = MavMessage;
const ID: u32 = 0;
const NAME: &'static str = "FOREIGN_HEARTBEAT";
const EXTRA_CRC: u8 = 50;
const ENCODED_LEN: usize = 9;
fn ser(&self, _: MavlinkVersion, payload: &mut [u8]) -> usize {
payload[..Self::ENCODED_LEN].copy_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 3]);
Self::ENCODED_LEN
}
fn deser(_: MavlinkVersion, _: &[u8]) -> Result<Self, ParserError> {
Ok(Self)
}
}
struct MalformedKaptanHeartbeat;
impl MessageData for MalformedKaptanHeartbeat {
type Message = MavMessage;
const ID: u32 = HEARTBEAT_DATA::ID;
const NAME: &'static str = HEARTBEAT_DATA::NAME;
const EXTRA_CRC: u8 = HEARTBEAT_DATA::EXTRA_CRC;
const ENCODED_LEN: usize = HEARTBEAT_DATA::ENCODED_LEN;
fn ser(&self, _: MavlinkVersion, payload: &mut [u8]) -> usize {
payload[0] = 0;
Self::ENCODED_LEN
}
fn deser(_: MavlinkVersion, _: &[u8]) -> Result<Self, ParserError> {
Ok(Self)
}
}
fn heartbeat(sequence: u8) -> MavlinkFrame {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
message::MavHeader {
MavHeader {
sequence,
system_id: 42,
component_id: 7,
},
&message::HEARTBEAT_DATA {
custom_mode: 0,
mavtype: message::MavType::MAV_TYPE_QUADROTOR,
autopilot: message::MavAutopilot::MAV_AUTOPILOT_GENERIC,
base_mode: message::MavModeFlag::empty(),
system_status: message::MavState::MAV_STATE_STANDBY,
mavlink_version: 3,
&HEARTBEAT_DATA {
state: HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED,
},
);
frame
}
fn heartbeat_v1(sequence: u8) -> mavlink::MAVLinkV1MessageRaw {
let mut frame = mavlink::MAVLinkV1MessageRaw::new();
fn foreign_heartbeat_v1(sequence: u8) -> MAVLinkV1MessageRaw {
let mut frame = MAVLinkV1MessageRaw::new();
frame.serialize_message_data(
message::MavHeader {
MavHeader {
sequence,
system_id: 42,
component_id: 7,
},
&message::HEARTBEAT_DATA {
custom_mode: 0,
mavtype: message::MavType::MAV_TYPE_QUADROTOR,
autopilot: message::MavAutopilot::MAV_AUTOPILOT_GENERIC,
base_mode: message::MavModeFlag::empty(),
system_status: message::MavState::MAV_STATE_STANDBY,
mavlink_version: 3,
&ForeignHeartbeat,
);
frame
}
fn foreign_heartbeat_v2(sequence: u8) -> MavlinkFrame {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
MavHeader {
sequence,
system_id: 42,
component_id: 7,
},
&ForeignHeartbeat,
);
frame
}
fn malformed_kaptan_heartbeat(sequence: u8) -> MavlinkFrame {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
MavHeader {
sequence,
system_id: 42,
component_id: 7,
},
&MalformedKaptanHeartbeat,
);
frame
}
@@ -182,14 +260,26 @@ mod tests {
#[test]
fn discards_a_crc_valid_mavlink_v1_frame() {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
let v1 = heartbeat_v1(3);
let v1 = foreign_heartbeat_v1(3);
assert_eq!(v1.raw_bytes()[0], 0xfe);
for byte in v1.raw_bytes() {
assert!(!receiver.receive_byte(*byte));
}
assert_eq!(receiver.take_received(), None);
assert!(receiver.take_received().is_none());
}
#[test]
fn discards_a_crc_valid_foreign_dialect_frame() {
let foreign = foreign_heartbeat_v2(3);
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in foreign.raw_bytes() {
assert!(!receiver.receive_byte(*byte));
}
assert!(receiver.take_received().is_none());
}
#[test]
@@ -199,8 +289,35 @@ mod tests {
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
assert_eq!(receiver.take_received(), Some(frame));
assert_eq!(receiver.take_received(), None);
let received = receiver.take_received().expect("complete frame");
assert_eq!(received.raw(), &frame);
assert_eq!(received.decode(), crate::decode_message(&frame));
assert!(receiver.take_received().is_none());
}
#[test]
fn defers_payload_decoding_until_after_integrity_admission() {
let frame = malformed_kaptan_heartbeat(9);
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
let received = receiver.take_received().expect("CRC-valid Kaptan frame");
assert_eq!(received.raw(), &frame);
assert_eq!(received.decode(), None);
}
#[test]
fn integrity_wrapper_has_exactly_the_raw_frame_layout() {
assert_eq!(
core::mem::size_of::<IntegrityCheckedFrame>(),
core::mem::size_of::<MavlinkFrame>()
);
assert_eq!(
core::mem::align_of::<IntegrityCheckedFrame>(),
core::mem::align_of::<MavlinkFrame>()
);
}
#[test]
@@ -212,7 +329,7 @@ mod tests {
for byte in [0xfd, 0, 1, 0, 0, 1, 1, 0, 0, 0] {
assert!(!receiver.receive_byte(byte));
}
assert_eq!(receiver.take_received(), None);
assert!(receiver.take_received().is_none());
}
#[test]
@@ -226,6 +343,6 @@ mod tests {
for byte in corrupt.raw_bytes() {
assert!(!receiver.receive_byte(*byte));
}
assert_eq!(receiver.take_received(), None);
assert!(receiver.take_received().is_none());
}
}
+49 -89
View File
@@ -59,20 +59,6 @@ where
Memory: DmaTxBufferFactory,
Engine: DmaTxEngine,
{
/// Admits an already-serialized MAVLink frame for UART transmission.
///
/// A full queue is a normal bounded-admission outcome, returned as
/// [`EnqueueResult`]. DMA preparation and transfer-start failures remain
/// [`MavlinkTransmitFault`] errors.
pub fn send_frame(
&mut self,
class: FrameClass,
frame: MavlinkFrame,
) -> Result<EnqueueResult, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
let outcome = self.admit_and_start(class, frame);
outcome.start.map(|()| outcome.admission)
}
/// Admits one frame and attempts one bounded DMA start while preserving
/// both outcomes. A start failure retains the selected frame in the queue
/// reservation for a later service-turn retry; it does not change whether
@@ -82,34 +68,11 @@ where
class: FrameClass,
frame: MavlinkFrame,
) -> AdmissionStart<Memory::Error, Engine::StartError> {
let admission = self.enqueue_frame(class, frame);
let start = self.start_pending();
let admission = self.transmit.enqueue(PendingFrame { class, frame });
let start = self.start_next();
AdmissionStart { admission, start }
}
/// Admits a frame without attempting to start the DMA engine.
///
/// This split lifecycle is for hardware routes whose terminal DMA IRQ can
/// arrive a few bus cycles before the stream's `EN` bit reads clear. Such
/// a route may acknowledge a completion, retain the selected frame, and
/// retry [`Self::start_pending`] from its next bounded service turn rather
/// than treating that transient `Busy` observation as a transport fault.
/// Callers still own the policy for a queue-full result.
pub fn enqueue_frame(&mut self, class: FrameClass, frame: MavlinkFrame) -> EnqueueResult {
self.transmit.enqueue(PendingFrame { class, frame })
}
/// Attempts to start one pending DMA frame.
///
/// A rejected engine start leaves the exact frame reserved for a later
/// retry. This method therefore does not discard or overwrite DMA source
/// storage on a transient hardware-not-ready result.
pub fn start_pending(
&mut self,
) -> Result<(), MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
self.start_next()
}
/// Releases a terminal DMA frame and opportunistically starts the next.
///
/// The returned class is the frame that reached a terminal DMA result,
@@ -122,19 +85,12 @@ where
) -> Result<Option<FrameClass>, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
let completed = self.complete_dma(completion)?;
if completion == TxCompletion::Complete {
self.start_pending()?;
self.start_next()?;
}
Ok(completed)
}
/// Releases the active frame after a terminal DMA result without starting
/// another one.
///
/// Most routes use [`Self::on_dma_complete`], which preserves legacy
/// immediate restart behavior. A route whose hardware exposes terminal
/// status before its enable bit is observably low uses this method and
/// calls [`Self::start_pending`] from a later bounded service turn.
pub fn complete_dma(
fn complete_dma(
&mut self,
completion: TxCompletion,
) -> Result<Option<FrameClass>, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
@@ -164,8 +120,10 @@ where
#[cfg(test)]
mod tests {
use super::{EnqueueResult, FrameClass, MavlinkTransmitFault, MavlinkTransmitter};
use crate::{encoder::MavlinkEncoder, message::MavState};
use super::{
EnqueueResult, FrameClass, MavlinkTransmitFault, MavlinkTransmitter, PendingFrame,
};
use crate::encoder::MavlinkEncoder;
use kaptan_dma::{DmaReadableBuffer, DmaTxBufferFactory, DmaTxEngine, TxCompletion};
struct Memory;
@@ -231,18 +189,18 @@ mod tests {
fn transfer_fault_drops_only_the_active_frame() {
let mut encoder = MavlinkEncoder::new(1, 1);
let mut transmitter = MavlinkTransmitter::<Memory, Engine, 2>::new(Memory, Engine);
assert!(
transmitter
.send_frame(
FrameClass::BestEffort,
encoder.heartbeat(MavState::MAV_STATE_STANDBY, false),
)
.unwrap();
.admit_and_start(FrameClass::BestEffort, encoder.heartbeat())
.start
.is_ok()
);
assert!(
transmitter
.send_frame(
FrameClass::BestEffort,
encoder.heartbeat(MavState::MAV_STATE_STANDBY, false),
)
.unwrap();
.admit_and_start(FrameClass::BestEffort, encoder.heartbeat())
.start
.is_ok()
);
assert!(transmitter.transmit.is_active());
transmitter
@@ -258,29 +216,20 @@ mod tests {
assert_eq!(
transmitter
.send_frame(
FrameClass::Critical,
encoder.heartbeat(MavState::MAV_STATE_STANDBY, false),
)
.unwrap(),
.admit_and_start(FrameClass::Critical, encoder.heartbeat())
.admission,
EnqueueResult::Enqueued
);
assert_eq!(
transmitter
.send_frame(
FrameClass::Critical,
encoder.heartbeat(MavState::MAV_STATE_STANDBY, false),
)
.unwrap(),
.admit_and_start(FrameClass::Critical, encoder.heartbeat())
.admission,
EnqueueResult::Enqueued
);
assert_eq!(
transmitter
.send_frame(
FrameClass::Critical,
encoder.heartbeat(MavState::MAV_STATE_STANDBY, false),
)
.unwrap(),
.admit_and_start(FrameClass::Critical, encoder.heartbeat())
.admission,
EnqueueResult::CriticalQueueFull
);
}
@@ -295,12 +244,16 @@ mod tests {
starts: 0,
},
);
let state = MavState::MAV_STATE_STANDBY;
transmitter.enqueue_frame(FrameClass::Critical, encoder.heartbeat(state, false));
transmitter.start_pending().unwrap();
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
transmitter.start_next().unwrap();
assert_eq!(transmitter.engine.starts, 1);
transmitter.enqueue_frame(FrameClass::Critical, encoder.heartbeat(state, false));
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
// Model the STM32 case: TCIF is acknowledged but CR.EN still reports
// busy for the first attempt to start the successor. `complete_dma`
@@ -312,12 +265,12 @@ mod tests {
assert_eq!(transmitter.engine.starts, 1);
transmitter.engine.busy_once = true;
assert!(matches!(
transmitter.start_pending(),
transmitter.start_next(),
Err(MavlinkTransmitFault::Engine(RetryError::Busy))
));
assert!(!transmitter.transmit.is_active());
transmitter.start_pending().unwrap();
transmitter.start_next().unwrap();
assert!(transmitter.transmit.is_active());
assert_eq!(transmitter.engine.starts, 2);
}
@@ -332,20 +285,27 @@ mod tests {
starts: 0,
},
);
let state = MavState::MAV_STATE_STANDBY;
// A critical frame is active. A best-effort sample arrives behind it
// before DMA completion.
transmitter.enqueue_frame(FrameClass::Critical, encoder.heartbeat(state, false));
transmitter.start_pending().unwrap();
transmitter.enqueue_frame(FrameClass::BestEffort, encoder.heartbeat(state, false));
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
transmitter.start_next().unwrap();
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::BestEffort,
frame: encoder.heartbeat(),
});
// The completion worker performs its bounded retry before the next
// periodic sender can enqueue fresh critical traffic. The existing
// best-effort frame therefore cannot starve behind later traffic.
transmitter.complete_dma(TxCompletion::Complete).unwrap();
transmitter.start_pending().unwrap();
transmitter.enqueue_frame(FrameClass::Critical, encoder.heartbeat(state, false));
transmitter.start_next().unwrap();
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
assert_eq!(transmitter.engine.starts, 2);
assert_eq!(&transmitter.engine.started_sequences[..2], &[0, 1]);
+1 -1
View File
@@ -17,7 +17,7 @@ test = false
[dependencies]
kaptan-err-reportable = { path = "../../libs/kaptan-err-reportable" }
kaptan-config = { path = "../../libs/kaptan-config" }
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" }
kaptan-file-mavlink = { path = "../../libs/kaptan-file-mavlink" }
kaptan-data-channel = { path = "../../libs/kaptan-data-channel" }
kaptan-dma = { path = "../../libs/kaptan-dma" }
kaptan-drivers = { path = "../../libs/kaptan-drivers" }
+70 -120
View File
@@ -27,27 +27,20 @@ use kaptan_log::{DeliveryClass, LogSinkQueue, TransportAdmission};
use kaptan_spi::SpiCompletion;
use stm32f7::stm32f722;
use kaptan_config_mavlink::{
ConfigProtocol, Operation, ResponseVisitor, ResultKind, ServiceResponse, Target,
visit_response, wire,
};
use kaptan_mavlink::{
MavlinkVersion,
message::{Message, kaptan},
receiver::IntegrityMavlinkReceiver,
use kaptan_file_mavlink::{
FileResponse, FileService, FileStatus, Principal, TRANSFER_REPLAY_LEASE_SECONDS, Target, wire,
};
use kaptan_mavlink::{generated::kaptan, receiver::IntegrityMavlinkReceiver};
use crate::{BoardError, F7ImmutableConfig, F722Hardware, F722Profile, F722StatusLedRoute};
use super::config::{
F722ConfigManager, F722ConfigStore, F722Nor, UserConfigModel, project_runtime,
validate_runtime_values,
F722ConfigFile, F722ConfigManager, F722ConfigStore, F722Nor, UserConfigModel, project_runtime,
};
use super::{
VerticalRoute, clock,
data_channel::{
F722MavlinkTransmitter, F722StatusLed, GeprcStatusLed, SERVICE_HZ, SERVICE_TIMER_CONFIG,
vertical_data_config,
},
interrupts,
sensors::{F722Imu, ImuEvent},
@@ -57,8 +50,10 @@ const UART4_CLOCK_HZ: u32 = 54_000_000;
const I2C1_CONFIG: I2c1Config = I2c1Config::from_timing_register(0x2040_4768);
const LIDAR_MODE: AcquisitionMode = AcquisitionMode::WithReceiverBiasCorrection;
const LIDAR_STARTUP_TICKS: u8 = 3;
const CONFIG_SESSION_LEASE_TICKS: u16 = (SERVICE_HZ * 10) as u16;
const SCHEDULED_FRAME_MAXIMUM_BYTES: usize = 48;
const FILE_TRANSFER_LEASE_TICKS: u16 = (SERVICE_HZ * TRANSFER_REPLAY_LEASE_SECONDS as usize) as u16;
// The IMU is the largest current scheduled payload; unsigned MAVLink 2 adds a
// ten-byte header and two-byte checksum.
const SCHEDULED_FRAME_MAXIMUM_BYTES: usize = 12 + kaptan::IMU_DATA::ENCODED_LEN;
// User/immutable validation permits at most one scheduled frame per 100 Hz
// service tick. Configuration replies and diagnostics are event-driven and
// use bounded queue admission; they do not invent a periodic link reservation.
@@ -93,10 +88,10 @@ where
I: F7ImmutableConfig,
{
data_channel: DataChannel<'static, F722MavlinkTransmitter, SERVICE_HZ>,
configuration: F722ConfigManager<C, I, MAX_PAYLOAD_BYTES>,
config_protocol: ConfigProtocol,
receiver: IntegrityMavlinkReceiver<2>,
config_lease_ticks: u16,
config_file: F722ConfigFile<C, I, MAX_PAYLOAD_BYTES>,
file_service: FileService,
receiver: IntegrityMavlinkReceiver<1>,
file_lease_ticks: u16,
mavlink_errors: ErrorSink,
mavlink_logs: LogSinkQueue<1, 1>,
led: F722StatusLed,
@@ -142,11 +137,11 @@ pub(crate) fn validate_board_config<C, I>(
hardware: F722Hardware,
) -> Result<(), BoardError>
where
C: Clone + Config,
C: Config,
I: F7ImmutableConfig,
{
<C as kaptan_config::Config>::validate(config).map_err(|_| BoardError::Configuration)?;
validate_runtime_values(config, immutable).map_err(|_| BoardError::Configuration)?;
let _ = project_runtime(config, immutable).map_err(|_| BoardError::Configuration)?;
let immutable = immutable.f722();
if immutable.service_hz != SERVICE_HZ {
return Err(BoardError::UnsupportedServiceFrequency);
@@ -249,7 +244,6 @@ where
device.TIM2,
config,
immutable,
vertical,
imu,
range,
mtf01,
@@ -317,7 +311,6 @@ where
device.TIM2,
config,
immutable,
vertical,
imu,
range,
mtf01,
@@ -334,7 +327,6 @@ fn finish<C, I, const MAX_PAYLOAD_BYTES: usize>(
tim2: stm32f722::TIM2,
config: C,
immutable: I,
vertical: VerticalRoute,
imu: F722Imu,
range: Option<F722Range>,
mtf01: Option<F722Mtf01>,
@@ -348,12 +340,10 @@ where
I: F7ImmutableConfig,
{
let runtime = project_runtime(&config, &immutable).map_err(|_| BoardError::Configuration)?;
let immutable = immutable.f722();
let system_id = runtime.system_id;
let component_id = runtime.component_id;
let rates = DataChannelRates {
service_hz: immutable.service_hz,
raw_imu_hz: usize::from(runtime.raw_imu_hz),
imu_hz: usize::from(runtime.imu_hz),
range_hz: runtime.range_hz.map(usize::from),
optical_flow_hz: runtime.optical_flow_hz.map(usize::from),
heartbeat_hz: usize::from(runtime.heartbeat_hz),
@@ -364,7 +354,7 @@ where
system_id,
component_id,
rates,
vertical_data_config(vertical),
10,
DataSources {
imu: &SAMPLES,
range: &RANGES,
@@ -383,10 +373,10 @@ where
},
mavlink: F722MavlinkState {
data_channel,
configuration,
config_protocol: ConfigProtocol::new(Target::new(system_id, component_id)),
config_file: F722ConfigFile::new(configuration),
file_service: FileService::new(Target::new(system_id, component_id)),
receiver: IntegrityMavlinkReceiver::new(),
config_lease_ticks: 0,
file_lease_ticks: 0,
mavlink_errors: ErrorSink::new(),
mavlink_logs: LogSinkQueue::new(mavlink_log_filter),
led,
@@ -404,8 +394,7 @@ where
I: F7ImmutableConfig,
{
let candidate = UserConfigModel::<C, I>::new(factory);
let store =
F722ConfigStore::<C, I, MAX_PAYLOAD_BYTES>::new(F722Nor::new(flash), candidate.clone());
let store = F722ConfigStore::<MAX_PAYLOAD_BYTES>::new(F722Nor::new(flash));
F722ConfigManager::<C, I, MAX_PAYLOAD_BYTES>::recover_compatible_or_factory(
candidate, immutable, store,
)
@@ -595,7 +584,7 @@ where
const DMA: u8 = 1 << 2;
if events & RECEIVED != 0 {
self.drain_config_frames();
self.drain_file_frames();
}
if events & DMA != 0 {
@@ -607,77 +596,82 @@ where
}
if events & TIMER != 0 {
self.tick_config_lease();
// The board is initialized and transmitting physical telemetry,
// but has no flight runtime or output authority. `STANDBY` with
// an unarmed base mode communicates that accurately; `UNINIT`
// would incorrectly claim initialization never completed.
self.data_channel
.on_service_tick(kaptan_mavlink::message::MavState::MAV_STATE_STANDBY, false);
self.tick_file_lease();
self.data_channel.on_service_tick();
self.drain_one_mavlink_diagnostic();
}
}
pub fn receive_config_interrupt(&mut self, event: ReceiveInterrupt) -> bool {
pub fn receive_mavlink_interrupt(&mut self, event: ReceiveInterrupt) -> bool {
match event {
ReceiveInterrupt::Byte(byte) => self.receiver.receive_byte(byte),
ReceiveInterrupt::Fault => {
self.receiver.reset();
self.config_lease_ticks = 0;
self.config_protocol.expire(&mut self.configuration);
kaptan_log::warn!("mavlink receive fault; configuration session expired");
self.file_lease_ticks = 0;
self.file_service.expire(&mut self.config_file);
kaptan_log::warn!("mavlink receive fault; file transfer expired");
false
}
ReceiveInterrupt::NotPending => false,
}
}
fn drain_config_frames(&mut self) {
fn drain_file_frames(&mut self) {
while let Some(frame) = self.receiver.take_received() {
if frame.system_id() == 0 || frame.component_id() == 0 {
continue;
}
let Ok(message) =
kaptan::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload())
let Some(principal) =
Principal::try_new(frame.raw().system_id(), frame.raw().component_id())
else {
continue;
};
let Ok(command) = wire::decode_command(&message) else {
let Some(message) = frame.decode() else {
continue;
};
let principal =
kaptan_config_mavlink::Principal::new(frame.system_id(), frame.component_id());
let mut sender = ConfigResponseSender {
data_channel: &mut self.data_channel,
let Ok(request) = wire::decode_request(&message) else {
continue;
};
let mut refresh_lease = false;
let _: Result<(), ()> = self.config_protocol.dispatch_from_composition(
&mut self.configuration,
principal,
command,
|response| {
if let ServiceResponse::Result(reply) = response
&& reply.result != ResultKind::Rejected
{
refresh_lease |= reply.operation != Operation::Query;
let Some(response) =
self.file_service
.dispatch(&mut self.config_file, principal, request)
else {
continue;
};
let refresh_lease = match response {
FileResponse::Data(_) => true,
FileResponse::Control(control) => {
control.transfer_id != 0
&& control.status != FileStatus::Busy
&& control.status != FileStatus::InvalidTransfer
&& control.status != FileStatus::RequestConflict
}
visit_response(response, &mut sender)
},
);
};
self.send_file_response(response);
if refresh_lease {
self.config_lease_ticks = CONFIG_SESSION_LEASE_TICKS;
self.file_lease_ticks = FILE_TRANSFER_LEASE_TICKS;
}
}
}
fn tick_config_lease(&mut self) {
if self.config_lease_ticks == 0 {
fn tick_file_lease(&mut self) {
if self.file_lease_ticks == 0 {
return;
}
self.config_lease_ticks -= 1;
if self.config_lease_ticks == 0 {
self.config_protocol.expire(&mut self.configuration);
kaptan_log::debug!("configuration session expired");
self.file_lease_ticks -= 1;
if self.file_lease_ticks == 0 && self.file_service.expire(&mut self.config_file) {
kaptan_log::debug!("file transfer expired");
}
}
fn send_file_response(&mut self, response: FileResponse) {
let message = wire::encode_response(response);
let class = kaptan_mavlink::transmitter::FrameClass::Critical;
match message {
kaptan::MavMessage::FILE_CONTROL(data) => {
let _ = self.data_channel.send_message(class, &data);
}
kaptan::MavMessage::FILE_DATA(data) => {
let _ = self.data_channel.send_message(class, &data);
}
_ => unreachable!("file response encoder returned another message"),
}
}
@@ -702,7 +696,7 @@ where
self.mavlink_errors.submit_next(|record| {
let payload = encode_error(record);
data_channel
.send_custom(kaptan_mavlink::transmitter::FrameClass::Critical, &payload)
.send_message(kaptan_mavlink::transmitter::FrameClass::Critical, &payload)
.is_retained()
})
}
@@ -715,7 +709,7 @@ where
DeliveryClass::BestEffort => kaptan_mavlink::transmitter::FrameClass::BestEffort,
};
let payload = encode_log(record);
let admission = data_channel.send_custom(class, &payload);
let admission = data_channel.send_message(class, &payload);
let outcome = if admission.is_retained() {
TransportAdmission::Accepted
} else {
@@ -726,50 +720,6 @@ where
}
}
struct ConfigResponseSender<'a> {
data_channel: &'a mut DataChannel<'static, F722MavlinkTransmitter, SERVICE_HZ>,
}
impl ResponseVisitor for ConfigResponseSender<'_> {
type Error = ();
fn info(&mut self, response: &kaptan::KAPTAN_CONFIG_INFO_DATA) -> Result<(), Self::Error> {
self.send(response)
}
fn result(&mut self, response: &kaptan::KAPTAN_CONFIG_RESULT_DATA) -> Result<(), Self::Error> {
self.send(response)
}
fn descriptor(
&mut self,
response: &kaptan::KAPTAN_CONFIG_DESCRIPTOR_DATA,
) -> Result<(), Self::Error> {
self.send(response)
}
fn enum_option(
&mut self,
response: &kaptan::KAPTAN_CONFIG_ENUM_OPTION_DATA,
) -> Result<(), Self::Error> {
self.send(response)
}
fn value(&mut self, response: &kaptan::KAPTAN_CONFIG_VALUE_DATA) -> Result<(), Self::Error> {
self.send(response)
}
}
impl ConfigResponseSender<'_> {
fn send<M: kaptan_mavlink::message::MessageData>(&mut self, response: &M) -> Result<(), ()> {
self.data_channel
.send_custom(kaptan_mavlink::transmitter::FrameClass::Critical, response)
.is_retained()
.then_some(())
.ok_or(())
}
}
pub(crate) fn take_mavlink_completion() -> kaptan_dma::TxCompletion {
match UART_COMPLETION.swap(0, Ordering::AcqRel) {
1 => kaptan_dma::TxCompletion::Complete,
+373 -24
View File
@@ -1,9 +1,14 @@
//! Board-owned user configuration support and the generic F722 flash journal.
use core::marker::PhantomData;
use core::{marker::PhantomData, mem};
use kaptan_config::{
Config, ConfigManager, ConfigModel, ConfigValue, ConfigViolation, DualSlotJournal, DualSlotNor,
FieldDescriptor, FieldId, FieldWriteError, ValidationRule,
FieldDescriptor, FieldId, FieldWriteError, JournalError, KcfgDecodeError, KcfgExportPlan,
KcfgExporter, KcfgImporter, KcfgResultDetail, ReplaceError, ReplaceOutcome, ValidationRule,
kcfg_length,
};
use kaptan_file_mavlink::{
CONTENT_BYTES, Content, FileFailure, FileMetadata, FileProvider, FileStatus,
};
use stm32f7::stm32f722;
@@ -11,11 +16,12 @@ 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 IMU_HZ: FieldId = FieldId::new(103);
const HEARTBEAT_HZ: FieldId = FieldId::new(104);
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 USER_CONFIG_PATH: &[u8] = b"/config/user.kcfg";
/// Narrow boot projection consumed only by fixed F722 services.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -23,7 +29,7 @@ pub(super) struct F722UserRuntime {
pub(super) system_id: u8,
pub(super) component_id: u8,
pub(super) mavlink_log_level_filter: LevelFilter,
pub(super) raw_imu_hz: u8,
pub(super) imu_hz: u8,
pub(super) range_hz: Option<u8>,
pub(super) optical_flow_hz: Option<u8>,
pub(super) heartbeat_hz: u8,
@@ -81,22 +87,11 @@ where
fn validate(&self, context: &Self::Context) -> Result<Self::Active, ConfigViolation> {
<C as Config>::validate(&self.candidate)?;
validate_runtime_values(&self.candidate, context)?;
let _ = project_runtime(&self.candidate, context)?;
Ok(self.candidate.clone())
}
}
pub(super) fn validate_runtime_values<C, I>(
candidate: &C,
immutable: &I,
) -> Result<(), ConfigViolation>
where
C: Clone + Config,
I: F7ImmutableConfig,
{
project_runtime(candidate, immutable).map(|_| ())
}
pub(super) fn project_runtime<C, I>(
candidate: &C,
immutable: &I,
@@ -120,7 +115,7 @@ where
ValidationRule::Zero,
));
}
let raw_imu_hz = required_u8(candidate, RAW_IMU_HZ)?;
let imu_hz = required_u8(candidate, IMU_HZ)?;
let heartbeat_hz = required_u8(candidate, HEARTBEAT_HZ)?;
let level_filter = required_u8(candidate, MAVLINK_LOG_LEVEL_FILTER)?;
let mavlink_log_level_filter = LevelFilter::try_from_wire(level_filter).ok_or(
@@ -138,7 +133,7 @@ where
};
let service_hz = immutable.service_hz;
let mut scheduled_hz = checked_rate(raw_imu_hz, RAW_IMU_HZ, service_hz)?
let mut scheduled_hz = checked_rate(imu_hz, IMU_HZ, service_hz)?
.checked_add(checked_rate(heartbeat_hz, HEARTBEAT_HZ, service_hz)?)
.ok_or(ConfigViolation::global(ValidationRule::Inconsistent))?;
scheduled_hz = add_optional_rate(range_hz, RANGE_HZ, service_hz, scheduled_hz)?;
@@ -151,7 +146,7 @@ where
system_id,
component_id,
mavlink_log_level_filter,
raw_imu_hz,
imu_hz,
range_hz,
optical_flow_hz,
heartbeat_hz,
@@ -160,8 +155,7 @@ where
fn required_u8<C: Config>(candidate: &C, field: FieldId) -> Result<u8, ConfigViolation> {
match candidate.field_value(field) {
Some(ConfigValue::U32(value)) => u8::try_from(value)
.map_err(|_| ConfigViolation::field(field, ValidationRule::AboveMaximum)),
Some(ConfigValue::U8(value)) => Ok(value),
Some(_) | None => Err(ConfigViolation::field(field, ValidationRule::Inconsistent)),
}
}
@@ -203,13 +197,368 @@ const FLASH_ERROR_FLAGS: u32 = 0x01f2;
const FLASH_OPERATION_TIMEOUT_CYCLES: u32 = 2 * 216_000_000;
/// One schema-independent journal shared by every STM32F722 board image.
pub(crate) type F722ConfigStore<C, I, const MAX_PAYLOAD_BYTES: usize> =
DualSlotJournal<UserConfigModel<C, I>, F722Nor, MAX_PAYLOAD_BYTES>;
pub(crate) type F722ConfigStore<const MAX_PAYLOAD_BYTES: usize> =
DualSlotJournal<F722Nor, MAX_PAYLOAD_BYTES>;
/// Concrete manager storage selected when the board macro monomorphizes the
/// common platform task graph for its aggregate and exact snapshot size.
pub(crate) type F722ConfigManager<C, I, const MAX_PAYLOAD_BYTES: usize> =
ConfigManager<UserConfigModel<C, I>, F722ConfigStore<C, I, MAX_PAYLOAD_BYTES>>;
ConfigManager<UserConfigModel<C, I>, F722ConfigStore<MAX_PAYLOAD_BYTES>>;
#[derive(Debug, kaptan_err_reportable::KaptanError)]
enum PersistConfigurationError {
/// Reserved journal sectors cannot hold the configured payload bound.
#[kaptan_error(id = 2210)]
JournalGeometry,
/// The compiled configuration model cannot produce a private snapshot.
#[kaptan_error(id = 2202)]
ConfigurationModel,
/// The private journal sequence has exhausted its stable integer domain.
#[kaptan_error(id = 2203)]
JournalSequenceExhausted,
/// A flash request addressed bytes outside the reserved journal sectors.
#[kaptan_error(id = 2204)]
FlashBounds,
/// The embedded flash controller could not be unlocked for programming.
#[kaptan_error(id = 2206)]
FlashUnlock,
/// The embedded flash controller reported a programming or erase fault.
#[kaptan_error(id = 2207)]
FlashController,
/// Flash read-back differed from the word requested by the platform.
#[kaptan_error(id = 2208)]
FlashVerification,
/// A complete journal record failed post-write verification.
#[kaptan_error(id = 2209)]
JournalVerification,
}
impl From<JournalError<F722FlashError>> for PersistConfigurationError {
fn from(error: JournalError<F722FlashError>) -> Self {
match error {
JournalError::InvalidGeometry => Self::JournalGeometry,
JournalError::InvalidModel => Self::ConfigurationModel,
JournalError::SequenceExhausted => Self::JournalSequenceExhausted,
JournalError::Storage(F722FlashError::Bounds) => Self::FlashBounds,
JournalError::Storage(F722FlashError::Unlock) => Self::FlashUnlock,
JournalError::Storage(F722FlashError::Controller) => Self::FlashController,
JournalError::Storage(F722FlashError::Verification) => Self::FlashVerification,
JournalError::Verification => Self::JournalVerification,
}
}
}
enum ConfigFileTransfer<C: ConfigModel> {
Idle,
Read(KcfgExportPlan),
Write(KcfgImporter<C>),
}
/// Streaming KCFG endpoint over the private power-loss-safe flash journal.
///
/// The endpoint retains one typed candidate while writing and generates read
/// chunks directly from compiled metadata. It never buffers the public file.
pub(crate) struct F722ConfigFile<C, I, const MAX_PAYLOAD_BYTES: usize>
where
C: Clone + Config,
I: F7ImmutableConfig,
{
manager: F722ConfigManager<C, I, MAX_PAYLOAD_BYTES>,
transfer: ConfigFileTransfer<UserConfigModel<C, I>>,
}
impl<C, I, const MAX_PAYLOAD_BYTES: usize> F722ConfigFile<C, I, MAX_PAYLOAD_BYTES>
where
C: Clone + Config,
I: F7ImmutableConfig,
{
pub(crate) const fn new(manager: F722ConfigManager<C, I, MAX_PAYLOAD_BYTES>) -> Self {
Self {
manager,
transfer: ConfigFileTransfer::Idle,
}
}
/// Keeps user-content rejection in the file response while reporting a
/// storage failure once at the persistence boundary.
#[kaptan_err_reportable::propagate]
fn persist_candidate(
&mut self,
candidate: UserConfigModel<C, I>,
) -> Result<Result<ReplaceOutcome, ConfigViolation>, PersistConfigurationError> {
match self.manager.replace(candidate) {
Ok(outcome) => Ok(Ok(outcome)),
Err(ReplaceError::InvalidCandidate(violation)) => Ok(Err(violation)),
Err(ReplaceError::Store(error)) => Err(error.into()),
}
}
}
impl<C, I, const MAX_PAYLOAD_BYTES: usize> FileProvider for F722ConfigFile<C, I, MAX_PAYLOAD_BYTES>
where
C: Clone + Config,
I: F7ImmutableConfig,
{
fn open_read(
&mut self,
path: &kaptan_file_mavlink::FilePath,
) -> Result<FileMetadata, FileFailure> {
if path.as_bytes() != USER_CONFIG_PATH {
return Err(file_failure(FileStatus::NotFound, b"file not found"));
}
if !matches!(self.transfer, ConfigFileTransfer::Idle) {
return Err(file_failure(FileStatus::Busy, b"file transfer busy"));
}
let exporter = KcfgExporter::new(
self.manager.committed(),
self.manager.factory(),
self.manager.restart_required(),
)
.map_err(|_| file_failure(FileStatus::Internal, b"cannot export configuration"))?;
let metadata = FileMetadata {
size: u32::try_from(exporter.length())
.map_err(|_| file_failure(FileStatus::TooLarge, b"configuration file too large"))?,
checksum_crc32c: exporter.checksum_crc32c(),
};
self.transfer = ConfigFileTransfer::Read(exporter.plan());
Ok(metadata)
}
fn open_write(
&mut self,
path: &kaptan_file_mavlink::FilePath,
metadata: FileMetadata,
) -> Result<(), FileFailure> {
if path.as_bytes() != USER_CONFIG_PATH {
return Err(file_failure(FileStatus::NotFound, b"file not found"));
}
if !matches!(self.transfer, ConfigFileTransfer::Idle) {
return Err(file_failure(FileStatus::Busy, b"file transfer busy"));
}
let expected = kcfg_length::<UserConfigModel<C, I>>()
.map_err(|_| file_failure(FileStatus::Internal, b"cannot import configuration"))?;
if usize::try_from(metadata.size) != Ok(expected) {
return Err(kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::failure(),
b"wrong configuration file length",
));
}
let importer = KcfgImporter::new(self.manager.factory())
.map_err(|_| file_failure(FileStatus::Internal, b"cannot import configuration"))?;
self.transfer = ConfigFileTransfer::Write(importer);
Ok(())
}
fn read(&mut self, position: u32, output: &mut [u8]) -> Result<(), FileFailure> {
let ConfigFileTransfer::Read(plan) = &self.transfer else {
return Err(file_failure(
FileStatus::InvalidState,
b"file is not open for read",
));
};
let exporter = KcfgExporter::from_plan_unchecked(
self.manager.committed(),
self.manager.factory(),
*plan,
);
let written = exporter
.read(position as usize, output)
.map_err(|_| file_failure(FileStatus::Internal, b"cannot export configuration"))?;
if written != output.len() {
return Err(file_failure(
FileStatus::Internal,
b"short configuration read",
));
}
Ok(())
}
fn write(&mut self, _: u32, data: &[u8]) -> Result<(), FileFailure> {
let ConfigFileTransfer::Write(importer) = &mut self.transfer else {
return Err(file_failure(
FileStatus::InvalidState,
b"file is not open for write",
));
};
importer
.push(self.manager.factory(), data)
.map_err(kcfg_failure)
}
fn close(&mut self) -> Result<Content, FileFailure> {
match mem::replace(&mut self.transfer, ConfigFileTransfer::Idle) {
ConfigFileTransfer::Idle => {
Err(file_failure(FileStatus::InvalidState, b"file is not open"))
}
ConfigFileTransfer::Read(_) => Ok(Content::EMPTY),
ConfigFileTransfer::Write(importer) => {
let candidate = importer.finish().map_err(kcfg_failure)?;
let outcome = self
.persist_candidate(candidate)
.map_err(|_| {
file_failure(FileStatus::StorageFailure, b"configuration storage failed")
})?
.map_err(|violation| {
kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::violation(violation),
b"configuration validation failed",
)
})?;
Ok(kcfg_content(
KcfgResultDetail::success(outcome.restart_required),
b"",
))
}
}
}
fn abort(&mut self) {
self.transfer = ConfigFileTransfer::Idle;
}
}
fn file_failure(status: FileStatus, detail: &'static [u8]) -> FileFailure {
let content = Content::try_new(detail).expect("static file failure detail is bounded");
FileFailure::new(status, content).expect("failure status must reject the operation")
}
fn kcfg_content(detail: KcfgResultDetail, text: &'static [u8]) -> Content {
let prefix = detail.encode();
let length = prefix.len() + text.len();
assert!(
length <= CONTENT_BYTES,
"KCFG result detail must be bounded"
);
let mut bytes = [0_u8; CONTENT_BYTES];
bytes[..prefix.len()].copy_from_slice(&prefix);
bytes[prefix.len()..length].copy_from_slice(text);
Content::try_new(&bytes[..length]).expect("KCFG result detail is bounded")
}
fn kcfg_file_failure(
status: FileStatus,
detail: KcfgResultDetail,
text: &'static [u8],
) -> FileFailure {
FileFailure::new(status, kcfg_content(detail, text))
.expect("failure status must reject the operation")
}
fn kcfg_failure(error: KcfgDecodeError) -> FileFailure {
let (result, text): (KcfgResultDetail, &'static [u8]) = match error {
KcfgDecodeError::Truncated => {
(KcfgResultDetail::failure(), b"truncated configuration file")
}
KcfgDecodeError::TrailingData => {
(KcfgResultDetail::failure(), b"trailing configuration data")
}
KcfgDecodeError::InvalidMagic => {
(KcfgResultDetail::failure(), b"invalid configuration file")
}
KcfgDecodeError::UnsupportedVersion(_) => (
KcfgResultDetail::failure(),
b"unsupported configuration format",
),
KcfgDecodeError::InvalidArtifactFlags(_) => {
(KcfgResultDetail::failure(), b"invalid configuration flags")
}
KcfgDecodeError::ChecksumMismatch => (
KcfgResultDetail::failure(),
b"configuration checksum mismatch",
),
KcfgDecodeError::InvalidFieldId(_) => (
KcfgResultDetail::failure(),
b"invalid configuration field id",
),
KcfgDecodeError::InvalidValueKind(_) => (
KcfgResultDetail::failure(),
b"invalid configuration value type",
),
KcfgDecodeError::InvalidFieldFlags(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"invalid configuration field flags",
);
}
KcfgDecodeError::InvalidValue(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"invalid configuration value",
);
}
KcfgDecodeError::InvalidText(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"invalid configuration metadata",
);
}
KcfgDecodeError::InvalidEnum(field) | KcfgDecodeError::DuplicateEnumValue(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"invalid configuration options",
);
}
KcfgDecodeError::DuplicateField(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"duplicate configuration field",
);
}
KcfgDecodeError::WrongSchema => (
KcfgResultDetail::failure(),
b"configuration schema mismatch",
),
KcfgDecodeError::WrongFieldCount => (
KcfgResultDetail::failure(),
b"configuration field count mismatch",
),
KcfgDecodeError::DescriptorMismatch(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"configuration metadata mismatch",
);
}
KcfgDecodeError::FactoryMismatch(field) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::field_failure(field),
b"configuration factory mismatch",
);
}
KcfgDecodeError::InvalidModel(_) => {
return kcfg_file_failure(
FileStatus::Internal,
KcfgResultDetail::failure(),
b"invalid firmware configuration schema",
);
}
KcfgDecodeError::InvalidWrite(FieldWriteError::Violation(violation)) => {
return kcfg_file_failure(
FileStatus::ContentRejected,
KcfgResultDetail::violation(violation),
b"configuration value rejected",
);
}
KcfgDecodeError::InvalidWrite(
FieldWriteError::UnknownField(field) | FieldWriteError::WrongType { field, .. },
) => {
return kcfg_file_failure(
FileStatus::Internal,
KcfgResultDetail::field_failure(field),
b"invalid firmware configuration schema",
);
}
};
kcfg_file_failure(FileStatus::ContentRejected, result, text)
}
/// Failure of the STM32F722 embedded-flash controller contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -1,11 +1,10 @@
//! F722 transport and vertical-route adapters for the portable data channel.
use super::{VerticalRoute, peripherals};
use kaptan_data_channel::{FrameSink, VerticalDataConfig};
use super::peripherals;
use kaptan_data_channel::FrameSink;
use kaptan_dma::TxCompletion;
use kaptan_mavlink::{
MavlinkFrame,
encoder::DistanceSensorConfig,
transmitter::{EnqueueResult, FrameClass, MavlinkTransmitter},
};
@@ -23,39 +22,6 @@ pub(crate) const SERVICE_TIMER_CONFIG: peripherals::Tim2PeriodicConfig =
auto_reload: 99,
};
/// Maps a selected vertical electrical route to configured data-channel metadata.
///
/// The values are telemetry descriptions only; they neither calibrate a flight
/// estimator nor authorize an `ExternalControlOnly` profile.
pub(crate) fn vertical_data_config(route: VerticalRoute) -> VerticalDataConfig {
let distance =
|minimum_distance_centimetres, maximum_distance_centimetres| DistanceSensorConfig {
sensor_id: 0,
orientation:
kaptan_mavlink::message::MavSensorOrientation::MAV_SENSOR_ROTATION_PITCH_270,
minimum_distance_centimetres,
maximum_distance_centimetres,
};
match route {
VerticalRoute::None => VerticalDataConfig {
distance: None,
optical_flow_sensor_id: None,
maximum_age_ticks: 10,
},
VerticalRoute::Mtf01 => VerticalDataConfig {
distance: Some(distance(2, 800)),
optical_flow_sensor_id: Some(0),
maximum_age_ticks: 10,
},
VerticalRoute::LidarLiteV3 => VerticalDataConfig {
distance: Some(distance(20, 4_000)),
optical_flow_sensor_id: None,
maximum_age_ticks: 10,
},
}
}
/// The two F722 DMA engines behind the portable data-channel transport.
enum F722MavlinkTransport {
Usart1(MavlinkTransmitter<peripherals::DcacheDisabledDmaMemory, peripherals::Usart1TxDma, 4>),
@@ -230,7 +230,7 @@ macro_rules! __f722_flight_application {
if context
.shared
.mavlink
.lock(|mavlink| mavlink.receive_config_interrupt(event))
.lock(|mavlink| mavlink.receive_mavlink_interrupt(event))
{
schedule_mavlink(MAVLINK_EVENT_RECEIVED);
}
@@ -242,7 +242,7 @@ macro_rules! __f722_flight_application {
if context
.shared
.mavlink
.lock(|mavlink| mavlink.receive_config_interrupt(event))
.lock(|mavlink| mavlink.receive_mavlink_interrupt(event))
{
schedule_mavlink(MAVLINK_EVENT_RECEIVED);
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub const fn config_snapshot_length<C: Config>() -> usize {
C::FIELD_COUNT <= u16::MAX as usize,
"configuration schema exceeds wire field-count domain"
);
kaptan_config::scalar_snapshot_length(C::FIELD_COUNT as u16)
kaptan_config::scalar_snapshot_length(C::FIELD_COUNT as u16, C::SNAPSHOT_VALUE_BYTES)
}
/// Dependencies named only by the platform's exported RTIC macro expansion.
@@ -32,11 +32,11 @@ struct MavlinkConfig {
struct TelemetryConfig {
#[config(
id = 103,
key = "telemetry.raw_imu_hz",
key = "telemetry.imu_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
raw_imu_hz: u8,
imu_hz: u8,
#[config(
id = 104,
key = "telemetry.heartbeat_hz",
@@ -91,7 +91,7 @@ struct OneRecord<C>(Option<C>);
impl<C: Clone> ConfigStore<C> for OneRecord<C> {
type Error = ();
fn load(&mut self) -> Result<Option<C>, Self::Error> {
fn load(&mut self, _: &C) -> Result<Option<C>, Self::Error> {
Ok(self.0.take())
}
@@ -109,7 +109,7 @@ fn factory() -> TestUserConfig {
log_level_filter: LevelFilter::Info,
},
telemetry: TelemetryConfig {
raw_imu_hz: 20,
imu_hz: 20,
heartbeat_hz: 2,
},
driver: DriverConfig { driver_gain: 7 },
@@ -123,6 +123,7 @@ struct MalformedProjection {
impl Config for MalformedProjection {
const FIELD_COUNT: usize = TestUserConfig::FIELD_COUNT;
const SNAPSHOT_VALUE_BYTES: usize = TestUserConfig::SNAPSHOT_VALUE_BYTES;
const SCHEMA_ID: [u8; 16] = TestUserConfig::SCHEMA_ID;
fn descriptor(index: usize) -> Option<&'static kaptan_config::FieldDescriptor> {
@@ -159,7 +160,7 @@ fn mavlink_logging_policy_is_projected_and_validated_independently() {
);
let mut candidate = UserConfigModel::<TestUserConfig, F722ImmutableConfig>::new(factory());
candidate
.set_field(FieldId::new(105), ConfigValue::U32(0))
.set_field(FieldId::new(105), ConfigValue::U8(0))
.unwrap();
let active = candidate.validate(&IMMUTABLE).unwrap();
@@ -168,17 +169,13 @@ fn mavlink_logging_policy_is_projected_and_validated_independently() {
}
#[test]
fn runtime_projection_rejects_missing_wrong_kind_and_wide_semantic_values() {
fn runtime_projection_rejects_missing_and_wrong_kind_values() {
for (system_id, rule) in [
(None, kaptan_config::ValidationRule::Inconsistent),
(
Some(ConfigValue::Bool(true)),
kaptan_config::ValidationRule::Inconsistent,
),
(
Some(ConfigValue::U32(256)),
kaptan_config::ValidationRule::AboveMaximum,
),
] {
let malformed = MalformedProjection {
inner: factory(),
@@ -195,7 +192,7 @@ fn runtime_construction_reads_the_persisted_board_owned_user_root() {
let factory = UserConfigModel::<TestUserConfig, F722ImmutableConfig>::new(factory());
let mut persisted = factory.clone();
persisted
.set_field(FieldId::new(1000), ConfigValue::U32(42))
.set_field(FieldId::new(1000), ConfigValue::U8(42))
.expect("driver field must accept a valid persisted value");
let manager = ConfigManager::recover_compatible_or_factory(
@@ -207,7 +204,7 @@ fn runtime_construction_reads_the_persisted_board_owned_user_root() {
assert_eq!(
manager.active().field_value(FieldId::new(1000)),
Some(ConfigValue::U32(42)),
Some(ConfigValue::U8(42)),
"runtime construction must receive the validated persisted field"
);
}
@@ -215,19 +212,19 @@ fn runtime_construction_reads_the_persisted_board_owned_user_root() {
#[test]
fn committing_a_user_edit_does_not_mutate_the_boot_active_view() {
let factory = UserConfigModel::<TestUserConfig, F722ImmutableConfig>::new(factory());
let mut manager = ConfigManager::new(factory, IMMUTABLE, OneRecord(None)).unwrap();
manager.begin().unwrap();
manager
.set(FieldId::new(1000), ConfigValue::U32(42))
let mut manager = ConfigManager::recover(factory, IMMUTABLE, OneRecord(None)).unwrap();
let mut replacement = manager.committed().clone();
replacement
.set_field(FieldId::new(1000), ConfigValue::U8(42))
.unwrap();
manager.commit().unwrap();
manager.replace(replacement).unwrap();
assert_eq!(
manager.active().field_value(FieldId::new(1000)),
Some(ConfigValue::U32(7))
Some(ConfigValue::U8(7))
);
assert_eq!(
manager.committed_value(FieldId::new(1000)),
Some(ConfigValue::U32(42))
manager.committed().field_value(FieldId::new(1000)),
Some(ConfigValue::U8(42))
);
}
@@ -141,13 +141,17 @@ fn ordinary_f722_image_has_direct_configuration_after_output_inhibition() {
"ordinary board images must not hide configuration behind a development feature"
);
for required in [
"configuration: F722ConfigManager",
"config_protocol: ConfigProtocol",
"receiver: IntegrityMavlinkReceiver<2>",
"config_lease_ticks: u16",
"wire::decode_command",
"self.config_protocol.dispatch_from_composition",
"visit_response(response, &mut sender)",
"config_file: F722ConfigFile",
"file_service: FileService",
"receiver: IntegrityMavlinkReceiver<1>",
"file_lease_ticks: u16",
"TRANSFER_REPLAY_LEASE_SECONDS",
"let Some(message) = frame.decode()",
"wire::decode_request",
".dispatch(&mut self.config_file",
"send_file_response",
"let class = kaptan_mavlink::transmitter::FrameClass::Critical",
"control.status != FileStatus::RequestConflict",
] {
assert!(
board.contains(required),
@@ -163,23 +167,15 @@ fn ordinary_f722_image_has_direct_configuration_after_output_inhibition() {
"the MPU runtime may drive only PB2 atomically, not retain GPIOB ownership"
);
assert!(!board.contains("development-config"));
assert!(
!board.contains("ArmRequest"),
"telemetry must not decode arm commands"
);
assert!(
!board.contains("MAV_CMD_COMPONENT_ARM_DISARM") && !board.contains("decode_arm_request"),
"telemetry must not accept an arm command"
);
assert!(
!firmware.contains("shared = [mavlink, motors]"),
"the MAVLink worker must not share a motor resource"
);
assert!(
firmware.contains("const MAVLINK_EVENT_RECEIVED")
&& firmware.contains("mavlink.receive_config_interrupt(event)")
&& firmware.contains("mavlink.receive_mavlink_interrupt(event)")
&& !firmware.contains("development-config"),
"ordinary images must always dispatch received configuration frames"
"ordinary images must always dispatch received MAVLink frames"
);
}
@@ -279,7 +275,7 @@ fn configuration_schema_is_generated_without_board_forwarding_code() {
for semantic_id in [
"MAVLINK_SYSTEM_ID",
"MAVLINK_COMPONENT_ID",
"RAW_IMU_HZ",
"IMU_HZ",
"HEARTBEAT_HZ",
"MAVLINK_LOG_LEVEL_FILTER",
"RANGE_HZ",
@@ -330,8 +326,8 @@ fn f722_link_budget_reserves_only_deterministic_telemetry() {
"ERROR_RECORDS_PER_SECOND",
"LOG_RECORDS_PER_SECOND",
"WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND",
"KAPTAN_ERROR_FRAME_MAXIMUM_BYTES",
"KAPTAN_LOG_FRAME_MAXIMUM_BYTES",
"ERROR_FRAME_MAXIMUM_BYTES",
"LOG_FRAME_MAXIMUM_BYTES",
"budget_ticks",
"error_budget",
"log_budget",
@@ -385,6 +381,7 @@ fn mavlink_transport_faults_do_not_recurse_or_retain_dead_accounting() {
#[test]
fn f722_producers_keep_logs_and_reportable_errors_separate() {
let board = platform_source("src/f722/board.rs");
let config = platform_source("src/f722/config.rs");
let logger = platform_source("../../libs/kaptan-log/src/facade.rs");
let errors = platform_source("../../libs/kaptan-err-reportable/src/ingress.rs");
@@ -400,6 +397,14 @@ fn f722_producers_keep_logs_and_reportable_errors_separate() {
);
}
assert!(board.contains("#[kaptan_err_reportable::propagate]"));
assert!(
config.contains("enum PersistConfigurationError {")
&& config.contains("#[kaptan_error(id = 2210)]")
&& config.contains("#[kaptan_error(id = 2209)]")
&& config.contains("impl From<JournalError<F722FlashError>>")
&& config.contains("fn persist_candidate(")
&& config.contains("#[kaptan_err_reportable::propagate]")
);
assert!(!board.contains("report_error"));
assert!(!board.contains("report_static_error"));
assert!(logger.contains("GLOBAL_INGRESS_CAPACITY"));
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""Small MAVLink command-line debugger for a Kaptan flight controller."""
import argparse
import os
import sys
import time
def parse_arguments():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("serial_device", help="serial device connected to the flight controller")
parser.add_argument("command", choices=("arm", "disarm", "read"))
parser.add_argument("--baud", type=int, default=115200, help="serial baud rate (default: 115200)")
parser.add_argument("--system-id", type=int, default=1, help="target MAVLink system ID (default: 1)")
parser.add_argument(
"--component-id", type=int, default=1, help="target MAVLink component ID (default: 1)"
)
return parser.parse_args()
def print_messages(connection, timeout):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
message = connection.recv_match(blocking=True, timeout=deadline - time.monotonic())
if message is None:
return None
print(message)
yield message
def send_arm_command(connection, mavutil, arguments, armed):
for _ in range(3):
connection.mav.command_long_send(
arguments.system_id,
arguments.component_id,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0,
1.0 if armed else 0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
)
for message in print_messages(connection, timeout=1.0):
if (
message.get_type() == "COMMAND_ACK"
and message.command == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
):
return
action = "arm" if armed else "disarm"
raise SystemExit(f"timed out waiting for {action} acknowledgement")
def read_messages(connection):
try:
while True:
message = connection.recv_match(blocking=True, timeout=1.0)
if message is not None:
print(message)
except KeyboardInterrupt:
pass
def main():
arguments = parse_arguments()
# Kaptan currently accepts MAVLink 2 frames only.
os.environ["MAVLINK20"] = "1"
from pymavlink import mavutil
connection = mavutil.mavlink_connection(arguments.serial_device, baud=arguments.baud)
if arguments.command == "read":
read_messages(connection)
else:
send_arm_command(connection, mavutil, arguments, armed=arguments.command == "arm")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)
+1 -1
View File
@@ -121,7 +121,7 @@ report_package kaptan-dma /libs/kaptan-dma/
report_package kaptan-drivers /libs/kaptan-drivers/
report_package kaptan-i2c /libs/kaptan-i2c/
report_package kaptan-math /libs/kaptan-math/
report_package kaptan-mavlink /libs/kaptan-mavlink/ --features dialect-kaptan
report_package kaptan-mavlink /libs/kaptan-mavlink/ --features sensor-telemetry
report_package kaptan-navigation /libs/kaptan-navigation/ --features sitl
report_package kaptan-safety /libs/kaptan-safety/
report_package kaptan-sensor /libs/kaptan-sensor/
+3 -3
View File
@@ -10,10 +10,10 @@ workspace = true
[dependencies]
kaptan-config = { path = "../../libs/kaptan-config" }
kaptan-config-mavlink = { path = "../../libs/kaptan-config-mavlink" }
kaptan-file-mavlink = { path = "../../libs/kaptan-file-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 }
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false }
libc = "0.2"
mavlink-core = { version = "0.18", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+20 -1
View File
@@ -196,7 +196,7 @@ mod tests {
assert!(!APP.contains("HEARTBEAT"));
assert!(APP.contains("field.id"));
assert!(
APP.contains("field.minimum, field.maximum, field.default, field.help, field.options")
APP.contains("field.minimum, field.maximum, field.factory, field.help, field.options")
);
assert!(APP.contains("document.createElement('select')"));
assert!(APP.contains("snapshot.packets"));
@@ -213,4 +213,23 @@ mod tests {
assert!(APP.contains("truncated.textContent = 'TRUNCATED'"));
assert!(INDEX.contains("id=\"log-entries\""));
}
#[test]
fn browser_clears_recovered_errors_and_retries_failed_configuration_fetches() {
assert!(APP.contains("showError(state.error ?? '')"));
assert!(APP.contains("q('#configuration').hidden = !state.has_config"));
let fetch = APP
.find("const config = await api('/api/config')")
.expect("configuration refresh fetches the latest document");
let render = APP[fetch..]
.find("renderConfig(config)")
.map(|offset| fetch + offset)
.expect("a fetched configuration is rendered");
let revision = APP[render..]
.find("configRevision = state.config_revision")
.map(|offset| render + offset)
.expect("a successful render advances the observed revision");
assert!(fetch < render && render < revision);
}
}
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -1,8 +1,12 @@
//! Local, board-agnostic configurator server and transport modules.
pub mod api;
pub mod config_client;
pub mod log_monitor;
pub mod packet_monitor;
pub mod serial_worker;
pub mod state;
mod api;
mod config_client;
mod log_monitor;
mod packet_monitor;
mod serial_worker;
mod state;
pub use api::serve_connection;
pub use serial_worker::{SerialBaud, SerialController};
pub use state::SharedState;
+24 -38
View File
@@ -3,6 +3,7 @@
use std::{collections::VecDeque, time::SystemTime};
use kaptan_log::Level;
use kaptan_mavlink::generated::kaptan::LogLevel;
use serde::{Serialize, Serializer};
const MAX_LOG_ENTRIES: usize = 512;
@@ -14,30 +15,23 @@ where
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;
}
/// Decodes a wire event level, rejecting the filter-only Off value.
pub const fn event_level_from_wire(level: LogLevel) -> Option<Level> {
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,
LogLevel::LOG_LEVEL_OFF => None,
LogLevel::LOG_LEVEL_TRACE => Some(Level::Trace),
LogLevel::LOG_LEVEL_DEBUG => Some(Level::Debug),
LogLevel::LOG_LEVEL_INFO => Some(Level::Info),
LogLevel::LOG_LEVEL_WARN => Some(Level::Warn),
LogLevel::LOG_LEVEL_ERROR => Some(Level::Error),
}
}
/// 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})"),
/// Human-readable packet-view representation.
pub const fn wire_level_label(level: LogLevel) -> &'static str {
match event_level_from_wire(level) {
Some(level) => level.as_str(),
None => "OFF",
}
}
@@ -194,27 +188,19 @@ mod tests {
}
#[test]
fn wire_level_accepts_only_the_exact_kaptan_contract() {
fn wire_level_accepts_only_event_values() {
assert_eq!(event_level_from_wire(LogLevel::LOG_LEVEL_OFF), None);
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),
(LogLevel::LOG_LEVEL_TRACE, Level::Trace),
(LogLevel::LOG_LEVEL_DEBUG, Level::Debug),
(LogLevel::LOG_LEVEL_INFO, Level::Info),
(LogLevel::LOG_LEVEL_WARN, Level::Warn),
(LogLevel::LOG_LEVEL_ERROR, Level::Error),
] {
assert_eq!(level_from_wire(wire, false), expected, "wire {wire}");
assert_eq!(event_level_from_wire(wire), Some(expected));
}
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)");
assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_TRACE), "TRACE");
assert_eq!(wire_level_label(LogLevel::LOG_LEVEL_OFF), "OFF");
}
#[test]
+65 -18
View File
@@ -2,19 +2,21 @@
use std::{net::TcpListener, sync::Arc};
use kaptan_configurator::{
api::serve_connection,
serial_worker::{SerialBaud, SerialController},
state::SharedState,
};
use kaptan_configurator::{SerialBaud, SerialController, SharedState, serve_connection};
use kaptan_file_mavlink::Principal;
const LISTEN_ADDRESS: &str = "127.0.0.1:4861";
const GCS_COMPONENT_ID: u8 = 190;
fn main() -> std::io::Result<()> {
let arguments = arguments()?;
let state = Arc::new(SharedState::default());
let serial =
SerialController::new(Arc::clone(&state), arguments.baud).map_err(std::io::Error::other)?;
let serial = SerialController::new(
Arc::clone(&state),
arguments.baud,
Principal::new(arguments.source_system, GCS_COMPONENT_ID),
)
.map_err(std::io::Error::other)?;
if let Some(path) = arguments.serial_path {
serial.connect(path).map_err(std::io::Error::other)?;
}
@@ -39,6 +41,7 @@ fn main() -> std::io::Result<()> {
struct Arguments {
serial_path: Option<String>,
baud: SerialBaud,
source_system: u8,
}
fn arguments() -> std::io::Result<Arguments> {
@@ -49,6 +52,7 @@ fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<A
let mut serial_path = None;
let mut baud = SerialBaud::DEFAULT;
let mut baud_seen = false;
let mut source_system = None;
while let Some(flag) = values.next() {
match flag.as_str() {
"--serial" if serial_path.is_none() => {
@@ -67,16 +71,30 @@ fn arguments_from(mut values: impl Iterator<Item = String>) -> std::io::Result<A
.ok_or_else(|| invalid_arguments(&flag))?;
baud_seen = true;
}
"--source-system" if source_system.is_none() => {
let value = values.next().ok_or_else(|| invalid_arguments(&flag))?;
source_system = value.parse::<u8>().ok().filter(|value| *value != 0);
if source_system.is_none() {
return Err(invalid_arguments(&flag));
}
}
_ => return Err(invalid_arguments(&flag)),
}
}
Ok(Arguments { serial_path, baud })
let source_system = source_system.ok_or_else(|| invalid_arguments("--source-system"))?;
Ok(Arguments {
serial_path,
baud,
source_system,
})
}
fn invalid_arguments(flag: &str) -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid {flag}; usage: kaptan-configurator [--serial PATH] [--baud RATE]"),
format!(
"invalid {flag}; usage: kaptan-configurator --source-system ID [--serial PATH] [--baud RATE]"
),
)
}
@@ -90,31 +108,60 @@ mod tests {
}
#[test]
fn command_line_defaults_to_115200_and_accepts_an_optional_serial_path() {
let accepted = arguments_from(["--serial", "/dev/ttyACM0"].into_iter().map(String::from))
fn command_line_defaults_to_115200_and_requires_an_exact_source_system() {
let accepted = arguments_from(
["--source-system", "255", "--serial", "/dev/ttyACM0"]
.into_iter()
.map(String::from),
)
.expect("recovery endpoint is accepted");
assert_eq!(accepted.serial_path.as_deref(), Some("/dev/ttyACM0"));
assert_eq!(accepted.baud.bits_per_second(), 115_200);
assert_eq!(accepted.baud, SerialBaud::try_new(115_200).unwrap());
assert_eq!(accepted.source_system, 255);
}
#[test]
fn command_line_accepts_a_supported_baud_in_either_order() {
for values in [
["--baud", "57600", "--serial", "/dev/ttyUSB0"],
["--serial", "/dev/ttyUSB0", "--baud", "57600"],
[
"--source-system",
"254",
"--baud",
"57600",
"--serial",
"/dev/ttyUSB0",
],
[
"--serial",
"/dev/ttyUSB0",
"--baud",
"57600",
"--source-system",
"254",
],
] {
let accepted = arguments_from(values.into_iter().map(String::from)).unwrap();
assert_eq!(accepted.serial_path.as_deref(), Some("/dev/ttyUSB0"));
assert_eq!(accepted.baud.bits_per_second(), 57_600);
assert_eq!(accepted.baud, SerialBaud::try_new(57_600).unwrap());
}
}
#[test]
fn command_line_rejects_unsupported_or_duplicate_baud_arguments() {
for values in [
vec!["--baud", "12345"],
vec!["--baud", "115200", "--baud", "57600"],
vec!["--baud"],
vec!["--source-system", "255", "--baud", "12345"],
vec![
"--source-system",
"255",
"--baud",
"115200",
"--baud",
"57600",
],
vec!["--source-system", "255", "--baud"],
vec!["--source-system", "0"],
vec!["--source-system", "255", "--source-system", "254"],
vec![],
] {
let rejected = arguments_from(values.into_iter().map(String::from))
.expect_err("invalid baud arguments must be rejected");
@@ -166,12 +166,15 @@ mod tests {
fn aggregates_counts_and_rates_without_packet_history() {
let start = Instant::now();
let mut monitor = PacketMonitor::default();
monitor.observe([packet(0, "HEARTBEAT"), packet(0, "HEARTBEAT")], start);
monitor.observe(
[packet(51_027, "HEARTBEAT"), packet(51_027, "HEARTBEAT")],
start,
);
monitor.observe(
[
packet(0, "HEARTBEAT"),
packet(0, "HEARTBEAT"),
packet(0, "HEARTBEAT"),
packet(51_027, "HEARTBEAT"),
packet(51_027, "HEARTBEAT"),
packet(51_027, "HEARTBEAT"),
],
start + Duration::from_millis(500),
);
@@ -180,14 +183,14 @@ mod tests {
assert_eq!(snapshot.packets.len(), 1);
assert_eq!(snapshot.packets[0].count, 5);
assert_eq!(snapshot.packets[0].frequency_hz, 5.0);
assert_eq!(snapshot.packets[0].values["sequence"], 0);
assert_eq!(snapshot.packets[0].values["sequence"], 51_027);
}
#[test]
fn reports_zero_after_a_window_with_no_packets() {
let start = Instant::now();
let mut monitor = PacketMonitor::default();
monitor.observe([packet(0, "HEARTBEAT")], start);
monitor.observe([packet(51_027, "HEARTBEAT")], start);
assert_eq!(
monitor.snapshot(start + RATE_WINDOW).packets[0].frequency_hz,
1.0
@@ -201,9 +204,9 @@ mod tests {
#[test]
fn retains_only_the_latest_decoded_values_for_each_identity() {
let start = Instant::now();
let mut first = packet(51021, "KAPTAN_ERROR");
let mut first = packet(51021, "ERROR");
first.values = serde_json::json!({"path": "first"});
let mut second = packet(51021, "KAPTAN_ERROR");
let mut second = packet(51021, "ERROR");
second.values = serde_json::json!({"path": "second"});
let mut monitor = PacketMonitor::default();
@@ -240,7 +243,7 @@ mod tests {
fn clear_starts_a_new_link_session() {
let start = Instant::now();
let mut monitor = PacketMonitor::default();
monitor.observe([packet(0, "HEARTBEAT")], start);
monitor.observe([packet(51_027, "HEARTBEAT")], start);
monitor.clear();
assert!(monitor.snapshot(start + RATE_WINDOW).packets.is_empty());
}
+274 -177
View File
@@ -14,18 +14,19 @@ use std::{
};
use crate::{
config_client::{ClientEvent, ConfigClient},
log_monitor::{LogObservation, level_from_wire, wire_level_label},
config_client::{ConfigClient, Edit, Outcome},
log_monitor::{LogObservation, event_level_from_wire, wire_level_label},
packet_monitor::PacketObservation,
state::{SaveRequest, SharedState},
state::SharedState,
};
use kaptan_file_mavlink::{Principal, Target};
use kaptan_log::Level;
use kaptan_mavlink::{
MavlinkFrame,
message::{self, Message},
generated::kaptan::{HeartbeatState, LogFlags, MavMessage},
receiver::IntegrityMavlinkReceiver,
};
use mavlink_core::MavlinkVersion;
use mavlink_core::Message;
const COMMAND_CAPACITY: usize = 8;
const IDLE_POLL: Duration = Duration::from_millis(20);
@@ -48,11 +49,6 @@ impl SerialBaud {
}
}
/// Returns the user-selected numeric line rate.
pub const fn bits_per_second(self) -> u32 {
self.0
}
fn termios_speed(self) -> libc::speed_t {
match self.0 {
9_600 => libc::B9600,
@@ -68,10 +64,10 @@ impl SerialBaud {
}
}
pub enum Command {
pub(crate) enum Command {
Connect { path: String, baud: SerialBaud },
Disconnect,
Save(SaveRequest),
Save(Vec<Edit>),
Shutdown,
}
@@ -82,11 +78,15 @@ pub struct SerialController {
}
impl SerialController {
pub fn new(state: Arc<SharedState>, baud: SerialBaud) -> Result<Self, String> {
pub fn new(
state: Arc<SharedState>,
baud: SerialBaud,
local: Principal,
) -> Result<Self, String> {
let (commands, receiver) = sync_channel(COMMAND_CAPACITY);
let worker = thread::Builder::new()
.name("kaptan-config-serial".into())
.spawn(move || worker(receiver, state))
.spawn(move || worker(receiver, state, local))
.map_err(|error| error.to_string())?;
Ok(Self {
commands,
@@ -103,7 +103,7 @@ impl SerialController {
})
}
pub fn send(&self, command: Command) -> Result<(), String> {
pub(crate) fn send(&self, command: Command) -> Result<(), String> {
self.commands
.try_send(command)
.map_err(|error| match error {
@@ -112,7 +112,7 @@ impl SerialController {
})
}
pub fn shutdown(&self) {
fn shutdown(&self) {
let _ = self.commands.send(Command::Shutdown);
if let Some(worker) = lock(&self.worker).take() {
let _ = worker.join();
@@ -126,27 +126,30 @@ impl Drop for SerialController {
}
}
#[derive(Default)]
struct Session {
link: Option<File>,
receiver: IntegrityMavlinkReceiver<1>,
client: ConfigClient,
target: Option<(u8, u8)>,
sequence: u8,
}
impl Session {
fn new(local: Principal) -> Self {
Self {
link: None,
receiver: IntegrityMavlinkReceiver::default(),
client: ConfigClient::new(local),
}
}
fn close(&mut self) {
self.link = None;
self.receiver.reset();
self.client.reset();
self.target = None;
self.sequence = 0;
}
}
fn worker(commands: Receiver<Command>, state: Arc<SharedState>) {
let mut session = Session::default();
fn worker(commands: Receiver<Command>, state: Arc<SharedState>, local: Principal) {
let mut session = Session::new(local);
loop {
loop {
match commands.try_recv() {
@@ -190,8 +193,8 @@ fn handle_command(command: Command, session: &mut Session, state: &SharedState)
session.close();
state.disconnected(None);
}
Command::Save(save) => {
let request = session.client.save(save.edits, &mut session.sequence);
Command::Save(edits) => {
let request = session.client.save(edits);
match request {
Ok(request) => {
if let Err(error) = write_raw(session, request.raw_bytes()) {
@@ -233,40 +236,30 @@ fn service_link(session: &mut Session, state: &SharedState) -> Result<(), String
frames.push(frame);
}
}
let mut packets = Vec::with_capacity(frames.len());
let mut logs = Vec::new();
for frame in &frames {
let (packet, log) = frame_observations(frame);
packets.push(packet);
logs.extend(log);
}
state.observe_packets(packets);
state.observe_logs(logs);
for frame in frames {
if session.target.is_none()
&& let Some(target) = heartbeat_target(&frame)
let Some(message) = frame.decode() else {
continue;
};
let raw = frame.raw();
let (packet, log) = decoded_frame_observations(raw, &message);
state.observe_packets([packet]);
state.observe_logs(log);
if session.client.target().is_err()
&& let Some(target) = decoded_heartbeat_target(raw, &message)
{
session.target = Some(target);
state.discovering();
let request = session.client.discover(target, &mut session.sequence)?;
let request = session.client.discover(target)?;
write_raw(session, request.raw_bytes())?;
}
let (event, next) = session.client.observe(&frame, &mut session.sequence);
if let Some(event) = event {
handle_client_event(session, state, event);
}
if let Some(next) = next {
write_raw(session, next.raw_bytes())?;
}
let Some(source) = Target::try_new(raw.system_id(), raw.component_id()) else {
continue;
};
let outcome = session.client.observe(source, &message);
handle_client_outcome(session, state, outcome)?;
}
let (event, retry) = session.client.retry_due(&mut session.sequence);
if let Some(event) = event {
handle_client_event(session, state, event);
}
if let Some(request) = retry {
write_raw(session, request.raw_bytes())?;
}
let outcome = session.client.retry_due();
handle_client_outcome(session, state, outcome)?;
Ok(())
}
@@ -275,66 +268,46 @@ fn packet_observation(frame: &MavlinkFrame) -> PacketObservation {
frame_observations(frame).0
}
#[cfg(test)]
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received()?.decode()
}
#[cfg(test)]
fn frame_observations(frame: &MavlinkFrame) -> (PacketObservation, Option<LogObservation>) {
let message_id = frame.message_id();
let (name, values, log) = decoded_observations(frame);
let message = parse_test_message(frame).expect("test frame must be CRC-valid Kaptan MAVLink");
decoded_frame_observations(frame, &message)
}
fn decoded_frame_observations(
frame: &MavlinkFrame,
message: &MavMessage,
) -> (PacketObservation, Option<LogObservation>) {
let packet = PacketObservation {
system_id: frame.system_id(),
component_id: frame.component_id(),
message_id,
name,
values,
message_id: frame.message_id(),
name: message.message_name(),
values: kaptan_message_values(message),
};
(packet, log)
(packet, log_observation(frame, message))
}
fn decoded_observations(
frame: &MavlinkFrame,
) -> (&'static str, serde_json::Value, Option<LogObservation>) {
if frame.has_valid_crc::<message::kaptan::MavMessage>()
&& let Ok(message) = message::kaptan::MavMessage::parse(
MavlinkVersion::V2,
frame.message_id(),
frame.payload(),
)
{
return (
message.message_name(),
kaptan_message_values(&message),
log_observation(frame, &message),
);
}
if frame.has_valid_crc::<message::MavMessage>()
&& let Ok(message) =
message::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload())
{
let name = message.message_name();
return (name, decoded_fields(&message), None);
}
(
"UNKNOWN",
serde_json::json!({"payload_hex": hex_bytes(frame.payload())}),
None,
)
}
fn log_observation(
frame: &MavlinkFrame,
message: &message::kaptan::MavMessage,
) -> Option<LogObservation> {
fn log_observation(frame: &MavlinkFrame, message: &MavMessage) -> Option<LogObservation> {
let (level, truncated, text) = match message {
message::kaptan::MavMessage::KAPTAN_LOG(log) => {
let trace = log
.flags
.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE);
(
level_from_wire(log.level, trace)?,
log.flags
.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED),
MavMessage::LOG(log) => (
event_level_from_wire(log.level)?,
log.flags.contains(LogFlags::LOG_FLAG_TRUNCATED),
bounded_text(&log.message, log.message_length)?,
)
),
MavMessage::ERROR(error) => {
if error.error_id == 0 {
return None;
}
message::kaptan::MavMessage::KAPTAN_ERROR(error) => {
let raised_at = zero_padded_text(&error.raised_at)?;
let text = if raised_at.is_empty() {
format!("Kaptan error {}", error.error_id)
@@ -354,29 +327,59 @@ fn log_observation(
})
}
fn kaptan_message_values(message: &message::kaptan::MavMessage) -> serde_json::Value {
fn kaptan_message_values(message: &MavMessage) -> serde_json::Value {
match message {
message::kaptan::MavMessage::KAPTAN_ERROR(error) => serde_json::json!({
MavMessage::ERROR(error) => serde_json::json!({
"error_id": error.error_id,
"raised_at": packet_zero_padded_text(&error.raised_at),
}),
message::kaptan::MavMessage::KAPTAN_LOG(log) => serde_json::json!({
"level": wire_level_label(
log.level,
log.flags.contains(message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRACE),
),
MavMessage::LOG(log) => serde_json::json!({
"level": wire_level_label(log.level),
"flags": format!("{:?}", log.flags),
"flags_bits": log.flags.bits(),
"message": packet_text(&log.message, log.message_length),
}),
_ => decoded_fields(message),
MavMessage::HEARTBEAT(heartbeat) => serde_json::json!({
"state": format!("{:?}", heartbeat.state),
}),
MavMessage::IMU(imu) => serde_json::json!({
"time_us": imu.time_us,
"acceleration_milligravity": imu.acceleration_milligravity,
"angular_rate_milliradians_per_second": imu.angular_rate_milliradians_per_second,
"temperature_centidegrees_celsius": imu.temperature_centidegrees_celsius,
}),
MavMessage::RANGE(range) => serde_json::json!({
"time_us": range.time_us,
"distance_millimetres": range.distance_millimetres,
}),
MavMessage::OPTICAL_FLOW(flow) => serde_json::json!({
"time_us": flow.time_us,
"velocity_millimetres_per_second": flow.velocity_millimetres_per_second,
"ground_distance_millimetres": flow.ground_distance_millimetres,
"quality": flow.quality,
}),
MavMessage::FILE_CONTROL(control) => serde_json::json!({
"target_system": control.target_system,
"target_component": control.target_component,
"operation": format!("{:?}", control.operation),
"status": format!("{:?}", control.status),
"transfer_id": control.transfer_id,
"position": control.position,
"file_size": control.file_size,
"checksum_crc32c": control.checksum_crc32c,
"content_hex": hex_bytes(&control.content[..usize::from(control.content_length)
.min(control.content.len())]),
}),
MavMessage::FILE_DATA(data) => serde_json::json!({
"target_system": data.target_system,
"target_component": data.target_component,
"status": format!("{:?}", data.status),
"transfer_id": data.transfer_id,
"position": data.position,
"data_hex": hex_bytes(&data.data),
}),
}
}
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) -> Option<String> {
let text = core::str::from_utf8(bytes.get(..usize::from(length))?).ok()?;
Some(text.into())
@@ -405,11 +408,19 @@ fn hex_bytes(bytes: &[u8]) -> String {
encoded
}
fn handle_client_event(session: &mut Session, state: &SharedState, event: ClientEvent) {
if matches!(event, ClientEvent::RediscoveryRequired(_)) {
session.target = None;
}
fn handle_client_outcome(
session: &mut Session,
state: &SharedState,
outcome: Outcome,
) -> Result<(), String> {
match outcome {
Outcome::Idle => Ok(()),
Outcome::Event(event) => {
state.configuration_event(event);
Ok(())
}
Outcome::Send(request) => write_raw(session, request.raw_bytes()),
}
}
fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> {
@@ -421,17 +432,20 @@ fn write_raw(session: &mut Session, bytes: &[u8]) -> Result<(), String> {
.map_err(|error| error.to_string())
}
fn heartbeat_target(frame: &MavlinkFrame) -> Option<(u8, u8)> {
if !frame.has_valid_crc::<message::MavMessage>() {
fn decoded_heartbeat_target(frame: &MavlinkFrame, message: &MavMessage) -> Option<Target> {
let MavMessage::HEARTBEAT(heartbeat) = message else {
return None;
};
if heartbeat.state != HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED {
return None;
}
let message =
message::MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload()).ok()?;
if !matches!(message, message::MavMessage::HEARTBEAT(_)) {
return None;
}
let target = (frame.system_id(), frame.component_id());
(target.0 != 0 && target.1 != 0).then_some(target)
Target::try_new(frame.system_id(), frame.component_id())
}
#[cfg(test)]
fn heartbeat_target(frame: &MavlinkFrame) -> Option<Target> {
let message = parse_test_message(frame)?;
decoded_heartbeat_target(frame, &message)
}
fn open_serial(path: &str, baud: SerialBaud) -> Result<File, String> {
@@ -481,11 +495,18 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
#[cfg(test)]
mod tests {
use super::*;
use kaptan_mavlink::message::{
HEARTBEAT_DATA, MavAutopilot, MavHeader, MavModeFlag, MavState, MavType,
use kaptan_file_mavlink::DATA_BYTES;
use kaptan_mavlink::generated::kaptan::{
ERROR_DATA, FILE_DATA_DATA, FileStatus as WireFileStatus, HEARTBEAT_DATA, IMU_DATA,
LOG_DATA, LogLevel,
};
use mavlink_core::{MavHeader, MessageData};
fn heartbeat(system_id: u8, component_id: u8, autopilot: MavAutopilot) -> MavlinkFrame {
fn frame<M: MessageData<Message = MavMessage>>(
system_id: u8,
component_id: u8,
data: &M,
) -> MavlinkFrame {
let mut raw = MavlinkFrame::new();
raw.serialize_message_data(
MavHeader {
@@ -493,31 +514,31 @@ mod tests {
system_id,
component_id,
},
&HEARTBEAT_DATA {
custom_mode: 0,
mavtype: MavType::MAV_TYPE_ONBOARD_CONTROLLER,
autopilot,
base_mode: MavModeFlag::empty(),
system_status: MavState::MAV_STATE_STANDBY,
mavlink_version: 3,
},
data,
);
raw
}
fn heartbeat(system_id: u8, component_id: u8) -> MavlinkFrame {
frame(
system_id,
component_id,
&HEARTBEAT_DATA {
state: HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED,
},
)
}
#[test]
fn point_to_point_discovery_accepts_kaptans_invalid_autopilot_heartbeat() {
let frame = heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_INVALID);
assert_eq!(heartbeat_target(&frame), Some((42, 7)));
fn point_to_point_discovery_accepts_the_exact_kaptan_heartbeat() {
let frame = heartbeat(42, 7);
assert_eq!(heartbeat_target(&frame), Some(Target::new(42, 7)));
}
#[test]
fn heartbeat_discovery_rejects_broadcast_sources_and_unverified_frames() {
assert_eq!(
heartbeat_target(&heartbeat(0, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC)),
None
);
let frame = heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC);
assert_eq!(heartbeat_target(&heartbeat(0, 7)), None);
let frame = heartbeat(42, 7);
let mut bytes = frame.into_inner();
bytes[10] ^= 1;
let unverified = MavlinkFrame::from_bytes_unparsed(bytes);
@@ -526,16 +547,34 @@ mod tests {
#[test]
fn packet_monitor_uses_the_decoded_dialect_name() {
let observation =
packet_observation(&heartbeat(42, 7, MavAutopilot::MAV_AUTOPILOT_GENERIC));
let observation = packet_observation(&heartbeat(42, 7));
assert_eq!(observation.system_id, 42);
assert_eq!(observation.component_id, 7);
assert_eq!(observation.message_id, 0);
assert_eq!(observation.message_id, 51_027);
assert_eq!(observation.name, "HEARTBEAT");
let decoded = observation.values.as_str().unwrap();
assert!(decoded.contains("custom_mode: 0"));
assert!(decoded.contains("MAV_STATE_STANDBY"));
assert!(decoded.contains('\n'));
assert_eq!(
observation.values["state"],
"HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED"
);
}
#[test]
fn file_data_packet_exposes_its_direction() {
let observation = packet_observation(&frame(
42,
7,
&FILE_DATA_DATA {
position: 246,
transfer_id: 9,
target_system: 255,
target_component: 190,
status: WireFileStatus::FILE_STATUS_OK,
data: [0; DATA_BYTES],
},
));
assert_eq!(observation.name, "FILE_DATA");
assert_eq!(observation.values["status"], "FILE_STATUS_OK");
}
#[test]
@@ -550,14 +589,14 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_ERROR_DATA {
&ERROR_DATA {
error_id: 1234,
raised_at,
},
);
let (observation, log) = frame_observations(&frame);
assert_eq!(observation.name, "KAPTAN_ERROR");
assert_eq!(observation.name, "ERROR");
assert!(observation.values.get("level").is_none());
assert_eq!(observation.values["error_id"], 1234);
assert_eq!(observation.values["raised_at"], raised_at_text);
@@ -589,16 +628,16 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_LOG_DATA {
level: 6,
flags: message::kaptan::KaptanLogFlags::empty(),
&LOG_DATA {
level: LogLevel::LOG_LEVEL_INFO,
flags: LogFlags::empty(),
message_length: message_text.len() as u8,
message: message_bytes,
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.name, "KAPTAN_LOG");
assert_eq!(packet.name, "LOG");
assert_eq!(packet.values["message"], "estimator initialized");
assert_eq!(
log,
@@ -612,6 +651,28 @@ mod tests {
);
}
#[test]
fn off_level_is_visible_as_a_packet_but_not_a_log_event() {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
MavHeader {
sequence: 1,
system_id: 42,
component_id: 7,
},
&LOG_DATA {
level: LogLevel::LOG_LEVEL_OFF,
flags: LogFlags::empty(),
message_length: 0,
message: [0; 160],
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.values["level"], "OFF");
assert_eq!(log, None);
}
#[test]
fn error_level_runtime_log_remains_kaptan_log() {
let message_bytes = [b'x'; 160];
@@ -623,16 +684,16 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_LOG_DATA {
level: 3,
flags: message::kaptan::KaptanLogFlags::KAPTAN_LOG_FLAG_TRUNCATED,
&LOG_DATA {
level: LogLevel::LOG_LEVEL_ERROR,
flags: LogFlags::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!(packet.name, "LOG");
assert_eq!(
log,
Some(LogObservation {
@@ -646,23 +707,17 @@ mod tests {
}
#[test]
fn malformed_log_text_or_level_is_not_added_to_history() {
fn malformed_log_text_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(),
LogLevel::LOG_LEVEL_INFO,
LogFlags::empty(),
161,
[0_u8; 160],
),
(6, message::kaptan::KaptanLogFlags::empty(), 1, invalid_utf8),
(LogLevel::LOG_LEVEL_INFO, LogFlags::empty(), 1, invalid_utf8),
] {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
@@ -671,7 +726,7 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_LOG_DATA {
&LOG_DATA {
level,
flags,
message_length: length,
@@ -683,6 +738,28 @@ mod tests {
}
}
#[test]
fn native_sensor_packet_values_are_structured() {
let observation = packet_observation(&frame(
42,
7,
&IMU_DATA {
time_us: 123,
acceleration_milligravity: [1, 2, 3],
angular_rate_milliradians_per_second: [-4, 5, -6],
temperature_centidegrees_celsius: 2_500,
},
));
assert_eq!(observation.name, "IMU");
assert_eq!(observation.values["time_us"], 123);
assert_eq!(
observation.values["angular_rate_milliradians_per_second"],
serde_json::json!([-4, 5, -6])
);
assert!(observation.values.as_str().is_none());
}
#[test]
fn malformed_reportable_error_location_is_not_added_to_history() {
let mut raised_at = [0_u8; 96];
@@ -694,7 +771,7 @@ mod tests {
system_id: 42,
component_id: 7,
},
&message::kaptan::KAPTAN_ERROR_DATA {
&ERROR_DATA {
error_id: 1,
raised_at,
},
@@ -703,13 +780,33 @@ mod tests {
assert_eq!(log, None);
}
#[test]
fn reserved_zero_error_id_is_visible_as_a_packet_but_not_an_event() {
let mut frame = MavlinkFrame::new();
frame.serialize_message_data(
MavHeader {
sequence: 1,
system_id: 42,
component_id: 7,
},
&ERROR_DATA {
error_id: 0,
raised_at: [0; 96],
},
);
let (packet, log) = frame_observations(&frame);
assert_eq!(packet.values["error_id"], 0);
assert_eq!(log, None);
}
#[test]
fn serial_baud_accepts_only_rates_with_an_exact_termios_mapping() {
for rate in [
9_600, 19_200, 38_400, 57_600, 115_200, 230_400, 460_800, 921_600,
] {
let baud = SerialBaud::try_new(rate).expect("supported rate");
assert_eq!(baud.bits_per_second(), rate);
assert_eq!(baud, SerialBaud::try_new(rate).unwrap());
assert_ne!(baud.termios_speed(), 0);
}
assert_eq!(SerialBaud::try_new(0), None);
+97 -51
View File
@@ -6,13 +6,11 @@ use std::{
time::{Instant, SystemTime},
};
use kaptan_config::{ConfigValue, FieldId, ValueKind};
use serde::Serialize;
use crate::{
config_client::{
ClientEvent, Edit, RemoteConfig, RemoteField, Scalar, ValueKind, scalar_json,
scalar_less_than,
},
config_client::{ClientEvent, Edit, RemoteConfig, RemoteField, scalar_json, scalar_less_than},
log_monitor::{LogMonitor, LogObservation, LogSnapshot},
packet_monitor::{PacketMonitor, PacketObservation, PacketSnapshot},
};
@@ -23,6 +21,7 @@ pub struct StateSnapshot {
pub status: String,
pub error: Option<String>,
pub config_revision: u64,
pub has_config: bool,
pub can_edit: bool,
pub can_save: bool,
pub can_disconnect: bool,
@@ -46,7 +45,7 @@ pub struct FieldView {
pub kind: &'static str,
pub minimum: Option<serde_json::Value>,
pub maximum: Option<serde_json::Value>,
pub default: serde_json::Value,
pub factory: serde_json::Value,
pub value: serde_json::Value,
pub help: String,
pub options: Vec<EnumOptionView>,
@@ -58,11 +57,6 @@ pub struct EnumOptionView {
pub label: String,
}
#[derive(Clone)]
pub struct SaveRequest {
pub edits: Vec<Edit>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Operation {
Disconnected,
@@ -70,6 +64,7 @@ enum Operation {
Discovering,
Ready,
Saving,
DurableStateUnknown,
}
struct State {
@@ -78,7 +73,7 @@ struct State {
status: String,
error: Option<String>,
config: Option<RemoteConfig>,
draft: BTreeMap<u16, Scalar>,
draft: BTreeMap<FieldId, ConfigValue>,
config_revision: u64,
packets: PacketMonitor,
logs: LogMonitor,
@@ -111,6 +106,7 @@ impl SharedState {
status: state.status.clone(),
error: state.error.clone(),
config_revision: state.config_revision,
has_config: state.config.is_some(),
can_edit: state.operation == Operation::Ready && state.config.is_some(),
can_save: state.operation == Operation::Ready && !state.draft.is_empty(),
can_disconnect: !matches!(state.operation, Operation::Disconnected),
@@ -128,8 +124,13 @@ impl SharedState {
};
};
ConfigSnapshot {
schema_id: Some(hex(&config.info.schema_id)),
state: config.info.state.clone(),
schema_id: Some(hex(&config.schema_id)),
state: if config.restart_required {
"restart required"
} else {
"active"
}
.into(),
fields: config
.fields
.iter()
@@ -195,16 +196,16 @@ impl SharedState {
pub fn discovering(&self) {
let mut state = lock(&self.0);
state.operation = Operation::Discovering;
state.status = "Reading configuration schema and values…".into();
state.status = "Exporting configuration file…".into();
state.error = None;
}
pub fn configuration_event(&self, event: ClientEvent) {
pub(crate) fn configuration_event(&self, event: ClientEvent) {
let mut state = lock(&self.0);
match event {
ClientEvent::Configuration(config) => {
state.operation = Operation::Ready;
state.status = if config.info.state == "restart required" {
state.status = if config.restart_required {
"Saved. Restart the controller to activate the new configuration.".into()
} else {
"Configuration ready.".into()
@@ -224,8 +225,17 @@ impl SharedState {
}
ClientEvent::RediscoveryRequired(detail) => {
state.operation = Operation::Discovering;
state.status = "Waiting to rediscover configuration…".into();
state.error = Some(detail);
state.config = None;
state.draft.clear();
state.config_revision = state.config_revision.wrapping_add(1);
}
ClientEvent::DurableStateUnknown(detail) => {
state.operation = Operation::DurableStateUnknown;
state.status =
"Controller state changed; waiting to rediscover configuration…".into();
"Configuration state is unknown. Power-cycle the controller, then disconnect and reconnect."
.into();
state.error = Some(detail);
state.config = None;
state.draft.clear();
@@ -235,6 +245,8 @@ impl SharedState {
}
pub fn edit(&self, field_id: u16, value: &serde_json::Value) -> Result<ConfigSnapshot, String> {
let field_id =
FieldId::try_new(field_id).ok_or_else(|| format!("unknown field id {field_id}"))?;
let mut state = lock(&self.0);
if state.operation != Operation::Ready {
return Err("configuration is not ready for edits".into());
@@ -243,7 +255,7 @@ impl SharedState {
.config
.as_ref()
.and_then(|config| config.fields.iter().find(|field| field.id == field_id))
.ok_or_else(|| format!("unknown field id {field_id}"))?;
.ok_or_else(|| format!("unknown field id {}", field_id.get()))?;
let next = parse_scalar(field.kind, value)?;
validate_bounds(field, next)?;
if next == field.value {
@@ -257,7 +269,7 @@ impl SharedState {
Ok(self.configuration())
}
pub fn begin_save(&self) -> Result<SaveRequest, String> {
pub(crate) fn begin_save(&self) -> Result<Vec<Edit>, String> {
let mut state = lock(&self.0);
if state.operation != Operation::Ready {
return Err("configuration is busy".into());
@@ -269,20 +281,18 @@ impl SharedState {
if state.draft.is_empty() {
return Err("configuration has no changed fields".into());
}
let request = SaveRequest {
edits: state
let edits = state
.draft
.iter()
.map(|(field_id, value)| Edit {
field_id: *field_id,
value: *value,
})
.collect(),
};
.collect();
state.operation = Operation::Saving;
state.status = "Validating and saving configuration…".into();
state.error = None;
Ok(request)
Ok(edits)
}
pub fn worker_failed(&self, detail: String) {
@@ -298,13 +308,13 @@ impl SharedState {
}
}
fn field_view(field: &RemoteField, draft: Option<Scalar>) -> FieldView {
fn field_view(field: &RemoteField, draft: Option<ConfigValue>) -> FieldView {
let (group, tail) = field
.key
.rsplit_once('.')
.unwrap_or(("configuration", field.key.as_str()));
FieldView {
id: field.id,
id: field.id.get(),
key: field.key.clone(),
label: humanize(tail),
group: humanize(group),
@@ -312,25 +322,27 @@ fn field_view(field: &RemoteField, draft: Option<Scalar>) -> FieldView {
kind: match field.kind {
ValueKind::Bool => "bool",
ValueKind::F32 | ValueKind::F64 => "decimal",
ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => "integer",
ValueKind::U8 | ValueKind::U16 | ValueKind::I32 | ValueKind::U32 | ValueKind::U64 => {
"integer"
}
},
minimum: field.minimum.map(scalar_json),
maximum: field.maximum.map(scalar_json),
default: scalar_json(field.default),
factory: scalar_json(field.factory),
value: scalar_json(draft.unwrap_or(field.value)),
help: format!("Stable field ID {}.", field.id),
help: format!("Stable field ID {}.", field.id.get()),
options: field
.options
.iter()
.map(|option| EnumOptionView {
value: scalar_json(option.value),
value: option.value.into(),
label: option.label.clone(),
})
.collect(),
}
}
fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, String> {
fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<ConfigValue, String> {
let text = value
.as_str()
.map(str::to_owned)
@@ -338,19 +350,27 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
match kind {
ValueKind::Bool => value
.as_bool()
.map(Scalar::Bool)
.map(ConfigValue::Bool)
.ok_or_else(|| "expected a boolean".into()),
ValueKind::U8 => text
.parse()
.map(ConfigValue::U8)
.map_err(|_| "expected an unsigned 8-bit integer".into()),
ValueKind::U16 => text
.parse()
.map(ConfigValue::U16)
.map_err(|_| "expected an unsigned 16-bit integer".into()),
ValueKind::I32 => text
.parse()
.map(Scalar::I32)
.map(ConfigValue::I32)
.map_err(|_| "expected a signed 32-bit integer".into()),
ValueKind::U32 => text
.parse()
.map(Scalar::U32)
.map(ConfigValue::U32)
.map_err(|_| "expected an unsigned 32-bit integer".into()),
ValueKind::U64 => text
.parse()
.map(Scalar::U64)
.map(ConfigValue::U64)
.map_err(|_| "expected an unsigned 64-bit integer".into()),
ValueKind::F32 => text
.parse::<f32>()
@@ -358,7 +378,7 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
.and_then(|value| {
value
.is_finite()
.then_some(Scalar::F32(value))
.then_some(ConfigValue::F32(value))
.ok_or_else(|| "decimal must be finite".into())
}),
ValueKind::F64 => text
@@ -367,14 +387,20 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result<Scalar, St
.and_then(|value| {
value
.is_finite()
.then_some(Scalar::F64(value))
.then_some(ConfigValue::F64(value))
.ok_or_else(|| "decimal must be finite".into())
}),
}
}
fn validate_bounds(field: &RemoteField, value: Scalar) -> Result<(), String> {
if !field.options.is_empty() && !field.options.iter().any(|option| option.value == value) {
fn validate_bounds(field: &RemoteField, value: ConfigValue) -> Result<(), String> {
if !field.options.is_empty()
&& !matches!(
value,
ConfigValue::U8(value)
if field.options.iter().any(|option| option.value == value)
)
{
return Err(format!("{} requires a named choice", field.key));
}
if field
@@ -425,23 +451,21 @@ fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
#[cfg(test)]
mod tests {
use super::*;
use crate::config_client::{RemoteEnumOption, RemoteInfo, ValueKind};
use crate::config_client::RemoteEnumOption;
fn unknown_board() -> RemoteConfig {
RemoteConfig {
info: RemoteInfo {
schema_id: *b"UNKNOWN-SCHEMA01",
state: "active".into(),
},
restart_required: false,
fields: vec![RemoteField {
id: 401,
id: FieldId::new(401),
key: "future.sensor.rate_hz".into(),
unit: "Hz".into(),
kind: ValueKind::U32,
minimum: Some(Scalar::U32(1)),
maximum: Some(Scalar::U32(100)),
default: Scalar::U32(20),
value: Scalar::U32(25),
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(100)),
factory: ConfigValue::U32(20),
value: ConfigValue::U32(25),
options: Vec::new(),
}],
}
@@ -455,7 +479,24 @@ mod tests {
let edited = state.edit(401, &serde_json::json!(30)).unwrap();
assert_eq!(edited.fields[0].value, serde_json::json!(30));
let save = state.begin_save().unwrap();
assert_eq!(save.edits[0].field_id, 401);
assert_eq!(save[0].field_id, FieldId::new(401));
}
#[test]
fn snapshot_exposes_configuration_availability_across_link_states() {
let state = SharedState::default();
assert!(!state.snapshot().has_config);
state.connected("test".into());
state.configuration_event(ClientEvent::Configuration(unknown_board()));
assert!(state.snapshot().has_config);
state.edit(401, &serde_json::json!(30)).unwrap();
state.begin_save().unwrap();
assert!(state.snapshot().has_config);
state.disconnected(None);
assert!(!state.snapshot().has_config);
}
#[test]
@@ -469,13 +510,18 @@ mod tests {
#[test]
fn enum_choices_are_projected_and_validated_from_remote_metadata() {
let mut config = unknown_board();
config.fields[0].kind = ValueKind::U8;
config.fields[0].minimum = Some(ConfigValue::U8(1));
config.fields[0].maximum = Some(ConfigValue::U8(100));
config.fields[0].factory = ConfigValue::U8(20);
config.fields[0].value = ConfigValue::U8(25);
config.fields[0].options = vec![
RemoteEnumOption {
value: Scalar::U32(20),
value: 20,
label: "Standard".into(),
},
RemoteEnumOption {
value: Scalar::U32(25),
value: 25,
label: "Fast".into(),
},
];
+6 -5
View File
@@ -81,7 +81,7 @@ q('#save').addEventListener('click', () => {
function fieldStructure(field) {
return [
field.id, field.key, field.label, field.group, field.unit, field.kind,
field.minimum, field.maximum, field.default, field.help, field.options,
field.minimum, field.maximum, field.factory, field.help, field.options,
];
}
@@ -152,7 +152,7 @@ function buildEditor(fields) {
const bounds = field.minimum !== null || field.maximum !== null
? `Inclusive bounds: ${field.minimum ?? 'unbounded'} to ${field.maximum ?? 'unbounded'}.`
: '';
const helpText = [field.help, `Default: ${field.default}.`, bounds].filter(Boolean).join(' ');
const helpText = [field.help, `Factory: ${field.factory}.`, bounds].filter(Boolean).join(' ');
if (helpText) {
const help = document.createElement('small');
help.id = `help-${field.id}`;
@@ -402,8 +402,8 @@ function renderLogs(snapshot) {
function renderState(state) {
latestState = state;
setText(q('#connection-status'), state.path ? `${state.status} (${state.path})` : state.status);
if (state.error) showError(state.error);
q('#configuration').hidden = latestConfig?.schema_id === null || latestConfig === null;
showError(state.error ?? '');
q('#configuration').hidden = !state.has_config;
q('#packet-monitor').hidden = !state.can_disconnect;
q('#log-monitor').hidden = !state.can_disconnect;
renderPermissions();
@@ -420,8 +420,9 @@ async function refresh() {
renderPackets(packets);
renderLogs(logs);
if (state.config_revision !== configRevision) {
const config = await api('/api/config');
renderConfig(config);
configRevision = state.config_revision;
renderConfig(await api('/api/config'));
q('#configuration').hidden = latestConfig.schema_id === null;
}
} catch (error) {
+2 -4
View File
@@ -12,12 +12,9 @@ workspace = true
kaptan-err-reportable = { path = "../../libs/kaptan-err-reportable" }
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" }
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" }
@@ -25,4 +22,5 @@ kaptan-time = { path = "../../libs/kaptan-time" }
minicbor = "0.25"
[dev-dependencies]
mavlink = { version = "0.18", default-features = false, features = ["embedded"] }
kaptan-diagnostics-mavlink = { path = "../../libs/kaptan-diagnostics-mavlink" }
kaptan-mavlink = { path = "../../libs/kaptan-mavlink", default-features = false }
+2 -1
View File
@@ -4,6 +4,7 @@
//! need a reviewed A/B journal and authority path; SITL uses a same-directory
//! file replacement to make startup and configuration tests reproducible.
#[cfg(test)]
mod logging;
mod runtime;
mod store;
@@ -11,7 +12,7 @@ mod store;
#[cfg(test)]
mod tests;
pub use runtime::{CAPABILITIES, SitlConfigRuntime, configuration_service};
pub(super) use runtime::{SitlConfigRuntime, configuration_service};
pub use store::{
FileConfigStore, FileStoreError, default_profile, load_profile, write_default_profile,
};
+14 -40
View File
@@ -1,49 +1,27 @@
//! Central SITL configuration and protocol composition.
//! Central SITL configuration composition.
use kaptan_config::ConfigManager;
use kaptan_config_mavlink::{ConfigCommand, ConfigProtocol, Principal, ServiceResponse, Target};
#[cfg(test)]
use kaptan_diagnostics_mavlink::encode_log;
use kaptan_flight_config::{CandidateProfile, VehicleCapabilities, VehicleProfile};
use kaptan_flight_runtime::FlightRuntime;
#[cfg(test)]
use kaptan_log::TransportAdmission;
use kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA;
#[cfg(test)]
use kaptan_mavlink::generated::kaptan::LOG_DATA;
use super::{FileConfigStore, logging};
use super::FileConfigStore;
#[cfg(test)]
use super::logging;
/// SITL's immutable compiled capability contract.
pub const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
pub(super) const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x();
/// SITL composition root retaining the one profile manager and one protocol
/// lease state machine. The MAVLink adapter never owns either resource.
/// SITL composition root retaining the one profile manager.
pub struct SitlConfigRuntime {
runtime: FlightRuntime<FileConfigStore>,
protocol: ConfigProtocol,
}
impl SitlConfigRuntime {
/// Dispatches one command for a caller-owned lease principal.
pub fn dispatch(&mut self, input: (Principal, ConfigCommand)) -> Vec<ServiceResponse> {
let (principal, command) = input;
let mut responses = Vec::new();
self.protocol
.dispatch_from_composition(
self.runtime.configuration_mut(),
principal,
command,
|response| {
responses.push(response);
Ok::<(), core::convert::Infallible>(())
},
)
.unwrap();
responses
}
/// Returns the central manager for SITL state inspection only.
pub const fn manager(&self) -> &ConfigManager<CandidateProfile, FileConfigStore> {
self.runtime.configuration()
}
/// Returns the immutable profile selected by the central manager at boot.
pub const fn active_profile(&self) -> VehicleProfile {
*self.runtime.configuration().active()
@@ -52,7 +30,8 @@ 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_log_payload(&mut self) -> Option<KAPTAN_LOG_DATA> {
#[cfg(test)]
pub(super) fn pending_log_payload(&mut self) -> Option<LOG_DATA> {
let runtime = &mut self.runtime;
let _ = logging::drain_one(|record| runtime.publish_log_event(*record));
self.runtime
@@ -60,14 +39,9 @@ impl SitlConfigRuntime {
}
}
/// Builds the host-only central configuration composition used by forthcoming
/// SITL/GCS integration. Flight targets must supply authenticated authority
/// and a reviewed journal/activation coordinator before constructing one.
/// Builds the host-only central configuration composition.
pub fn configuration_service(store: FileConfigStore) -> Result<SitlConfigRuntime, String> {
let runtime = FlightRuntime::recover(CandidateProfile::factory(), CAPABILITIES, store)
.map_err(|error| format!("configuration recovery failed: {error:?}"))?;
Ok(SitlConfigRuntime {
runtime,
protocol: ConfigProtocol::new(Target::new(1, 1)),
})
Ok(SitlConfigRuntime { runtime })
}
+2 -2
View File
@@ -13,7 +13,7 @@ use kaptan_flight_config::{
decode_candidate, encode_candidate,
};
use super::{CAPABILITIES, configuration_service};
use super::{configuration_service, runtime::CAPABILITIES};
/// Host file-store error. It intentionally makes no flash power-loss claim.
#[derive(Debug, KaptanError)]
@@ -55,7 +55,7 @@ impl FileConfigStore {
impl ConfigStore<CandidateProfile> for FileConfigStore {
type Error = FileStoreError;
fn load(&mut self) -> Result<Option<CandidateProfile>, Self::Error> {
fn load(&mut self, _: &CandidateProfile) -> Result<Option<CandidateProfile>, Self::Error> {
let Some(path) = self.path.as_deref() else {
return Ok(None);
};
+24 -17
View File
@@ -3,11 +3,21 @@ use std::fs;
use kaptan_config::{ConfigModel, ConfigStore};
use kaptan_flight_config::{CandidateProfile, decode_candidate};
use kaptan_mavlink::{
MavlinkFrame,
encoder::MavlinkEncoder,
message::{Message, kaptan::MavMessage},
generated::kaptan::{LogLevel, MavMessage},
receiver::IntegrityMavlinkReceiver,
};
use super::{CAPABILITIES, FileConfigStore, configuration_service};
use super::{FileConfigStore, configuration_service, runtime::CAPABILITIES};
fn parse_test_message(frame: &MavlinkFrame) -> Option<MavMessage> {
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received()?.decode()
}
#[test]
fn host_store_round_trips_a_complete_validated_snapshot() {
@@ -20,20 +30,21 @@ fn host_store_round_trips_a_complete_validated_snapshot() {
let _ = fs::remove_file(&path);
let _ = fs::remove_file(&temporary);
let factory = CandidateProfile::factory();
let mut store = FileConfigStore::new(&path);
assert!(store.load().unwrap().is_none());
store.persist(&CandidateProfile::factory()).unwrap();
assert!(store.load(&factory).unwrap().is_none());
store.persist(&factory).unwrap();
let snapshot = fs::read(&path).unwrap();
let decoded = decode_candidate(&snapshot, &CAPABILITIES).unwrap();
assert!(decoded.validate(&CAPABILITIES).is_ok());
let candidate = store.load().unwrap().expect("persisted candidate");
let candidate = store.load(&factory).unwrap().expect("persisted candidate");
assert!(candidate.validate(&CAPABILITIES).is_ok());
store.persist(&CandidateProfile::factory()).unwrap();
store.persist(&factory).unwrap();
let mut reopened = FileConfigStore::new(&path);
assert!(reopened.load().unwrap().is_some());
assert!(reopened.load(&factory).unwrap().is_some());
let mut fresh = FileConfigStore::new(&path);
fresh.persist(&CandidateProfile::factory()).unwrap();
fresh.persist(&factory).unwrap();
fs::remove_file(&path).unwrap();
}
@@ -51,21 +62,17 @@ fn standard_log_macro_reaches_the_generated_mavlink_log_payload() {
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);
.expect("info facade record must reach LOG");
assert_eq!(payload.level, LogLevel::LOG_LEVEL_INFO);
let message = std::str::from_utf8(&payload.message[..usize::from(payload.message_length)])
.expect("facade text remains UTF-8");
assert_eq!(message, "SITL configuration service ready");
let mut encoder = MavlinkEncoder::new(1, 1);
let frame = encoder.custom(&payload);
let frame = encoder.encode(&payload);
assert_eq!(frame.message_id(), 51_020);
assert!(matches!(
MavMessage::parse(
mavlink::MavlinkVersion::V2,
frame.message_id(),
frame.payload(),
),
Ok(MavMessage::KAPTAN_LOG(data))
parse_test_message(&frame),
Some(MavMessage::LOG(data))
if data.level == payload.level
&& data.message_length == payload.message_length
&& data.message == payload.message