diff --git a/.github/workflows/sitl-smoke.yml b/.github/workflows/sitl-smoke.yml index d647f4e..5644413 100644 --- a/.github/workflows/sitl-smoke.yml +++ b/.github/workflows/sitl-smoke.yml @@ -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 diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock index 26cbf19..7f357d8 100644 --- a/firmware/Cargo.lock +++ b/firmware/Cargo.lock @@ -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", diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index f567383..3211d39 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -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", diff --git a/firmware/boards/geprc-f722-v2/src/board.rs b/firmware/boards/geprc-f722-v2/src/board.rs index bebfdf2..79d5d3a 100644 --- a/firmware/boards/geprc-f722-v2/src/board.rs +++ b/firmware/boards/geprc-f722-v2/src/board.rs @@ -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, }, }; diff --git a/firmware/boards/kakute-f7-mini-v3/src/board.rs b/firmware/boards/kakute-f7-mini-v3/src/board.rs index 709074b..27f1b2f 100644 --- a/firmware/boards/kakute-f7-mini-v3/src/board.rs +++ b/firmware/boards/kakute-f7-mini-v3/src/board.rs @@ -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, diff --git a/firmware/libs/kaptan-config-derive/src/expand.rs b/firmware/libs/kaptan-config-derive/src/expand.rs index af061c7..f2a3952 100644 --- a/firmware/libs/kaptan-config-derive/src/expand.rs +++ b/firmware/libs/kaptan-config-derive/src/expand.rs @@ -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 }, diff --git a/firmware/libs/kaptan-config-derive/tests/section.rs b/firmware/libs/kaptan-config-derive/tests/section.rs index 7017775..857d6f8 100644 --- a/firmware/libs/kaptan-config-derive/tests/section.rs +++ b/firmware/libs/kaptan-config-derive/tests/section.rs @@ -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(); diff --git a/firmware/libs/kaptan-config-mavlink/src/command.rs b/firmware/libs/kaptan-config-mavlink/src/command.rs deleted file mode 100644 index 9d69d2e..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/command.rs +++ /dev/null @@ -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 { - 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 { - 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 { - principal: Principal, - command: T, -} - -#[cfg(test)] -impl Authorized { - /// 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) - } -} diff --git a/firmware/libs/kaptan-config-mavlink/src/lib.rs b/firmware/libs/kaptan-config-mavlink/src/lib.rs deleted file mode 100644 index 6da8617..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/lib.rs +++ /dev/null @@ -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; diff --git a/firmware/libs/kaptan-config-mavlink/src/protocol.rs b/firmware/libs/kaptan-config-mavlink/src/protocol.rs deleted file mode 100644 index e49472c..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/protocol.rs +++ /dev/null @@ -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, - /// 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, - } - } -} diff --git a/firmware/libs/kaptan-config-mavlink/src/service.rs b/firmware/libs/kaptan-config-mavlink/src/service.rs deleted file mode 100644 index fe7b372..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/service.rs +++ /dev/null @@ -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; diff --git a/firmware/libs/kaptan-config-mavlink/src/service/query.rs b/firmware/libs/kaptan-config-mavlink/src/service/query.rs deleted file mode 100644 index 20081fe..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/service/query.rs +++ /dev/null @@ -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( - &self, - manager: &ConfigManager, - index: u16, - ) -> Option<&'static FieldDescriptor> - where - C: ConfigModel, - S: ConfigStore, - { - manager.descriptor(index) - } - - /// Reads one typed value without opening or changing a transaction. - pub fn value( - &self, - manager: &ConfigManager, - source: ValueSource, - field: FieldId, - ) -> Option - where - C: ConfigModel, - S: ConfigStore, - { - 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( - &self, - manager: &ConfigManager, - request_id: u32, - index: u16, - ) -> Option - where - C: ConfigModel, - S: ConfigStore, - { - 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( - &self, - manager: &ConfigManager, - request_id: u32, - descriptor_index: u16, - option_index: u8, - ) -> Option - where - C: ConfigModel, - S: ConfigStore, - { - 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(&self, manager: &ConfigManager, request_id: u32) -> ConfigInfo - where - C: ConfigModel, - S: ConfigStore, - { - ConfigInfo { - request_id, - schema_id: C::SCHEMA_ID, - field_count: manager.field_count(), - state: self.state, - } - } - - pub(super) fn value_response( - &self, - manager: &ConfigManager, - owner: Principal, - request_id: u32, - session_id: u32, - field: FieldId, - source: ValueSource, - ) -> ServiceResponse - where - C: ConfigModel, - S: ConfigStore, - { - 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, - }) - } -} diff --git a/firmware/libs/kaptan-config-mavlink/src/service/replay.rs b/firmware/libs/kaptan-config-mavlink/src/service/replay.rs deleted file mode 100644 index 7ee15f5..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/service/replay.rs +++ /dev/null @@ -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 { - 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() -} diff --git a/firmware/libs/kaptan-config-mavlink/src/service/session.rs b/firmware/libs/kaptan-config-mavlink/src/service/session.rs deleted file mode 100644 index fec1ae2..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/service/session.rs +++ /dev/null @@ -1,1325 +0,0 @@ -//! Lease-bound command service over the transport-neutral configuration core. - -use core::num::NonZeroU32; - -use kaptan_config::{ - BeginError, CommitError, ConfigManager, ConfigModel, ConfigState, ConfigStore, FieldWriteError, - SetError, -}; - -#[cfg(test)] -use super::query::{ConfigInfo, DescriptorResponse, EnumOptionResponse, ValueResponse}; -use super::{query::ServiceResponse, replay::RememberedResult}; -#[cfg(test)] -use crate::command::Authorized; -use crate::{ - command::{ConfigCommand, Principal, Target}, - protocol::{ConfigReply, Operation, ProtocolError, ResultKind}, -}; - -#[derive(Clone, Copy)] -pub(super) struct Session { - pub(super) id: u32, - pub(super) revision: u32, -} - -#[derive(Debug, kaptan_err_reportable::KaptanError)] -enum CommitTransactionError { - #[kaptan_error(id = 2201)] - Store(E), -} - -/// Owns only the one GCS transaction lease and bounded protocol state. -/// -/// This protocol never accepts raw frames or interprets transport authority -/// evidence. The composition owner admits a command, binds its principal, and -/// may expire the protocol on lease or link loss. The same owner retains the -/// one [`ConfigManager`] and one runtime log sink queue. -pub struct ConfigProtocol { - pub(super) session: Option, - /// Sole configuration principal admitted until explicit lease expiry. - pub(super) principal: Option, - pub(super) next_session_id: Option, - pub(super) target: Target, - /// Sole most-recent result retained for the admitted stop-and-wait client. - pub(super) remembered: Option, - pub(super) state: ConfigState, -} - -impl ConfigProtocol { - /// Creates a custom configuration-protocol state machine. - pub const fn new(target: Target) -> Self { - Self { - session: None, - principal: None, - next_session_id: Some(NonZeroU32::MIN), - target, - remembered: None, - state: ConfigState::Active, - } - } - - /// Dispatches one composition-admitted configuration command. The - /// `principal` isolates transaction ownership; the first accepted `Begin` - /// binds this protocol to that exact source until [`Self::expire`]. This - /// adapter neither authenticates the source nor decides whether the - /// transport may configure the board. The authoritative - /// `KAPTAN_CONFIG_RESULT` response remains the complete configuration - /// audit; generic human logging is intentionally independent. - /// - /// An emitter error is a delivery failure, not an operation rollback. A - /// mutating command may already be durable and its sole most-recent result - /// is remembered before `emit` returns an error. Binding one stop-and-wait - /// client prevents another source from evicting that result; expiry clears - /// both the binding and replay state. Descriptor and enum-option queries - /// each emit exactly one response and are safe to retry by index. - pub fn dispatch_from_composition( - &mut self, - manager: &mut ConfigManager, - principal: Principal, - command: ConfigCommand, - mut emit: F, - ) -> Result<(), E> - where - C: ConfigModel, - S: ConfigStore, - F: FnMut(ServiceResponse) -> Result<(), E>, - { - self.refresh(manager); - self.dispatch_inner(manager, principal, command, &mut emit) - } - - /// Dispatches a host/SITL test command through the same protocol path. - #[cfg(test)] - pub fn dispatch( - &mut self, - manager: &mut ConfigManager, - authorized: Authorized, - emit: F, - ) -> Result<(), E> - where - C: ConfigModel, - S: ConfigStore, - F: FnMut(ServiceResponse) -> Result<(), E>, - { - let (principal, command) = authorized.into_parts(); - self.dispatch_from_composition(manager, principal, command, emit) - } - - /// Expires the admitted principal and drops its RAM transaction, if any. - pub fn expire(&mut self, manager: &mut ConfigManager) -> bool - where - C: ConfigModel, - S: ConfigStore, - { - let had_session = self.session.take().is_some(); - if had_session { - manager.abort(); - } - let expired = self.principal.take().is_some(); - self.remembered = None; - expired - } - - fn dispatch_inner( - &mut self, - manager: &mut ConfigManager, - owner: Principal, - command: ConfigCommand, - emit: &mut F, - ) -> Result<(), E> - where - C: ConfigModel, - S: ConfigStore, - F: FnMut(ServiceResponse) -> Result<(), E>, - { - if command.target() != self.target { - return emit(ServiceResponse::Result( - self.rejected(command, ProtocolError::BadTarget), - )); - } - if self.principal.is_some_and(|principal| principal != owner) { - return emit(ServiceResponse::Result( - self.rejected(command, ProtocolError::SessionBusy), - )); - } - if let Some(cached) = self.cached(owner, command) { - return emit(ServiceResponse::Result(cached)); - } - let response = match command { - ConfigCommand::InfoRequest { request_id, .. } => { - ServiceResponse::Info(self.info(manager, request_id)) - } - ConfigCommand::DescriptorRequest { - request_id, index, .. - } => self - .descriptor_response(manager, request_id, index) - .map_or_else( - || ServiceResponse::Result(self.rejected(command, ProtocolError::InvalidQuery)), - ServiceResponse::Descriptor, - ), - ConfigCommand::EnumOptionRequest { - request_id, - descriptor_index, - option_index, - .. - } => self - .enum_option_response(manager, request_id, descriptor_index, option_index) - .map_or_else( - || ServiceResponse::Result(self.rejected(command, ProtocolError::InvalidQuery)), - ServiceResponse::EnumOption, - ), - ConfigCommand::ValueRequest { - request_id, - session_id, - field, - source, - .. - } => self.value_response(manager, owner, request_id, session_id, field, source), - ConfigCommand::Begin { .. } => { - ServiceResponse::Result(self.begin(manager, owner, command)) - } - ConfigCommand::Set { .. } - | ConfigCommand::Validate { .. } - | ConfigCommand::Commit { .. } - | ConfigCommand::Abort { .. } => { - ServiceResponse::Result(self.in_session(manager, owner, command)) - } - }; - self.refresh(manager); - let response = match response { - ServiceResponse::Result(reply) => { - self.remember(owner, command, reply); - ServiceResponse::Result(reply) - } - response => response, - }; - emit(response) - } - - fn begin( - &mut self, - manager: &mut ConfigManager, - owner: Principal, - command: ConfigCommand, - ) -> ConfigReply - where - C: ConfigModel, - S: ConfigStore, - { - let ConfigCommand::Begin { request_id, .. } = command else { - return self.rejected(command, ProtocolError::Internal); - }; - if let Some(session) = self.session { - return self.reply( - request_id, - session.id, - session.revision, - Operation::Begin, - ResultKind::Rejected, - Some(ProtocolError::SessionBusy), - ); - } - let Some(id) = self.allocate_session_id() else { - return self.rejected(command, ProtocolError::Internal); - }; - match manager.begin() { - Ok(()) => { - self.principal = Some(owner); - self.session = Some(Session { id, revision: 0 }); - self.reply( - request_id, - id, - 0, - Operation::Begin, - ResultKind::Accepted, - None, - ) - } - Err(BeginError::TransactionActive) => { - self.rejected(command, ProtocolError::SessionBusy) - } - } - } - - fn in_session( - &mut self, - manager: &mut ConfigManager, - owner: Principal, - command: ConfigCommand, - ) -> ConfigReply - where - C: ConfigModel, - S: ConfigStore, - { - let Some(session) = self.session else { - return self.rejected(command, ProtocolError::InvalidSession); - }; - let session_id = command_session_id(command); - if session.id != session_id || self.principal != Some(owner) { - return self.rejected(command, ProtocolError::InvalidSession); - } - if let Some(expected_revision) = command_expected_revision(command) - && expected_revision != session.revision - { - return self.rejected(command, ProtocolError::StaleRevision); - } - match command { - ConfigCommand::Set { - request_id, - field, - value, - .. - } => { - let Some(next_revision) = session.revision.checked_add(1) else { - return self.rejected(command, ProtocolError::Internal); - }; - match manager.set(field, value) { - Ok(_) => { - let Some(session) = self.session.as_mut() else { - return self.rejected(command, ProtocolError::Internal); - }; - session.revision = next_revision; - let (session_id, revision) = (session.id, session.revision); - self.reply( - request_id, - session_id, - revision, - Operation::Set, - ResultKind::Accepted, - None, - ) - } - Err(SetError::NoTransaction) => self.rejected(command, ProtocolError::BadState), - Err(SetError::Field(error)) => self.rejected(command, field_error(error)), - } - } - ConfigCommand::Validate { request_id, .. } => match manager.validate() { - Ok(()) => self.reply( - request_id, - session.id, - session.revision, - Operation::Validate, - ResultKind::Accepted, - None, - ), - Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState), - Err(CommitError::InvalidCandidate(violation)) => { - self.rejected(command, ProtocolError::Violation(violation)) - } - Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal), - }, - ConfigCommand::Commit { request_id, .. } => self - .commit_transaction(manager, command, request_id, session) - .unwrap_or_else(|CommitTransactionError::Store(_)| { - self.rejected(command, ProtocolError::StorageFailure) - }), - ConfigCommand::Abort { request_id, .. } => { - manager.abort(); - self.session = None; - self.refresh(manager); - self.reply( - request_id, - 0, - session.revision, - Operation::Abort, - ResultKind::Accepted, - None, - ) - } - ConfigCommand::InfoRequest { .. } - | ConfigCommand::DescriptorRequest { .. } - | ConfigCommand::EnumOptionRequest { .. } - | ConfigCommand::ValueRequest { .. } - | ConfigCommand::Begin { .. } => self.rejected(command, ProtocolError::Internal), - } - } - - /// Classifies expected configuration rejection separately from a terminal - /// persistence failure. Only the latter leaves this boundary and becomes - /// a reportable firmware error; the configuration result remains authoritative - /// for the requesting user. - #[kaptan_err_reportable::propagate] - fn commit_transaction( - &mut self, - manager: &mut ConfigManager, - command: ConfigCommand, - request_id: u32, - session: Session, - ) -> Result> - where - C: ConfigModel, - S: ConfigStore, - { - match manager.commit() { - Ok(()) => { - self.session = None; - self.refresh(manager); - Ok(self.reply( - request_id, - 0, - session.revision, - Operation::Commit, - ResultKind::CommittedRestartRequired, - None, - )) - } - Err(CommitError::NoTransaction) => Ok(self.rejected(command, ProtocolError::BadState)), - Err(CommitError::InvalidCandidate(violation)) => { - Ok(self.rejected(command, ProtocolError::Violation(violation))) - } - Err(CommitError::Store(error)) => Err(CommitTransactionError::Store(error)), - } - } - - fn allocate_session_id(&mut self) -> Option { - let id = self.next_session_id?; - self.next_session_id = id.get().checked_add(1).and_then(NonZeroU32::new); - Some(id.get()) - } - - fn refresh(&mut self, manager: &ConfigManager) - where - C: ConfigModel, - S: ConfigStore, - { - self.state = manager.state(); - } - - pub(super) fn reply( - &self, - request_id: u32, - session_id: u32, - revision: u32, - operation: Operation, - result: ResultKind, - error: Option, - ) -> ConfigReply { - ConfigReply { - request_id, - session_id, - revision, - operation, - result, - error, - state: self.state, - } - } - - pub(super) fn rejected(&self, command: ConfigCommand, error: ProtocolError) -> ConfigReply { - self.reply( - command.request_id(), - command_session_id(command), - self.session.map_or(0, |session| session.revision), - operation(command), - ResultKind::Rejected, - Some(error), - ) - } - - pub(super) fn rejected_for( - &self, - request_id: u32, - session_id: u32, - error: ProtocolError, - ) -> ConfigReply { - self.reply( - request_id, - session_id, - self.session.map_or(0, |session| session.revision), - Operation::Query, - ResultKind::Rejected, - Some(error), - ) - } -} - -fn command_session_id(command: ConfigCommand) -> u32 { - match command { - ConfigCommand::InfoRequest { .. } - | ConfigCommand::DescriptorRequest { .. } - | ConfigCommand::EnumOptionRequest { .. } - | ConfigCommand::Begin { .. } => 0, - ConfigCommand::ValueRequest { session_id, .. } => session_id, - ConfigCommand::Set { session_id, .. } - | ConfigCommand::Validate { session_id, .. } - | ConfigCommand::Commit { session_id, .. } - | ConfigCommand::Abort { session_id, .. } => session_id, - } -} - -fn command_expected_revision(command: ConfigCommand) -> Option { - match command { - ConfigCommand::Set { - expected_revision, .. - } - | ConfigCommand::Validate { - expected_revision, .. - } - | ConfigCommand::Commit { - expected_revision, .. - } => Some(expected_revision), - ConfigCommand::InfoRequest { .. } - | ConfigCommand::DescriptorRequest { .. } - | ConfigCommand::EnumOptionRequest { .. } - | ConfigCommand::ValueRequest { .. } - | ConfigCommand::Begin { .. } - | ConfigCommand::Abort { .. } => None, - } -} - -const fn operation(command: ConfigCommand) -> Operation { - match command { - ConfigCommand::InfoRequest { .. } - | ConfigCommand::DescriptorRequest { .. } - | ConfigCommand::EnumOptionRequest { .. } - | ConfigCommand::ValueRequest { .. } => Operation::Query, - ConfigCommand::Begin { .. } => Operation::Begin, - ConfigCommand::Set { .. } => Operation::Set, - ConfigCommand::Validate { .. } => Operation::Validate, - ConfigCommand::Commit { .. } => Operation::Commit, - ConfigCommand::Abort { .. } => Operation::Abort, - } -} - -fn field_error(error: FieldWriteError) -> ProtocolError { - match error { - FieldWriteError::UnknownField(field) => ProtocolError::UnknownField(field), - FieldWriteError::WrongType { field, .. } => ProtocolError::TypeMismatch(field), - FieldWriteError::Violation(violation) => ProtocolError::Violation(violation), - } -} - -#[cfg(test)] -mod tests { - use std::{cell::Cell, rc::Rc}; - - use kaptan_config::{ - ConfigManager, ConfigModel, ConfigStore, ConfigValue, ConfigViolation, EnumOption, - FieldDescriptor, FieldId, FieldWriteError, ValidationRule, - }; - - use super::*; - use crate::{Authorized, ConfigCommand, ProtocolError, ResultKind, Target, ValueSource}; - - const VALUE: FieldId = FieldId::new(1); - const ENUM_OPTIONS: [EnumOption; 3] = [(0, "Zero"), (1, "One"), (2, "Two")]; - const DESCRIPTOR: FieldDescriptor = FieldDescriptor { - id: VALUE, - display_key: "test.value", - kind: kaptan_config::ValueKind::U32, - minimum: Some(ConfigValue::U32(0)), - maximum: Some(ConfigValue::U32(10)), - enum_options: &ENUM_OPTIONS, - unit: "count", - }; - - #[derive(Clone)] - struct Candidate(u32); - - impl ConfigModel for Candidate { - type Active = u32; - type Context = (); - const SCHEMA_ID: [u8; 16] = *b"CONFIGSERVICE-01"; - - fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { - (index == 0).then_some(&DESCRIPTOR) - } - fn field_count() -> u16 { - 1 - } - fn field_value(&self, id: FieldId) -> Option { - (id == VALUE).then_some(ConfigValue::U32(self.0)) - } - fn active_field_value(active: &Self::Active, id: FieldId) -> Option { - (id == VALUE).then_some(ConfigValue::U32(*active)) - } - fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { - if id != VALUE { - return Err(FieldWriteError::UnknownField(id)); - } - let ConfigValue::U32(value) = value else { - return Err(FieldWriteError::WrongType { - field: id, - expected: kaptan_config::ValueKind::U32, - actual: value.kind(), - }); - }; - if value > 10 { - return Err(FieldWriteError::Violation(ConfigViolation::field( - id, - ValidationRule::AboveMaximum, - ))); - } - self.0 = value; - Ok(()) - } - fn validate(&self, _: &Self::Context) -> Result { - Ok(self.0) - } - } - - struct Store; - impl ConfigStore for Store { - type Error = (); - fn load(&mut self) -> Result, Self::Error> { - Ok(None) - } - fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { - Ok(()) - } - } - - const fn multi_descriptor(id: u16) -> FieldDescriptor { - FieldDescriptor { - id: FieldId::new(id), - display_key: "test.multi", - kind: kaptan_config::ValueKind::U32, - minimum: None, - maximum: None, - enum_options: &[], - unit: "count", - } - } - - const MULTI_DESCRIPTORS: [FieldDescriptor; 6] = [ - multi_descriptor(1), - multi_descriptor(2), - multi_descriptor(3), - multi_descriptor(4), - multi_descriptor(5), - multi_descriptor(6), - ]; - - #[derive(Clone)] - struct MultiCandidate([u32; 6]); - - impl ConfigModel for MultiCandidate { - type Active = [u32; 6]; - type Context = (); - const SCHEMA_ID: [u8; 16] = *b"CONFIGSERVICE-02"; - - fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { - MULTI_DESCRIPTORS.get(usize::from(index)) - } - - fn field_count() -> u16 { - MULTI_DESCRIPTORS.len() as u16 - } - - fn field_value(&self, id: FieldId) -> Option { - id.get() - .checked_sub(1) - .and_then(|index| self.0.get(usize::from(index))) - .copied() - .map(ConfigValue::U32) - } - - fn active_field_value(active: &Self::Active, id: FieldId) -> Option { - id.get() - .checked_sub(1) - .and_then(|index| active.get(usize::from(index))) - .copied() - .map(ConfigValue::U32) - } - - fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { - let Some(index) = id.get().checked_sub(1).map(usize::from) else { - return Err(FieldWriteError::UnknownField(id)); - }; - let Some(field) = self.0.get_mut(index) else { - return Err(FieldWriteError::UnknownField(id)); - }; - let ConfigValue::U32(value) = value else { - return Err(FieldWriteError::WrongType { - field: id, - expected: kaptan_config::ValueKind::U32, - actual: value.kind(), - }); - }; - *field = value; - Ok(()) - } - - fn validate(&self, _: &Self::Context) -> Result { - Ok(self.0) - } - } - - struct MultiStore; - - impl ConfigStore for MultiStore { - type Error = (); - - fn load(&mut self) -> Result, Self::Error> { - Ok(None) - } - - fn persist(&mut self, _: &MultiCandidate) -> Result<(), Self::Error> { - Ok(()) - } - } - - struct CountingStore(Rc>); - - impl ConfigStore for CountingStore { - type Error = (); - - fn load(&mut self) -> Result, Self::Error> { - Ok(None) - } - - fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { - self.0.set(self.0.get() + 1); - Ok(()) - } - } - - struct FailingStore(Rc>); - - impl ConfigStore for FailingStore { - type Error = (); - - fn load(&mut self) -> Result, Self::Error> { - Ok(None) - } - - fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> { - self.0.set(self.0.get() + 1); - Err(()) - } - } - - /// Test-only composition root. Production code must retain these two - /// objects separately; this wrapper keeps protocol examples compact while - /// exercising the same externally-owned-manager dispatch API. - struct TestService { - manager: ConfigManager, - protocol: ConfigProtocol, - } - - impl TestService { - fn new(manager: ConfigManager, target: Target) -> Self { - Self { - manager, - protocol: ConfigProtocol::new(target), - } - } - - fn dispatch_all( - &mut self, - command: Authorized, - ) -> std::vec::Vec { - let mut responses = std::vec::Vec::new(); - self.protocol - .dispatch(&mut self.manager, command, |response| { - responses.push(response); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - responses - } - - fn dispatch(&mut self, command: Authorized) -> ServiceResponse { - let mut responses = self.dispatch_all(command); - assert_eq!(responses.len(), 1, "test expected one service response"); - responses.pop().unwrap() - } - } - - fn result(response: ServiceResponse) -> ConfigReply { - match response { - ServiceResponse::Result(reply) => reply, - ServiceResponse::Info(_) - | ServiceResponse::Descriptor(_) - | ServiceResponse::EnumOption(_) - | ServiceResponse::Value(_) => { - panic!("test expected a result response") - } - } - } - - #[test] - fn retries_are_idempotent_and_wrong_owner_cannot_mutate() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let begin = ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }; - let started = result(service.dispatch(Authorized::host(7, begin))); - assert_eq!(started.result, ResultKind::Accepted); - let set = ConfigCommand::Set { - request_id: 2, - session_id: started.session_id, - expected_revision: 0, - field: VALUE, - value: ConfigValue::U32(3), - target: Target::new(1, 1), - }; - let accepted = result(service.dispatch(Authorized::host(7, set))); - assert_eq!(accepted.result, ResultKind::Accepted); - assert_eq!(result(service.dispatch(Authorized::host(7, set))), accepted); - let conflict = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Set { - request_id: 2, - session_id: started.session_id, - expected_revision: 0, - field: VALUE, - value: ConfigValue::U32(4), - target: Target::new(1, 1), - }, - ))); - assert_eq!(conflict.error, Some(ProtocolError::RequestConflict)); - assert_eq!( - service.manager.transaction_value(VALUE), - Some(ConfigValue::U32(3)) - ); - let foreign = result(service.dispatch(Authorized::host( - 8, - ConfigCommand::Set { - request_id: 3, - session_id: started.session_id, - expected_revision: 1, - field: VALUE, - value: ConfigValue::U32(3), - target: Target::new(1, 1), - }, - ))); - assert_eq!(foreign.error, Some(ProtocolError::SessionBusy)); - } - - #[test] - fn only_the_most_recent_stop_and_wait_result_is_replayed() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let begin = ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }; - let started = result(service.dispatch(Authorized::host(7, begin))); - let set = ConfigCommand::Set { - request_id: 2, - session_id: started.session_id, - expected_revision: 0, - field: VALUE, - value: ConfigValue::U32(3), - target: Target::new(1, 1), - }; - assert_eq!( - result(service.dispatch(Authorized::host(7, set))).result, - ResultKind::Accepted - ); - - // Advancing a stop-and-wait client replaces the preceding result. - // Reusing that older command is handled from current state instead of - // retaining an unbounded request history. - assert_eq!( - result(service.dispatch(Authorized::host(7, begin))).error, - Some(ProtocolError::SessionBusy) - ); - } - - #[test] - fn busy_owner_can_identify_and_abort_its_abandoned_session() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let started = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }, - ))); - let owned_busy = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 2, - target: Target::new(1, 1), - }, - ))); - assert_eq!(owned_busy.error, Some(ProtocolError::SessionBusy)); - assert_eq!(owned_busy.session_id, started.session_id); - - let foreign_busy = result(service.dispatch(Authorized::host( - 8, - ConfigCommand::Begin { - request_id: 3, - target: Target::new(1, 1), - }, - ))); - assert_eq!(foreign_busy.error, Some(ProtocolError::SessionBusy)); - assert_eq!(foreign_busy.session_id, 0); - - let aborted = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Abort { - request_id: 4, - session_id: owned_busy.session_id, - target: Target::new(1, 1), - }, - ))); - assert_eq!(aborted.result, ResultKind::Accepted); - } - - #[test] - fn exact_validation_issue_reaches_result() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let begin = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }, - ))); - let rejected = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Set { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - field: VALUE, - value: ConfigValue::U32(11), - target: Target::new(1, 1), - }, - ))); - assert!( - matches!(rejected.error, Some(ProtocolError::Violation(issue)) if issue.rule() == ValidationRule::AboveMaximum) - ); - } - - #[test] - fn discovery_and_value_reads_are_service_responses() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let descriptors = service.dispatch(Authorized::host( - 7, - ConfigCommand::DescriptorRequest { - request_id: 1, - index: 0, - target: Target::new(1, 1), - }, - )); - assert!(matches!( - descriptors, - ServiceResponse::Descriptor(DescriptorResponse { - index: 0, - descriptor: DESCRIPTOR, - .. - }) - )); - let value = service.dispatch(Authorized::host( - 7, - ConfigCommand::ValueRequest { - request_id: 2, - session_id: 0, - field: VALUE, - source: ValueSource::Active, - target: Target::new(1, 1), - }, - )); - assert!(matches!( - value, - ServiceResponse::Value(ValueResponse { - value: ConfigValue::U32(2), - .. - }) - )); - - let info = service.dispatch(Authorized::host( - 7, - ConfigCommand::InfoRequest { - request_id: 3, - target: Target::new(1, 1), - }, - )); - assert!(matches!( - info, - ServiceResponse::Info(ConfigInfo { - request_id: 3, - field_count: 1, - .. - }) - )); - } - - #[test] - fn descriptors_advertise_the_composition_factory_not_a_schema_fallback() { - let manager = ConfigManager::new(Candidate(7), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let response = service.dispatch(Authorized::host( - 7, - ConfigCommand::DescriptorRequest { - request_id: 1, - index: 0, - target: Target::new(1, 1), - }, - )); - let ServiceResponse::Descriptor(response) = response else { - panic!("expected descriptor response"); - }; - assert_eq!(response.default, ConfigValue::U32(7)); - } - - #[test] - fn enum_options_are_explicitly_indexed_and_retry_the_rejected_request() { - let mut manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut protocol = ConfigProtocol::new(Target::new(1, 1)); - let command = ConfigCommand::EnumOptionRequest { - request_id: 10, - descriptor_index: 0, - option_index: 0, - target: Target::new(1, 1), - }; - - let mut rejected = None; - assert_eq!( - protocol.dispatch(&mut manager, Authorized::host(7, command), |response| { - rejected = Some(response); - Err(()) - }), - Err(()) - ); - assert!(matches!( - rejected, - Some(ServiceResponse::EnumOption(EnumOptionResponse { - option_index: 0, - option: (0, "Zero"), - .. - })) - )); - - let mut retry = std::vec::Vec::new(); - protocol - .dispatch(&mut manager, Authorized::host(7, command), |response| { - retry.push(response); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!(retry.len(), 1, "one request identifies one option"); - assert!(matches!( - retry[0], - ServiceResponse::EnumOption(EnumOptionResponse { - option_index: 0, - option: (0, "Zero"), - .. - }) - )); - - let next = ConfigCommand::EnumOptionRequest { - request_id: 11, - descriptor_index: 0, - option_index: 1, - target: Target::new(1, 1), - }; - let mut second = None; - protocol - .dispatch(&mut manager, Authorized::host(7, next), |response| { - second = Some(response); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert!(matches!( - second, - Some(ServiceResponse::EnumOption(EnumOptionResponse { - option_index: 1, - option: (1, "One"), - .. - })) - )); - } - - #[test] - fn descriptor_queries_are_bounded_ordered_and_stop_at_the_schema_end() { - let mut manager = ConfigManager::new(MultiCandidate([0; 6]), (), MultiStore).unwrap(); - let mut protocol = ConfigProtocol::new(Target::new(1, 1)); - - let mut indices = std::vec::Vec::new(); - protocol - .dispatch( - &mut manager, - Authorized::host( - 7, - ConfigCommand::DescriptorRequest { - request_id: 1, - index: 1, - target: Target::new(1, 1), - }, - ), - |response| { - let ServiceResponse::Descriptor(response) = response else { - panic!("expected a descriptor response"); - }; - indices.push(response.index); - Ok::<(), core::convert::Infallible>(()) - }, - ) - .unwrap(); - assert_eq!(indices, [1]); - - indices.clear(); - protocol - .dispatch( - &mut manager, - Authorized::host( - 7, - ConfigCommand::DescriptorRequest { - request_id: 2, - index: 4, - target: Target::new(1, 1), - }, - ), - |response| { - let ServiceResponse::Descriptor(response) = response else { - panic!("expected a descriptor response"); - }; - indices.push(response.index); - Ok::<(), core::convert::Infallible>(()) - }, - ) - .unwrap(); - assert_eq!(indices, [4]); - } - - #[test] - fn descriptor_request_retries_the_same_index_after_delivery_fails() { - let mut manager = ConfigManager::new(MultiCandidate([0; 6]), (), MultiStore).unwrap(); - let mut protocol = ConfigProtocol::new(Target::new(1, 1)); - let command = ConfigCommand::DescriptorRequest { - request_id: 1, - index: 0, - target: Target::new(1, 1), - }; - let mut indices = std::vec::Vec::new(); - let failure = protocol.dispatch(&mut manager, Authorized::host(7, command), |response| { - let ServiceResponse::Descriptor(response) = response else { - panic!("expected a descriptor response"); - }; - indices.push(response.index); - Err(()) - }); - assert_eq!(failure, Err(())); - assert_eq!(indices, [0]); - - indices.clear(); - protocol - .dispatch(&mut manager, Authorized::host(7, command), |response| { - let ServiceResponse::Descriptor(response) = response else { - panic!("expected a descriptor response"); - }; - indices.push(response.index); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!(indices, [0]); - - indices.clear(); - protocol - .dispatch( - &mut manager, - Authorized::host( - 7, - ConfigCommand::DescriptorRequest { - request_id: 2, - index: 1, - target: Target::new(1, 1), - }, - ), - |response| { - let ServiceResponse::Descriptor(response) = response else { - panic!("expected a descriptor response"); - }; - indices.push(response.index); - Ok::<(), core::convert::Infallible>(()) - }, - ) - .unwrap(); - assert_eq!(indices, [1]); - } - - #[test] - fn invalid_descriptor_index_is_a_structured_query_error() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let reply = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::DescriptorRequest { - request_id: 1, - index: 1, - target: Target::new(1, 1), - }, - ))); - assert_eq!(reply.error, Some(ProtocolError::InvalidQuery)); - } - - #[test] - fn a_lost_commit_response_does_not_repeat_flash_persistence() { - let persists = Rc::new(Cell::new(0)); - let store = CountingStore(Rc::clone(&persists)); - let mut manager = ConfigManager::new(Candidate(2), (), store).unwrap(); - let mut protocol = ConfigProtocol::new(Target::new(1, 1)); - let mut begin_reply = None; - protocol - .dispatch( - &mut manager, - Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }, - ), - |response| { - begin_reply = Some(result(response)); - Ok::<(), core::convert::Infallible>(()) - }, - ) - .unwrap(); - let begin_reply = begin_reply.unwrap(); - let commit = ConfigCommand::Commit { - request_id: 2, - session_id: begin_reply.session_id, - expected_revision: 0, - target: Target::new(1, 1), - }; - - assert_eq!( - protocol.dispatch(&mut manager, Authorized::host(7, commit), |_| Err(())), - Err(()) - ); - assert_eq!(persists.get(), 1); - - let foreign = ConfigCommand::Begin { - request_id: 3, - target: Target::new(1, 1), - }; - let mut foreign_reply = None; - protocol - .dispatch(&mut manager, Authorized::host(8, foreign), |response| { - foreign_reply = Some(result(response)); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!( - foreign_reply.unwrap().error, - Some(ProtocolError::SessionBusy) - ); - - let mut retry_reply = None; - protocol - .dispatch(&mut manager, Authorized::host(7, commit), |response| { - retry_reply = Some(result(response)); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!(persists.get(), 1); - assert_eq!( - retry_reply.unwrap().result, - ResultKind::CommittedRestartRequired - ); - - // Expiry releases the principal even though COMMIT already closed its - // RAM transaction, allowing the next direct-link configurator in. - assert!(protocol.expire(&mut manager)); - let mut next_owner = None; - protocol - .dispatch(&mut manager, Authorized::host(8, foreign), |response| { - next_owner = Some(result(response)); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!(next_owner.unwrap().result, ResultKind::Accepted); - } - - #[test] - fn failed_commit_reports_the_returned_error_only_once() { - while kaptan_err_reportable::take().is_some() {} - let persists = Rc::new(Cell::new(0)); - let store = FailingStore(Rc::clone(&persists)); - let mut manager = ConfigManager::new(Candidate(2), (), store).unwrap(); - let mut protocol = ConfigProtocol::new(Target::new(1, 1)); - let mut begin_reply = None; - protocol - .dispatch( - &mut manager, - Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }, - ), - |response| { - begin_reply = Some(result(response)); - Ok::<(), core::convert::Infallible>(()) - }, - ) - .unwrap(); - let commit = ConfigCommand::Commit { - request_id: 2, - session_id: begin_reply.unwrap().session_id, - expected_revision: 0, - target: Target::new(1, 1), - }; - - let mut first_reply = None; - protocol - .dispatch(&mut manager, Authorized::host(7, commit), |response| { - first_reply = Some(result(response)); - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!(persists.get(), 1); - assert_eq!( - first_reply.unwrap().error, - Some(ProtocolError::StorageFailure) - ); - let error = - kaptan_err_reportable::take().expect("failed commit must report its returned error"); - assert_eq!(error.id(), 2201); - while kaptan_err_reportable::take().is_some() {} - - protocol - .dispatch(&mut manager, Authorized::host(7, commit), |_| { - Ok::<(), core::convert::Infallible>(()) - }) - .unwrap(); - assert_eq!(persists.get(), 1); - assert!( - kaptan_err_reportable::take().is_none(), - "a cached command replay must not report the error again" - ); - } - - #[test] - fn configuration_result_remains_the_structured_failure_authority() { - let manager = ConfigManager::new(Candidate(2), (), Store).unwrap(); - let mut service = TestService::new(manager, Target::new(1, 1)); - let begin = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Begin { - request_id: 1, - target: Target::new(1, 1), - }, - ))); - let rejected = result(service.dispatch(Authorized::host( - 7, - ConfigCommand::Set { - request_id: 2, - session_id: begin.session_id, - expected_revision: 0, - field: FieldId::new(2), - value: ConfigValue::U32(3), - target: Target::new(1, 1), - }, - ))); - assert_eq!( - rejected.error, - Some(ProtocolError::UnknownField(FieldId::new(2))) - ); - assert_eq!(rejected.request_id, 2); - assert_eq!(rejected.primary_field(), FieldId::new(2).get()); - } -} diff --git a/firmware/libs/kaptan-config-mavlink/src/wire.rs b/firmware/libs/kaptan-config-mavlink/src/wire.rs deleted file mode 100644 index b0a097f..0000000 --- a/firmware/libs/kaptan-config-mavlink/src/wire.rs +++ /dev/null @@ -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(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 { - 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 { - match FieldId::try_new(value) { - Some(value) => Ok(value), - None => Err(DecodeError::InvalidFieldId), - } -} - -const fn value_source(value: KaptanConfigValueSource) -> Result { - 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 { - 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) -> 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, - } - - 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)), - ] - ); - } -} diff --git a/firmware/libs/kaptan-config/src/artifact.rs b/firmware/libs/kaptan-config/src/artifact.rs new file mode 100644 index 0000000..34f4cb3 --- /dev/null +++ b/firmware/libs/kaptan-config/src/artifact.rs @@ -0,0 +1,1616 @@ +//! Canonical self-describing KCFG import and export artifact. +//! +//! The artifact is independent of any transport and of the private flash +//! journal. It contains the generated field metadata, one complete committed +//! value set, and the board-selected factory values required by a generic UI. + +use core::str; + +use crate::{ + ConfigModel, ConfigValue, ConfigViolation, FieldId, FieldWriteError, SnapshotModelError, + ValidationRule, ValueKind, + checksum::{Crc32c, crc32c}, + snapshot::validate_model, +}; + +const MAGIC: [u8; 4] = *b"KCFG"; +const MAGIC_OFFSET: usize = 0; +const VERSION_OFFSET: usize = 4; +const ARTIFACT_FLAGS_OFFSET: usize = 5; +const SCHEMA_OFFSET: usize = 6; +const FIELD_COUNT_OFFSET: usize = 22; +const FIELDS_OFFSET: usize = 24; +const CHECKSUM_LENGTH: usize = 4; +const FIELD_PREFIX_LENGTH: usize = 7; +const FIELD_HAS_MINIMUM: u8 = 1 << 0; +const FIELD_HAS_MAXIMUM: u8 = 1 << 1; +const FIELD_FLAGS: u8 = FIELD_HAS_MINIMUM | FIELD_HAS_MAXIMUM; + +/// Current canonical KCFG artifact format. +const KCFG_VERSION: u8 = 1; +/// KCFG header bytes preceding the first field record. +const KCFG_HEADER_LENGTH: usize = FIELDS_OFFSET; +/// KCFG trailer bytes containing a big-endian CRC-32C. +/// +/// Network byte order is deliberate: the generic file layer independently +/// computes CRC-32C across the complete artifact. Appending the reflected CRC +/// in little-endian order would make that outer checksum the same fixed residue +/// for every valid KCFG file instead of a content fingerprint. +const KCFG_CHECKSUM_LENGTH: usize = CHECKSUM_LENGTH; +/// Header flag indicating that the exported committed values require restart. +const KCFG_RESTART_REQUIRED: u8 = 1 << 0; +const ARTIFACT_FLAGS: u8 = KCFG_RESTART_REQUIRED; +/// Bytes in the canonical KCFG-specific result prefix carried by a transport. +const KCFG_RESULT_DETAIL_LENGTH: usize = 7; + +/// Typed KCFG outcome metadata independent of its file transport. +/// +/// A generic transport status determines success or failure. This prefix adds +/// KCFG activation state and exact configuration-validation identity without +/// teaching the transport about configuration policy. +#[derive(Clone, Copy)] +pub struct KcfgResultDetail { + restart_required: bool, + primary: Option, + related: Option, + rule: Option, +} + +impl KcfgResultDetail { + /// Describes one successfully persisted KCFG candidate. + pub const fn success(restart_required: bool) -> Self { + Self { + restart_required, + primary: None, + related: None, + rule: None, + } + } + + /// Describes one typed candidate-validation rejection. + pub const fn violation(violation: ConfigViolation) -> Self { + Self { + restart_required: false, + primary: violation.primary(), + related: violation.related_field(), + rule: Some(violation.rule()), + } + } + + /// Describes a field-bearing KCFG rejection without a validation rule. + pub const fn field_failure(field: FieldId) -> Self { + Self { + restart_required: false, + primary: Some(field), + related: None, + rule: None, + } + } + + /// Describes a KCFG rejection with no field-local identity. + pub const fn failure() -> Self { + Self { + restart_required: false, + primary: None, + related: None, + rule: None, + } + } + + /// Encodes the fixed canonical prefix. + pub const fn encode(self) -> [u8; KCFG_RESULT_DETAIL_LENGTH] { + let primary = match self.primary { + Some(field) => field.get(), + None => u16::MAX, + } + .to_le_bytes(); + let related = match self.related { + Some(field) => field.get(), + None => u16::MAX, + } + .to_le_bytes(); + let rule = match self.rule { + Some(rule) => rule.code(), + None => 0, + } + .to_le_bytes(); + [ + self.restart_required as u8, + primary[0], + primary[1], + related[0], + related[1], + rule[0], + rule[1], + ] + } + + /// Decodes a canonical prefix and optional UTF-8 diagnostic suffix. + pub fn decode(bytes: &[u8]) -> Option<(Self, &str)> { + let prefix = bytes.get(..KCFG_RESULT_DETAIL_LENGTH)?; + if prefix[0] & !KCFG_RESTART_REQUIRED != 0 { + return None; + } + let primary = decode_result_field(u16::from_le_bytes([prefix[1], prefix[2]]))?; + let related = decode_result_field(u16::from_le_bytes([prefix[3], prefix[4]]))?; + if primary.is_none() && related.is_some() { + return None; + } + let raw_rule = u16::from_le_bytes([prefix[5], prefix[6]]); + let rule = if raw_rule == 0 { + None + } else { + Some(ValidationRule::try_from_code(raw_rule)?) + }; + let text = str::from_utf8(&bytes[KCFG_RESULT_DETAIL_LENGTH..]).ok()?; + Some(( + Self { + restart_required: prefix[0] & KCFG_RESTART_REQUIRED != 0, + primary, + related, + rule, + }, + text, + )) + } + + /// Whether the accepted committed values differ from boot-active values. + pub const fn restart_required(self) -> bool { + self.restart_required + } + + /// First field associated with a rejection. + pub const fn primary(self) -> Option { + self.primary + } + + /// Related field for a cross-field rejection. + pub const fn related_field(self) -> Option { + self.related + } + + /// Stable validation rule associated with a rejection. + pub const fn rule(self) -> Option { + self.rule + } +} + +fn decode_result_field(raw: u16) -> Option> { + if raw == u16::MAX { + Some(None) + } else { + FieldId::try_new(raw).map(Some) + } +} + +/// Failure while calculating or encoding a canonical KCFG artifact. +#[derive(Debug)] +pub enum KcfgEncodeError { + /// The destination cannot hold the complete artifact. + BufferTooSmall { + /// Exact bytes required. + required: usize, + /// Bytes supplied by the caller. + available: usize, + }, + /// The generated descriptor registry is internally inconsistent. + InvalidModel(SnapshotModelError), + /// Artifact-length arithmetic exceeded the target address domain. + LengthOverflow, + /// A descriptor contains metadata outside the canonical KCFG domain. + InvalidMetadata(FieldId), + /// The supplied configuration has no value for a generated field. + MissingValue(FieldId), + /// A supplied current or factory value differs from its descriptor kind. + WrongValueKind(FieldId), + /// A dynamic edit callback returned a value of another kind. + EditedValueKind(FieldId), +} + +/// Failure while decoding untrusted KCFG bytes. +#[derive(Debug)] +pub enum KcfgDecodeError { + /// Input ends before a required fixed or variable-length component. + Truncated, + /// Bytes remain between the declared field sequence and checksum. + TrailingData, + /// The artifact does not carry KCFG magic. + InvalidMagic, + /// The artifact format is not supported. + UnsupportedVersion(u8), + /// Reserved header flags are set. + InvalidArtifactFlags(u8), + /// The complete artifact checksum differs from its trailer. + ChecksumMismatch, + /// A field used a reserved stable identity. + InvalidFieldId(u16), + /// A field used an unsupported scalar kind. + InvalidValueKind(u8), + /// Reserved field flags are set. + InvalidFieldFlags(FieldId), + /// A scalar does not have a canonical representation for its kind. + InvalidValue(FieldId), + /// A key, unit, or enum label is not canonical UTF-8 metadata. + InvalidText(FieldId), + /// Named enum choices were attached to a non-u8 field. + InvalidEnum(FieldId), + /// A field contains the same enum representation more than once. + DuplicateEnumValue(FieldId), + /// The dynamic artifact contains the same stable field identity twice. + DuplicateField(FieldId), + /// The artifact belongs to another generated schema. + WrongSchema, + /// The artifact does not contain the generated number of fields. + WrongFieldCount, + /// Artifact metadata differs from the generated descriptor at that index. + DescriptorMismatch(FieldId), + /// The artifact's factory value differs from the board-owned factory. + FactoryMismatch(FieldId), + /// The generated descriptor registry is internally inconsistent. + InvalidModel(SnapshotModelError), + /// The model rejected an imported current value. + InvalidWrite(FieldWriteError), +} + +/// One borrowed named enum choice in a decoded KCFG artifact. +#[derive(Clone, Copy)] +pub struct KcfgEnumOption<'a> { + value: u8, + label: &'a str, +} + +impl<'a> KcfgEnumOption<'a> { + /// Stable enum representation. + pub const fn value(self) -> u8 { + self.value + } + + /// Human-readable generated label. + pub const fn label(self) -> &'a str { + self.label + } +} + +/// Iterator over the named choices of one borrowed field record. +#[derive(Clone, Copy)] +pub struct KcfgEnumOptions<'a> { + bytes: &'a [u8], + remaining: u8, +} + +impl<'a> Iterator for KcfgEnumOptions<'a> { + type Item = KcfgEnumOption<'a>; + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let value = self.bytes[0]; + let length = usize::from(self.bytes[1]); + let label = + str::from_utf8(&self.bytes[2..2 + length]).expect("validated KCFG labels remain UTF-8"); + self.bytes = &self.bytes[2 + length..]; + self.remaining -= 1; + Some(KcfgEnumOption { value, label }) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = usize::from(self.remaining); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for KcfgEnumOptions<'_> {} + +/// One self-describing borrowed field record. +#[derive(Clone, Copy)] +pub struct KcfgField<'a> { + id: FieldId, + kind: ValueKind, + current: ConfigValue, + factory: ConfigValue, + minimum: Option, + maximum: Option, + key: &'a str, + unit: &'a str, + options: KcfgEnumOptions<'a>, + current_offset: usize, +} + +impl<'a> KcfgField<'a> { + /// Stable schema-local field identity. + pub const fn id(self) -> FieldId { + self.id + } + + /// Native scalar representation carried by the file. + pub const fn kind(self) -> ValueKind { + self.kind + } + + /// Exported committed value. + pub const fn value(self) -> ConfigValue { + self.current + } + + /// Board-owned erased-flash value. + pub const fn factory_value(self) -> ConfigValue { + self.factory + } + + /// Inclusive generated lower bound. + pub const fn minimum(self) -> Option { + self.minimum + } + + /// Inclusive generated upper bound. + pub const fn maximum(self) -> Option { + self.maximum + } + + /// Generated display key. + pub const fn key(self) -> &'a str { + self.key + } + + /// Generated display unit. + pub const fn unit(self) -> &'a str { + self.unit + } + + /// Generated named enum choices. + pub const fn enum_options(self) -> KcfgEnumOptions<'a> { + self.options + } +} + +/// Iterator over validated field records in canonical artifact order. +pub struct KcfgFields<'a> { + bytes: &'a [u8], + cursor: usize, + limit: usize, + remaining: u16, +} + +impl<'a> Iterator for KcfgFields<'a> { + type Item = KcfgField<'a>; + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let (field, next) = parse_field(self.bytes, self.cursor, self.limit) + .expect("validated KCFG fields remain parseable"); + self.cursor = next; + self.remaining -= 1; + Some(field) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = usize::from(self.remaining); + (remaining, Some(remaining)) + } +} + +/// Borrowed, fully syntax- and integrity-validated KCFG artifact. +pub struct KcfgArtifact<'a> { + bytes: &'a [u8], + schema_id: [u8; 16], + field_count: u16, + restart_required: bool, +} + +impl<'a> KcfgArtifact<'a> { + /// Generated schema identity carried by the artifact. + pub const fn schema_id(&self) -> [u8; 16] { + self.schema_id + } + + /// Number of self-describing fields. + pub const fn field_count(&self) -> u16 { + self.field_count + } + + /// Whether exported committed values differ from boot-active values. + pub const fn restart_required(&self) -> bool { + self.restart_required + } + + /// Iterates fields without allocation or a concrete firmware schema type. + pub fn fields(&self) -> KcfgFields<'a> { + KcfgFields { + bytes: self.bytes, + cursor: FIELDS_OFFSET, + limit: self.bytes.len() - CHECKSUM_LENGTH, + remaining: self.field_count, + } + } +} + +/// Validates and borrows one board-agnostic KCFG artifact. +pub fn decode_kcfg(input: &[u8]) -> Result, KcfgDecodeError> { + if input.len() < KCFG_HEADER_LENGTH + KCFG_CHECKSUM_LENGTH { + return Err(KcfgDecodeError::Truncated); + } + if input[MAGIC_OFFSET..VERSION_OFFSET] != MAGIC { + return Err(KcfgDecodeError::InvalidMagic); + } + if input[VERSION_OFFSET] != KCFG_VERSION { + return Err(KcfgDecodeError::UnsupportedVersion(input[VERSION_OFFSET])); + } + let artifact_flags = input[ARTIFACT_FLAGS_OFFSET]; + if artifact_flags & !ARTIFACT_FLAGS != 0 { + return Err(KcfgDecodeError::InvalidArtifactFlags(artifact_flags)); + } + let checksum_offset = input.len() - CHECKSUM_LENGTH; + let expected_checksum = read_u32_be(&input[checksum_offset..]); + if crc32c(&input[..checksum_offset]) != expected_checksum { + return Err(KcfgDecodeError::ChecksumMismatch); + } + + let field_count = read_u16(&input[FIELD_COUNT_OFFSET..FIELDS_OFFSET]); + let mut cursor = FIELDS_OFFSET; + for _ in 0..field_count { + let (_, next) = parse_field(input, cursor, checksum_offset)?; + cursor = next; + } + if cursor != checksum_offset { + return Err(KcfgDecodeError::TrailingData); + } + + let artifact = KcfgArtifact { + bytes: input, + schema_id: input[SCHEMA_OFFSET..FIELD_COUNT_OFFSET] + .try_into() + .expect("fixed schema slice"), + field_count, + restart_required: artifact_flags & KCFG_RESTART_REQUIRED != 0, + }; + reject_duplicate_fields(&artifact)?; + Ok(artifact) +} + +/// Returns the exact canonical KCFG length for a generated schema. +pub fn kcfg_length() -> Result { + validate_model::().map_err(KcfgEncodeError::InvalidModel)?; + let mut length = KCFG_HEADER_LENGTH + .checked_add(KCFG_CHECKSUM_LENGTH) + .ok_or(KcfgEncodeError::LengthOverflow)?; + for index in 0..C::field_count() { + let descriptor = C::descriptor(index).ok_or(KcfgEncodeError::InvalidModel( + SnapshotModelError::MissingDescriptor(index), + ))?; + validate_descriptor(descriptor)?; + let width = descriptor.kind.width(); + let values = 2 + + usize::from(descriptor.minimum.is_some()) + + usize::from(descriptor.maximum.is_some()); + let mut field_length = FIELD_PREFIX_LENGTH + .checked_add( + width + .checked_mul(values) + .ok_or(KcfgEncodeError::LengthOverflow)?, + ) + .and_then(|length| length.checked_add(descriptor.display_key.len())) + .and_then(|length| length.checked_add(descriptor.unit.len())) + .ok_or(KcfgEncodeError::LengthOverflow)?; + for (_, label) in descriptor.enum_options { + field_length = field_length + .checked_add(2) + .and_then(|length| length.checked_add(label.len())) + .ok_or(KcfgEncodeError::LengthOverflow)?; + } + length = length + .checked_add(field_length) + .ok_or(KcfgEncodeError::LengthOverflow)?; + } + Ok(length) +} + +/// Prepared immutable metadata for one canonical generated artifact. +/// +/// A file endpoint can retain this small value for the lifetime of an open +/// read and reconstruct range exporters without recomputing the file length or +/// either checksum for every transport chunk. +#[derive(Clone, Copy)] +pub struct KcfgExportPlan { + length: usize, + artifact_checksum: u32, + file_checksum: u32, + restart_required: bool, +} + +/// Allocation-free view of one canonical generated artifact. +/// +/// The exporter can fill an arbitrary transport range directly from the typed +/// configuration. Firmware therefore does not need a buffer sized for the +/// complete public file. Construction also computes both the embedded KCFG +/// checksum and the checksum of the complete file bytes. +pub struct KcfgExporter<'a, C: ConfigModel> { + current: &'a C, + factory: &'a C, + plan: KcfgExportPlan, +} + +impl<'a, C: ConfigModel> KcfgExporter<'a, C> { + /// Validates the generated model and prepares a range-readable artifact. + pub fn new( + current: &'a C, + factory: &'a C, + restart_required: bool, + ) -> Result { + let length = kcfg_length::()?; + let mut body_crc = Crc32c::new(); + emit_body(current, factory, restart_required, |bytes| { + body_crc.update(bytes); + true + })?; + let artifact_checksum = body_crc.finish(); + let artifact_checksum_bytes = artifact_checksum.to_be_bytes(); + let mut file_crc = body_crc; + file_crc.update(&artifact_checksum_bytes); + Ok(Self { + current, + factory, + plan: KcfgExportPlan { + length, + artifact_checksum, + file_checksum: file_crc.finish(), + restart_required, + }, + }) + } + + /// Reuses metadata prepared for the supplied immutable export values. + /// + /// The caller must supply the exact `current`, `factory`, and restart state + /// used to create `plan`, and keep them unchanged for the plan's lifetime. + /// File providers use this while an open read excludes replacement. + pub const fn from_plan_unchecked(current: &'a C, factory: &'a C, plan: KcfgExportPlan) -> Self { + Self { + current, + factory, + plan, + } + } + + /// Returns the small reusable plan prepared during construction. + pub const fn plan(&self) -> KcfgExportPlan { + self.plan + } + + /// Exact file length, including the embedded checksum. + pub const fn length(&self) -> usize { + self.plan.length + } + + /// CRC-32C of the complete file, including its embedded checksum. + pub const fn checksum_crc32c(&self) -> u32 { + self.plan.file_checksum + } + + /// Fills a range beginning at `offset` and returns bytes written. + /// + /// An offset at or beyond the end returns zero. Any bounded output range + /// can be filled directly without retaining preceding artifact bytes. + pub fn read(&self, offset: usize, output: &mut [u8]) -> Result { + if offset >= self.plan.length || output.is_empty() { + return Ok(0); + } + let mut sink = RangeSink::new(offset, output); + emit_body( + self.current, + self.factory, + self.plan.restart_required, + |bytes| sink.write(bytes), + )?; + if !sink.is_full() { + sink.write(&self.plan.artifact_checksum.to_be_bytes()); + } + Ok(sink.written()) + } +} + +/// Bounded sequential decoder for a typed KCFG replacement. +/// +/// Only the candidate plus eight scalar bytes are retained. Generated +/// metadata and factory bytes are checked as they arrive, and a field setter +/// is called only after its complete canonical record has been received. +/// Discarding the importer discards every partially imported candidate. +pub struct KcfgImporter { + candidate: C, + phase: ImportPhase, + header_offset: usize, + field_index: u16, + field_offset: usize, + current_bytes: [u8; 8], + checksum_bytes: [u8; CHECKSUM_LENGTH], + checksum_offset: usize, + crc: Crc32c, +} + +#[derive(Clone, Copy, PartialEq)] +enum ImportPhase { + Header, + Fields, + Checksum, + Done, +} + +impl KcfgImporter { + /// Creates an importer from the sole board-owned factory candidate. + pub fn new(factory: &C) -> Result { + validate_model::().map_err(KcfgEncodeError::InvalidModel)?; + // This validates every generated descriptor and factory value without + // retaining the public artifact. + emit_body(factory, factory, false, |_| true)?; + Ok(Self { + candidate: factory.clone(), + phase: ImportPhase::Header, + header_offset: 0, + field_index: 0, + field_offset: 0, + current_bytes: [0; 8], + checksum_bytes: [0; CHECKSUM_LENGTH], + checksum_offset: 0, + crc: Crc32c::new(), + }) + } + + /// Consumes the next contiguous file bytes. + pub fn push(&mut self, factory: &C, input: &[u8]) -> Result<(), KcfgDecodeError> { + for byte in input { + self.push_byte(factory, *byte)?; + } + Ok(()) + } + + /// Returns the complete candidate after checksum and schema validation. + pub fn finish(self) -> Result { + if self.phase != ImportPhase::Done { + return Err(KcfgDecodeError::Truncated); + } + Ok(self.candidate) + } + + fn push_byte(&mut self, factory: &C, byte: u8) -> Result<(), KcfgDecodeError> { + match self.phase { + ImportPhase::Header => self.push_header(byte), + ImportPhase::Fields => self.push_field(factory, byte), + ImportPhase::Checksum => self.push_checksum(byte), + ImportPhase::Done => Err(KcfgDecodeError::TrailingData), + } + } + + fn push_header(&mut self, byte: u8) -> Result<(), KcfgDecodeError> { + match self.header_offset { + MAGIC_OFFSET..VERSION_OFFSET => { + if byte != MAGIC[self.header_offset] { + return Err(KcfgDecodeError::InvalidMagic); + } + } + VERSION_OFFSET => { + if byte != KCFG_VERSION { + return Err(KcfgDecodeError::UnsupportedVersion(byte)); + } + } + ARTIFACT_FLAGS_OFFSET => { + if byte & !ARTIFACT_FLAGS != 0 { + return Err(KcfgDecodeError::InvalidArtifactFlags(byte)); + } + } + SCHEMA_OFFSET..FIELD_COUNT_OFFSET => { + if byte != C::SCHEMA_ID[self.header_offset - SCHEMA_OFFSET] { + return Err(KcfgDecodeError::WrongSchema); + } + } + FIELD_COUNT_OFFSET..FIELDS_OFFSET => { + if byte != C::field_count().to_le_bytes()[self.header_offset - FIELD_COUNT_OFFSET] { + return Err(KcfgDecodeError::WrongFieldCount); + } + } + _ => unreachable!("KCFG header offset is bounded"), + } + self.crc.update_byte(byte); + self.header_offset += 1; + if self.header_offset == FIELDS_OFFSET { + self.phase = if C::field_count() == 0 { + ImportPhase::Checksum + } else { + ImportPhase::Fields + }; + } + Ok(()) + } + + fn push_field(&mut self, factory: &C, byte: u8) -> Result<(), KcfgDecodeError> { + let descriptor = C::descriptor(self.field_index).ok_or(KcfgDecodeError::InvalidModel( + SnapshotModelError::MissingDescriptor(self.field_index), + ))?; + let factory = factory + .field_value(descriptor.id) + .ok_or(KcfgDecodeError::InvalidModel( + SnapshotModelError::MissingValue(descriptor.id), + ))?; + if factory.kind() != descriptor.kind { + return Err(KcfgDecodeError::InvalidModel( + SnapshotModelError::WrongValueKind { + field: descriptor.id, + expected: descriptor.kind, + actual: factory.kind(), + }, + )); + } + match expected_field_byte(descriptor, factory, self.field_offset) { + Some(ExpectedFieldByte::Current(index)) => self.current_bytes[index] = byte, + Some(ExpectedFieldByte::Factory(expected)) if byte != expected => { + return Err(KcfgDecodeError::FactoryMismatch(descriptor.id)); + } + Some(ExpectedFieldByte::Descriptor(expected)) if byte != expected => { + return Err(KcfgDecodeError::DescriptorMismatch(descriptor.id)); + } + Some(ExpectedFieldByte::Factory(_) | ExpectedFieldByte::Descriptor(_)) => {} + None => return Err(KcfgDecodeError::TrailingData), + } + self.crc.update_byte(byte); + self.field_offset += 1; + + if self.field_offset == field_record_length(descriptor) { + let width = descriptor.kind.width(); + let value = + value_from_bytes(descriptor.id, descriptor.kind, &self.current_bytes[..width])?; + self.candidate + .set_field(descriptor.id, value) + .map_err(KcfgDecodeError::InvalidWrite)?; + self.current_bytes = [0; 8]; + self.field_offset = 0; + self.field_index += 1; + if self.field_index == C::field_count() { + self.phase = ImportPhase::Checksum; + } + } + Ok(()) + } + + fn push_checksum(&mut self, byte: u8) -> Result<(), KcfgDecodeError> { + self.checksum_bytes[self.checksum_offset] = byte; + self.checksum_offset += 1; + if self.checksum_offset == CHECKSUM_LENGTH { + if u32::from_be_bytes(self.checksum_bytes) != self.crc.finish() { + return Err(KcfgDecodeError::ChecksumMismatch); + } + self.phase = ImportPhase::Done; + } + Ok(()) + } +} + +#[cfg(test)] +fn encode_kcfg<'a, C: ConfigModel>( + current: &C, + factory: &C, + restart_required: bool, + output: &'a mut [u8], +) -> Result<&'a [u8], KcfgEncodeError> { + let exporter = KcfgExporter::new(current, factory, restart_required)?; + let required = exporter.length(); + if output.len() < required { + return Err(KcfgEncodeError::BufferTooSmall { + required, + available: output.len(), + }); + } + let written = exporter.read(0, &mut output[..required])?; + debug_assert_eq!(written, required); + Ok(&output[..required]) +} + +/// Re-encodes a decoded artifact while replacing only committed field values. +/// +/// A board-agnostic host can retain the received artifact bytes, present the +/// borrowed metadata to its UI, and provide edited values by stable field ID. +/// The generated metadata, schema, factory values, and enum choices are copied +/// unchanged and the checksum is regenerated here. +pub fn encode_edited_kcfg<'a, F>( + source: &KcfgArtifact<'_>, + restart_required: bool, + output: &'a mut [u8], + mut value_for: F, +) -> Result<&'a [u8], KcfgEncodeError> +where + F: FnMut(&KcfgField<'_>) -> ConfigValue, +{ + let required = source.bytes.len(); + if output.len() < required { + return Err(KcfgEncodeError::BufferTooSmall { + required, + available: output.len(), + }); + } + output[..required].copy_from_slice(source.bytes); + output[ARTIFACT_FLAGS_OFFSET] = if restart_required { + KCFG_RESTART_REQUIRED + } else { + 0 + }; + for field in source.fields() { + let value = value_for(&field); + if value.kind() != field.kind { + return Err(KcfgEncodeError::EditedValueKind(field.id)); + } + value.write_le_bytes(&mut output[field.current_offset..]); + } + let checksum_offset = required - CHECKSUM_LENGTH; + let checksum = crc32c(&output[..checksum_offset]); + output[checksum_offset..required].copy_from_slice(&checksum.to_be_bytes()); + Ok(&output[..required]) +} + +#[cfg(test)] +fn decode_kcfg_for(input: &[u8], factory: &C) -> Result { + validate_model::().map_err(KcfgDecodeError::InvalidModel)?; + let artifact = decode_kcfg(input)?; + if artifact.schema_id != C::SCHEMA_ID { + return Err(KcfgDecodeError::WrongSchema); + } + if artifact.field_count != C::field_count() { + return Err(KcfgDecodeError::WrongFieldCount); + } + + let mut candidate = factory.clone(); + for (index, field) in artifact.fields().enumerate() { + let descriptor = C::descriptor(index as u16).ok_or(KcfgDecodeError::InvalidModel( + SnapshotModelError::MissingDescriptor(index as u16), + ))?; + if !same_descriptor(&field, descriptor) { + return Err(KcfgDecodeError::DescriptorMismatch(field.id)); + } + if factory.field_value(field.id) != Some(field.factory) { + return Err(KcfgDecodeError::FactoryMismatch(field.id)); + } + candidate + .set_field(field.id, field.current) + .map_err(KcfgDecodeError::InvalidWrite)?; + } + Ok(candidate) +} + +fn emit_body( + current: &C, + factory: &C, + restart_required: bool, + mut emit: F, +) -> Result<(), KcfgEncodeError> +where + C: ConfigModel, + F: FnMut(&[u8]) -> bool, +{ + macro_rules! emit_or_finish { + ($bytes:expr) => { + if !emit($bytes) { + return Ok(()); + } + }; + } + + emit_or_finish!(&MAGIC); + emit_or_finish!(&[KCFG_VERSION]); + emit_or_finish!(&[if restart_required { + KCFG_RESTART_REQUIRED + } else { + 0 + }]); + emit_or_finish!(&C::SCHEMA_ID); + emit_or_finish!(&C::field_count().to_le_bytes()); + + for index in 0..C::field_count() { + let descriptor = C::descriptor(index).ok_or(KcfgEncodeError::InvalidModel( + SnapshotModelError::MissingDescriptor(index), + ))?; + validate_descriptor(descriptor)?; + let current = current + .field_value(descriptor.id) + .ok_or(KcfgEncodeError::MissingValue(descriptor.id))?; + let factory = factory + .field_value(descriptor.id) + .ok_or(KcfgEncodeError::MissingValue(descriptor.id))?; + if current.kind() != descriptor.kind + || factory.kind() != descriptor.kind + || descriptor + .minimum + .is_some_and(|value| value.kind() != descriptor.kind) + || descriptor + .maximum + .is_some_and(|value| value.kind() != descriptor.kind) + { + return Err(KcfgEncodeError::WrongValueKind(descriptor.id)); + } + + emit_or_finish!(&descriptor.id.get().to_le_bytes()); + emit_or_finish!(&[ + descriptor.kind as u8, + field_flags(descriptor), + descriptor.display_key.len() as u8, + descriptor.unit.len() as u8, + descriptor.enum_options.len() as u8, + ]); + if !emit_value(current, &mut emit) { + return Ok(()); + } + if !emit_value(factory, &mut emit) { + return Ok(()); + } + if let Some(minimum) = descriptor.minimum + && !emit_value(minimum, &mut emit) + { + return Ok(()); + } + if let Some(maximum) = descriptor.maximum + && !emit_value(maximum, &mut emit) + { + return Ok(()); + } + emit_or_finish!(descriptor.display_key.as_bytes()); + emit_or_finish!(descriptor.unit.as_bytes()); + for (value, label) in descriptor.enum_options { + emit_or_finish!(&[*value, label.len() as u8]); + emit_or_finish!(label.as_bytes()); + } + } + Ok(()) +} + +fn emit_value bool>(value: ConfigValue, emit: &mut F) -> bool { + let bytes = value.raw_bits().to_le_bytes(); + emit(&bytes[..value.kind().width()]) +} + +fn validate_descriptor(descriptor: &crate::FieldDescriptor) -> Result<(), KcfgEncodeError> { + if descriptor.display_key.is_empty() + || descriptor.display_key.len() > u8::MAX as usize + || descriptor.display_key.contains('\0') + || descriptor.unit.len() > u8::MAX as usize + || descriptor.unit.contains('\0') + || descriptor.enum_options.len() > u8::MAX as usize + || (!descriptor.enum_options.is_empty() && descriptor.kind != ValueKind::U8) + { + return Err(KcfgEncodeError::InvalidMetadata(descriptor.id)); + } + for (index, (value, label)) in descriptor.enum_options.iter().enumerate() { + if label.is_empty() + || label.len() > u8::MAX as usize + || label.contains('\0') + || descriptor.enum_options[..index] + .iter() + .any(|previous| previous.0 == *value) + { + return Err(KcfgEncodeError::InvalidMetadata(descriptor.id)); + } + } + Ok(()) +} + +fn parse_field( + bytes: &[u8], + start: usize, + limit: usize, +) -> Result<(KcfgField<'_>, usize), KcfgDecodeError> { + let prefix_end = start + .checked_add(FIELD_PREFIX_LENGTH) + .ok_or(KcfgDecodeError::Truncated)?; + if prefix_end > limit { + return Err(KcfgDecodeError::Truncated); + } + let raw_id = read_u16(&bytes[start..start + 2]); + let id = FieldId::try_new(raw_id).ok_or(KcfgDecodeError::InvalidFieldId(raw_id))?; + let raw_kind = bytes[start + 2]; + let kind = + ValueKind::try_from_code(raw_kind).ok_or(KcfgDecodeError::InvalidValueKind(raw_kind))?; + let flags = bytes[start + 3]; + if flags & !FIELD_FLAGS != 0 { + return Err(KcfgDecodeError::InvalidFieldFlags(id)); + } + let key_length = usize::from(bytes[start + 4]); + let unit_length = usize::from(bytes[start + 5]); + let enum_count = bytes[start + 6]; + if enum_count != 0 && kind != ValueKind::U8 { + return Err(KcfgDecodeError::InvalidEnum(id)); + } + + let mut cursor = prefix_end; + let current_offset = cursor; + let current = parse_value(bytes, &mut cursor, limit, id, kind)?; + let factory = parse_value(bytes, &mut cursor, limit, id, kind)?; + let minimum = if flags & FIELD_HAS_MINIMUM != 0 { + Some(parse_value(bytes, &mut cursor, limit, id, kind)?) + } else { + None + }; + let maximum = if flags & FIELD_HAS_MAXIMUM != 0 { + Some(parse_value(bytes, &mut cursor, limit, id, kind)?) + } else { + None + }; + + let key_end = cursor + .checked_add(key_length) + .ok_or(KcfgDecodeError::Truncated)?; + let unit_end = key_end + .checked_add(unit_length) + .ok_or(KcfgDecodeError::Truncated)?; + if unit_end > limit { + return Err(KcfgDecodeError::Truncated); + } + let key = + canonical_text(&bytes[cursor..key_end], false).ok_or(KcfgDecodeError::InvalidText(id))?; + let unit = + canonical_text(&bytes[key_end..unit_end], true).ok_or(KcfgDecodeError::InvalidText(id))?; + cursor = unit_end; + let options_start = cursor; + for option_index in 0..enum_count { + if cursor.checked_add(2).is_none_or(|end| end > limit) { + return Err(KcfgDecodeError::Truncated); + } + let value = bytes[cursor]; + let label_length = usize::from(bytes[cursor + 1]); + let label_end = cursor + .checked_add(2 + label_length) + .ok_or(KcfgDecodeError::Truncated)?; + if label_end > limit { + return Err(KcfgDecodeError::Truncated); + } + if canonical_text(&bytes[cursor + 2..label_end], false).is_none() { + return Err(KcfgDecodeError::InvalidText(id)); + } + let mut previous = KcfgEnumOptions { + bytes: &bytes[options_start..cursor], + remaining: option_index, + }; + if previous.any(|option| option.value == value) { + return Err(KcfgDecodeError::DuplicateEnumValue(id)); + } + cursor = label_end; + } + + Ok(( + KcfgField { + id, + kind, + current, + factory, + minimum, + maximum, + key, + unit, + options: KcfgEnumOptions { + bytes: &bytes[options_start..cursor], + remaining: enum_count, + }, + current_offset, + }, + cursor, + )) +} + +fn reject_duplicate_fields(artifact: &KcfgArtifact<'_>) -> Result<(), KcfgDecodeError> { + for (index, field) in artifact.fields().enumerate() { + if artifact + .fields() + .take(index) + .any(|previous| previous.id == field.id) + { + return Err(KcfgDecodeError::DuplicateField(field.id)); + } + } + Ok(()) +} + +fn parse_value( + bytes: &[u8], + cursor: &mut usize, + limit: usize, + id: FieldId, + kind: ValueKind, +) -> Result { + let width = kind.width(); + let end = cursor + .checked_add(width) + .ok_or(KcfgDecodeError::Truncated)?; + if end > limit { + return Err(KcfgDecodeError::Truncated); + } + let value = value_from_bytes(id, kind, &bytes[*cursor..end])?; + *cursor = end; + Ok(value) +} + +fn value_from_bytes( + id: FieldId, + kind: ValueKind, + bytes: &[u8], +) -> Result { + ConfigValue::from_le_bytes(kind, bytes).ok_or(KcfgDecodeError::InvalidValue(id)) +} + +#[cfg(test)] +fn same_descriptor(field: &KcfgField<'_>, descriptor: &crate::FieldDescriptor) -> bool { + field.id == descriptor.id + && field.kind == descriptor.kind + && field.minimum == descriptor.minimum + && field.maximum == descriptor.maximum + && field.key == descriptor.display_key + && field.unit == descriptor.unit + && field + .options + .zip(descriptor.enum_options.iter().copied()) + .all(|(actual, expected)| (actual.value, actual.label) == expected) + && field.options.len() == descriptor.enum_options.len() +} + +fn canonical_text(bytes: &[u8], allow_empty: bool) -> Option<&str> { + let text = str::from_utf8(bytes).ok()?; + if (!allow_empty && text.is_empty()) || text.contains('\0') { + None + } else { + Some(text) + } +} + +fn read_u16(bytes: &[u8]) -> u16 { + u16::from_le_bytes([bytes[0], bytes[1]]) +} + +fn read_u32_be(bytes: &[u8]) -> u32 { + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +enum ExpectedFieldByte { + Current(usize), + Factory(u8), + Descriptor(u8), +} + +fn expected_field_byte( + descriptor: &crate::FieldDescriptor, + factory: ConfigValue, + offset: usize, +) -> Option { + let id = descriptor.id.get().to_le_bytes(); + let prefix = [ + id[0], + id[1], + descriptor.kind as u8, + field_flags(descriptor), + descriptor.display_key.len() as u8, + descriptor.unit.len() as u8, + descriptor.enum_options.len() as u8, + ]; + if offset < prefix.len() { + return Some(ExpectedFieldByte::Descriptor(prefix[offset])); + } + + let width = descriptor.kind.width(); + let mut cursor = prefix.len(); + if offset < cursor + width { + return Some(ExpectedFieldByte::Current(offset - cursor)); + } + cursor += width; + if offset < cursor + width { + return Some(ExpectedFieldByte::Factory(value_byte( + factory, + offset - cursor, + ))); + } + cursor += width; + if let Some(minimum) = descriptor.minimum { + if offset < cursor + width { + return Some(ExpectedFieldByte::Descriptor(value_byte( + minimum, + offset - cursor, + ))); + } + cursor += width; + } + if let Some(maximum) = descriptor.maximum { + if offset < cursor + width { + return Some(ExpectedFieldByte::Descriptor(value_byte( + maximum, + offset - cursor, + ))); + } + cursor += width; + } + if offset < cursor + descriptor.display_key.len() { + return Some(ExpectedFieldByte::Descriptor( + descriptor.display_key.as_bytes()[offset - cursor], + )); + } + cursor += descriptor.display_key.len(); + if offset < cursor + descriptor.unit.len() { + return Some(ExpectedFieldByte::Descriptor( + descriptor.unit.as_bytes()[offset - cursor], + )); + } + cursor += descriptor.unit.len(); + for (value, label) in descriptor.enum_options { + if offset == cursor { + return Some(ExpectedFieldByte::Descriptor(*value)); + } + cursor += 1; + if offset == cursor { + return Some(ExpectedFieldByte::Descriptor(label.len() as u8)); + } + cursor += 1; + if offset < cursor + label.len() { + return Some(ExpectedFieldByte::Descriptor( + label.as_bytes()[offset - cursor], + )); + } + cursor += label.len(); + } + None +} + +fn field_record_length(descriptor: &crate::FieldDescriptor) -> usize { + let width = descriptor.kind.width(); + let value_count = + 2 + usize::from(descriptor.minimum.is_some()) + usize::from(descriptor.maximum.is_some()); + FIELD_PREFIX_LENGTH + + width * value_count + + descriptor.display_key.len() + + descriptor.unit.len() + + descriptor + .enum_options + .iter() + .map(|(_, label)| 2 + label.len()) + .sum::() +} + +fn field_flags(descriptor: &crate::FieldDescriptor) -> u8 { + (u8::from(descriptor.minimum.is_some()) * FIELD_HAS_MINIMUM) + | (u8::from(descriptor.maximum.is_some()) * FIELD_HAS_MAXIMUM) +} + +fn value_byte(value: ConfigValue, offset: usize) -> u8 { + value.raw_bits().to_le_bytes()[offset] +} + +struct RangeSink<'a> { + requested_offset: usize, + output: &'a mut [u8], + position: usize, + written: usize, +} + +impl<'a> RangeSink<'a> { + const fn new(requested_offset: usize, output: &'a mut [u8]) -> Self { + Self { + requested_offset, + output, + position: 0, + written: 0, + } + } + + fn write(&mut self, bytes: &[u8]) -> bool { + let segment_start = self.position; + let segment_end = segment_start + bytes.len(); + let requested_end = self.requested_offset.saturating_add(self.output.len()); + let copy_start = segment_start.max(self.requested_offset); + let copy_end = segment_end.min(requested_end); + if copy_start < copy_end { + let source_start = copy_start - segment_start; + let destination_start = copy_start - self.requested_offset; + let count = copy_end - copy_start; + self.output[destination_start..destination_start + count] + .copy_from_slice(&bytes[source_start..source_start + count]); + self.written += count; + } + self.position = segment_end; + !self.is_full() + } + + const fn is_full(&self) -> bool { + self.written == self.output.len() + } + + const fn written(&self) -> usize { + self.written + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ConfigViolation, FieldDescriptor, ValidationRule}; + + const RATE: FieldId = FieldId::new(1); + const PORT: FieldId = FieldId::new(2); + const MODE: FieldId = FieldId::new(3); + const MODE_OPTIONS: &[(u8, &str)] = &[(1, "quiet"), (3, "verbose")]; + const DESCRIPTORS: [FieldDescriptor; 3] = [ + FieldDescriptor { + id: RATE, + display_key: "telemetry.imu_hz", + kind: ValueKind::U8, + minimum: Some(ConfigValue::U8(1)), + maximum: Some(ConfigValue::U8(100)), + enum_options: &[], + unit: "Hz", + }, + FieldDescriptor { + id: PORT, + display_key: "mavlink.port", + kind: ValueKind::U16, + minimum: Some(ConfigValue::U16(1)), + maximum: Some(ConfigValue::U16(u16::MAX)), + enum_options: &[], + unit: "id", + }, + FieldDescriptor { + id: MODE, + display_key: "logging.level", + kind: ValueKind::U8, + minimum: None, + maximum: None, + enum_options: MODE_OPTIONS, + unit: "level", + }, + ]; + + #[derive(Clone, Debug, Eq, PartialEq)] + struct Candidate { + rate: u8, + port: u16, + mode: u8, + } + + impl ConfigModel for Candidate { + type Active = Self; + type Context = (); + const SCHEMA_ID: [u8; 16] = *b"KCFGARTIFACTTEST"; + + fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { + DESCRIPTORS.get(usize::from(index)) + } + + fn field_count() -> u16 { + DESCRIPTORS.len() as u16 + } + + fn field_value(&self, id: FieldId) -> Option { + match id { + RATE => Some(ConfigValue::U8(self.rate)), + PORT => Some(ConfigValue::U16(self.port)), + MODE => Some(ConfigValue::U8(self.mode)), + _ => None, + } + } + + fn active_field_value(active: &Self::Active, id: FieldId) -> Option { + active.field_value(id) + } + + fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { + match (id, value) { + (RATE, ConfigValue::U8(value)) if (1..=100).contains(&value) => self.rate = value, + (PORT, ConfigValue::U16(value)) if value != 0 => self.port = value, + (MODE, ConfigValue::U8(1 | 3)) => self.mode = value.raw_bits() as u8, + (RATE | PORT | MODE, value) if value.kind() != descriptor(id).kind => { + return Err(FieldWriteError::WrongType { + field: id, + expected: descriptor(id).kind, + actual: value.kind(), + }); + } + (RATE | PORT | MODE, _) => { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::AboveMaximum, + ))); + } + _ => return Err(FieldWriteError::UnknownField(id)), + } + Ok(()) + } + + fn validate(&self, _: &Self::Context) -> Result { + Ok(self.clone()) + } + } + + fn descriptor(id: FieldId) -> &'static FieldDescriptor { + DESCRIPTORS + .iter() + .find(|descriptor| descriptor.id == id) + .unwrap() + } + + fn factory() -> Candidate { + Candidate { + rate: 20, + port: 5760, + mode: 1, + } + } + + #[test] + fn result_detail_preserves_activation_and_validation_identity() { + let success = KcfgResultDetail::success(true).encode(); + let (success, text) = KcfgResultDetail::decode(&success).unwrap(); + assert!(success.restart_required()); + assert_eq!(success.primary(), None); + assert_eq!(text, ""); + + let violation = ConfigViolation::related(RATE, PORT, ValidationRule::UnsupportedCapability); + let mut encoded = [0_u8; KCFG_RESULT_DETAIL_LENGTH + 8]; + encoded[..KCFG_RESULT_DETAIL_LENGTH] + .copy_from_slice(&KcfgResultDetail::violation(violation).encode()); + encoded[KCFG_RESULT_DETAIL_LENGTH..].copy_from_slice(b"rejected"); + let (detail, text) = KcfgResultDetail::decode(&encoded).unwrap(); + assert!(!detail.restart_required()); + assert_eq!(detail.primary(), Some(RATE)); + assert_eq!(detail.related_field(), Some(PORT)); + assert_eq!(detail.rule(), Some(ValidationRule::UnsupportedCapability)); + assert_eq!(text, "rejected"); + + let mut reserved = KcfgResultDetail::failure().encode(); + reserved[0] = 2; + assert!(KcfgResultDetail::decode(&reserved).is_none()); + let mut unknown_rule = KcfgResultDetail::failure().encode(); + unknown_rule[5..].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(KcfgResultDetail::decode(&unknown_rule).is_none()); + } + + #[test] + fn dynamic_artifact_preserves_native_widths_and_host_edits() { + let factory = factory(); + let current = Candidate { + rate: 40, + port: 14550, + mode: 3, + }; + let mut bytes = [0; 256]; + let encoded = encode_kcfg(¤t, &factory, true, &mut bytes).unwrap(); + let artifact = decode_kcfg(encoded).unwrap(); + assert_eq!(artifact.schema_id(), Candidate::SCHEMA_ID); + assert!(artifact.restart_required()); + let mut fields = artifact.fields(); + assert_eq!(fields.next().unwrap().value(), ConfigValue::U8(40)); + assert_eq!(fields.next().unwrap().value(), ConfigValue::U16(14550)); + assert_eq!(fields.next().unwrap().enum_options().len(), 2); + + let mut edited = [0; 256]; + let edited = encode_edited_kcfg(&artifact, false, &mut edited, |field| { + if field.id() == RATE { + ConfigValue::U8(75) + } else { + field.value() + } + }) + .unwrap(); + assert_eq!( + decode_kcfg_for(edited, &factory).unwrap(), + Candidate { + rate: 75, + port: 14550, + mode: 3, + } + ); + } + + #[test] + fn range_export_and_stream_import_need_no_whole_artifact_buffer() { + let factory = factory(); + let current = Candidate { + rate: 60, + port: 4861, + mode: 3, + }; + let exporter = KcfgExporter::new(¤t, &factory, true).unwrap(); + let mut contiguous = [0; 256]; + let contiguous_length = encode_kcfg(¤t, &factory, true, &mut contiguous) + .unwrap() + .len(); + assert_eq!(exporter.length(), contiguous_length); + let checksum_offset = contiguous_length - CHECKSUM_LENGTH; + assert_eq!( + &contiguous[checksum_offset..contiguous_length], + &crc32c(&contiguous[..checksum_offset]).to_be_bytes() + ); + assert_eq!( + exporter.checksum_crc32c(), + crc32c(&contiguous[..contiguous_length]) + ); + let factory_exporter = KcfgExporter::new(&factory, &factory, true).unwrap(); + assert_ne!( + exporter.checksum_crc32c(), + factory_exporter.checksum_crc32c(), + "equal-length KCFG files with different values need distinct transfer checksums" + ); + + let resumed = KcfgExporter::from_plan_unchecked(¤t, &factory, exporter.plan()); + assert_eq!(resumed.length(), exporter.length()); + assert_eq!(resumed.checksum_crc32c(), exporter.checksum_crc32c()); + + let mut ranged = [0; 256]; + let mut offset = 0; + while offset < exporter.length() { + let end = (offset + 7).min(exporter.length()); + assert_eq!( + exporter.read(offset, &mut ranged[offset..end]).unwrap(), + end - offset + ); + offset = end; + } + assert_eq!( + &ranged[..exporter.length()], + &contiguous[..exporter.length()] + ); + + for chunk_length in 1..=exporter.length() { + let mut importer = KcfgImporter::new(&factory).unwrap(); + for chunk in contiguous[..exporter.length()].chunks(chunk_length) { + importer.push(&factory, chunk).unwrap(); + } + assert_eq!(importer.finish().unwrap(), current); + } + + for truncated_length in 0..exporter.length() { + let mut importer = KcfgImporter::new(&factory).unwrap(); + importer + .push(&factory, &contiguous[..truncated_length]) + .unwrap(); + assert!(matches!(importer.finish(), Err(KcfgDecodeError::Truncated))); + } + let mut importer = KcfgImporter::new(&factory).unwrap(); + importer + .push(&factory, &contiguous[..exporter.length()]) + .unwrap(); + assert!(matches!( + importer.push(&factory, &[0]), + Err(KcfgDecodeError::TrailingData) + )); + } + + #[test] + fn range_export_stops_emission_when_the_requested_chunk_is_full() { + let factory = factory(); + let mut first_byte = [0]; + let (position, written, is_full) = { + let mut sink = RangeSink::new(0, &mut first_byte); + emit_body(&factory, &factory, false, |bytes| sink.write(bytes)).unwrap(); + (sink.position, sink.written(), sink.is_full()) + }; + + assert_eq!(first_byte, [MAGIC[0]]); + assert_eq!(position, MAGIC.len()); + assert_eq!(written, 1); + assert!(is_full); + } + + #[test] + fn typed_import_rejects_metadata_and_integrity_changes() { + let factory = factory(); + let mut storage = [0; 256]; + let length = encode_kcfg(&factory, &factory, false, &mut storage) + .unwrap() + .len(); + + storage[FIELDS_OFFSET + FIELD_PREFIX_LENGTH + 4] ^= 1; + let checksum_offset = length - CHECKSUM_LENGTH; + let checksum = crc32c(&storage[..checksum_offset]); + storage[checksum_offset..length].copy_from_slice(&checksum.to_be_bytes()); + assert!(decode_kcfg(&storage[..length]).is_ok()); + assert!(matches!( + decode_kcfg_for(&storage[..length], &factory), + Err(KcfgDecodeError::DescriptorMismatch(RATE)) + )); + + storage[checksum_offset] ^= 1; + assert!(matches!( + decode_kcfg(&storage[..length]), + Err(KcfgDecodeError::ChecksumMismatch) + )); + } +} diff --git a/firmware/libs/kaptan-config/src/checksum.rs b/firmware/libs/kaptan-config/src/checksum.rs new file mode 100644 index 0000000..cbb0fc3 --- /dev/null +++ b/firmware/libs/kaptan-config/src/checksum.rs @@ -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); + } +} diff --git a/firmware/libs/kaptan-config/src/error.rs b/firmware/libs/kaptan-config/src/error.rs index 408f27a..6e43984 100644 --- a/firmware/libs/kaptan-config/src/error.rs +++ b/firmware/libs/kaptan-config/src/error.rs @@ -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 { + 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. diff --git a/firmware/libs/kaptan-config/src/field.rs b/firmware/libs/kaptan-config/src/field.rs index 822e7d3..5f9ea4a 100644 --- a/firmware/libs/kaptan-config/src/field.rs +++ b/firmware/libs/kaptan-config/src/field.rs @@ -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 { - 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 { + 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 { + 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 { + 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, /// 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 { - 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)) diff --git a/firmware/libs/kaptan-config/src/journal.rs b/firmware/libs/kaptan-config/src/journal.rs index af2ef57..f170a37 100644 --- a/firmware/libs/kaptan-config/src/journal.rs +++ b/firmware/libs/kaptan-config/src/journal.rs @@ -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 { } /// Generic persistent store backed by two independently erasable NOR slots. -pub struct DualSlotJournal { +pub struct DualSlotJournal { flash: F, - factory: C, - _payload: PhantomData<[u8; MAX_PAYLOAD]>, + // The committed slot selected during recovery must survive until its replacement commits. + protected_slot: Option, } -impl DualSlotJournal { - /// 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 DualSlotJournal { + /// 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 DualSlotJournal { } } -#[derive(Clone)] struct Record { - slot: usize, + slot: Slot, sequence: u64, candidate: C, } -impl DualSlotJournal +#[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::>() == 1); + +impl DualSlotJournal where - C: ConfigModel, F: DualSlotNor, { fn geometry(&self) -> Result<(), JournalError> { @@ -97,10 +116,14 @@ where Ok(()) } - fn record(&mut self, slot: usize) -> Result>, JournalError> { + fn record( + &mut self, + slot: Slot, + decode_base: &C, + ) -> Result>, JournalError> { 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>, JournalError> { - let left = self.record(0)?; - let right = self.record(1)?; + fn newest( + &mut self, + decode_base: &C, + ) -> Result>, JournalError> { + 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> { @@ -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 ConfigStore for DualSlotJournal +impl ConfigStore for DualSlotJournal where C: ConfigModel, F: DualSlotNor, { type Error = JournalError; - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self, factory: &C) -> Result, 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

(&mut self, mut compatible: P) -> Result, Self::Error> + fn load_matching

(&mut self, factory: &C, mut compatible: P) -> Result, 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]; - self.flash - .read( - target_slot, - HEADER_LENGTH, - &mut verify_payload[..payload.len()], - ) - .map_err(JournalError::Storage)?; - if verify_header != header[..COMMIT_OFFSET] || verify_payload[..payload.len()] != *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.index(), + HEADER_LENGTH + chunk_index * VERIFY_CHUNK_LENGTH, + actual, + ) + .map_err(JournalError::Storage)?; + 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; + type Journal = DualSlotJournal; + 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" + ); + } + } } diff --git a/firmware/libs/kaptan-config/src/lib.rs b/firmware/libs/kaptan-config/src/lib.rs index 914c3d3..dd18088 100644 --- a/firmware/libs/kaptan-config/src/lib.rs +++ b/firmware/libs/kaptan-config/src/lib.rs @@ -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, }; diff --git a/firmware/libs/kaptan-config/src/lifecycle.rs b/firmware/libs/kaptan-config/src/lifecycle.rs index bc05537..ae3a43f 100644 --- a/firmware/libs/kaptan-config/src/lifecycle.rs +++ b/firmware/libs/kaptan-config/src/lifecycle.rs @@ -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 { /// 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 { - /// No transaction is open. - #[kaptan_error(id = 1401)] - NoTransaction, +pub enum ReplaceError { /// 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 { active: C::Active, factory: C, committed: C, - transaction: Option, context: C::Context, store: S, - restart_required: bool, } impl ConfigManager @@ -82,22 +54,6 @@ where C: ConfigModel, S: ConfigStore, { - /// 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 { - 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> { - let Some(committed) = store.load().map_err(StartupError::Store)? else { - return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted); - }; - let active = committed + let factory_active = factory .validate(&context) - .map_err(StartupError::InvalidPersisted)?; + .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> { - 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 active = committed + let factory_active = factory .validate(&context) - .map_err(StartupError::InvalidPersisted)?; + .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 { - 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 { - 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 { - 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::(&self.active, &self.committed) } - /// Returns a transaction value when an edit is open. - pub fn transaction_value(&self, id: FieldId) -> Option { - 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 { - 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> { 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(), + }); + } + 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"); + } else { + kaptan_log::info!("configuration restored to active values"); + } + Ok(ReplaceOutcome { restart_required }) } +} - /// Validates the complete open transaction without changing persistent or - /// active state. - pub fn validate(&self) -> Result<(), CommitError> { - 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)) - } +fn same_values(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; } } + true +} - /// Validates and durably commits the complete open transaction. - pub fn commit(&mut self) -> Result<(), CommitError> { - 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)); +fn active_matches_candidate(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; } - if let Err(error) = self.store.persist(candidate) { - return Err(CommitError::Store(error)); - } - self.committed = candidate.clone(); - self.transaction = None; - self.restart_required = true; - kaptan_log::info!("configuration saved; restart required"); - Ok(()) - } - - /// Drops the RAM transaction without changing active or committed state. - pub fn abort(&mut self) -> bool { - let aborted = self.transaction.take().is_some(); - if aborted { - kaptan_log::debug!("edit transaction aborted"); - } - aborted } + 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 { 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 { 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); - impl ConfigStore for MemoryStore { + impl ConfigStore for MemoryStore<'_> { type Error = (); - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self, _: &Candidate) -> Result, 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 for IncompatibleStore { type Error = (); - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self, _: &Candidate) -> Result, 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 for ValidStore { + type Error = (); + + fn load(&mut self, _: &Candidate) -> Result, 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(_)) + )); + } } diff --git a/firmware/libs/kaptan-config/src/snapshot.rs b/firmware/libs/kaptan-config/src/snapshot.rs index 360734e..5013aed 100644 --- a/firmware/libs/kaptan-config/src/snapshot.rs +++ b/firmware/libs/kaptan-config/src/snapshot.rs @@ -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::().map_err(ScalarSnapshotEncodeError::InvalidModel)?; let field_count = C::field_count(); - let required = scalar_snapshot_length(field_count); + let required = model_snapshot_length::().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( 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::(input, actual_count)?; if input.len() > expected_length { return Err(ScalarSnapshotDecodeError::TrailingData { expected: expected_length, @@ -279,24 +275,25 @@ pub fn decode_scalar_snapshot( }); } - validate_entries::(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() -> Result<(), SnapshotModelError> { +pub(crate) fn validate_model() -> Result<(), SnapshotModelError> { let count = C::field_count(); for index in 0..count { let descriptor = @@ -318,17 +315,25 @@ fn validate_model() -> Result<(), SnapshotModelError> { fn validate_entries( input: &[u8], count: u16, -) -> Result<(), ScalarSnapshotDecodeError> { +) -> Result { + 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::(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( 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; } - - 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)); - } - } - Ok(()) + Ok(entry) } -fn descriptor_by_id(id: FieldId) -> Option<&'static crate::FieldDescriptor> { +fn require_bytes(input: &[u8], required: usize) -> Result<(), ScalarSnapshotDecodeError> { + if input.len() < required { + Err(ScalarSnapshotDecodeError::Truncated { + required, + available: input.len(), + }) + } else { + Ok(()) + } +} + +fn model_snapshot_length() -> Result { + 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 { - 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 { - 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 { - boolean: false, - signed: -1, - unsigned: 1, - wide: 1, - float: 1.0, - double: 1.0, - } + 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( diff --git a/firmware/libs/kaptan-config/src/storage.rs b/firmware/libs/kaptan-config/src/storage.rs index 5b7e6f0..337f616 100644 --- a/firmware/libs/kaptan-config/src/storage.rs +++ b/firmware/libs/kaptan-config/src/storage.rs @@ -12,18 +12,20 @@ pub trait ConfigStore { type Error; /// Loads the newest valid committed candidate, if one exists. - fn load(&mut self) -> Result, Self::Error>; + fn load(&mut self, factory: &C) -> Result, 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(&mut self, mut compatible: F) -> Result, Self::Error> + fn load_matching(&mut self, factory: &C, mut compatible: F) -> Result, 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. diff --git a/firmware/libs/kaptan-config/src/validation.rs b/firmware/libs/kaptan-config/src/validation.rs index 2011cf6..1f7a7a0 100644 --- a/firmware/libs/kaptan-config/src/validation.rs +++ b/firmware/libs/kaptan-config/src/validation.rs @@ -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() -> 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; } diff --git a/firmware/libs/kaptan-data-channel/Cargo.toml b/firmware/libs/kaptan-data-channel/Cargo.toml index fefeb56..57849d0 100644 --- a/firmware/libs/kaptan-data-channel/Cargo.toml +++ b/firmware/libs/kaptan-data-channel/Cargo.toml @@ -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 } diff --git a/firmware/libs/kaptan-data-channel/src/lib.rs b/firmware/libs/kaptan-data-channel/src/lib.rs index 30f7522..57bffea 100644 --- a/firmware/libs/kaptan-data-channel/src/lib.rs +++ b/firmware/libs/kaptan-data-channel/src/lib.rs @@ -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, /// 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, - /// MTF-01 sensor ID for `OPTICAL_FLOW`; `None` disables it. - pub optical_flow_sensor_id: Option, - /// 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, sources: DataSources<'a>, - vertical: VerticalDataConfig, + maximum_vertical_age_ticks: u8, observed_imu: u32, observed_range: u32, last_range: Option, @@ -116,7 +103,7 @@ where system_id: u8, component_id: u8, rates: DataChannelRates, - vertical: VerticalDataConfig, + maximum_vertical_age_ticks: u8, sources: DataSources<'a>, ) -> Result { 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 self.optical_flow_age_ticks <= self.maximum_vertical_age_ticks => { - if let (Some(flow), Some(sensor_id)) = - (self.last_optical_flow, self.vertical.optical_flow_sensor_id) - { + 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(&mut self, class: FrameClass, message: &M) -> EnqueueResult { - self.sink.send(class, self.encoder.custom(message)) + pub fn send_message>( + &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 Schedule { 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 Schedule { 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; diff --git a/firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml b/firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml index a897cc2..8f52831 100644 --- a/firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-diagnostics-mavlink/Cargo.toml @@ -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 } diff --git a/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs b/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs index 3c2a0c7..1ed9683 100644 --- a/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs +++ b/firmware/libs/kaptan-diagnostics-mavlink/src/lib.rs @@ -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 { + 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::()); + 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]); } } diff --git a/firmware/libs/kaptan-config-mavlink/Cargo.toml b/firmware/libs/kaptan-file-mavlink/Cargo.toml similarity index 55% rename from firmware/libs/kaptan-config-mavlink/Cargo.toml rename to firmware/libs/kaptan-file-mavlink/Cargo.toml index 9bdc36b..2b03ddc 100644 --- a/firmware/libs/kaptan-config-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-file-mavlink/Cargo.toml @@ -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 } diff --git a/firmware/libs/kaptan-file-mavlink/src/lib.rs b/firmware/libs/kaptan-file-mavlink/src/lib.rs new file mode 100644 index 0000000..ad04161 --- /dev/null +++ b/firmware/libs/kaptan-file-mavlink/src/lib.rs @@ -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; diff --git a/firmware/libs/kaptan-file-mavlink/src/protocol.rs b/firmware/libs/kaptan-file-mavlink/src/protocol.rs new file mode 100644 index 0000000..c3d6bef --- /dev/null +++ b/firmware/libs/kaptan-file-mavlink/src/protocol.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/firmware/libs/kaptan-file-mavlink/src/service.rs b/firmware/libs/kaptan-file-mavlink/src/service.rs new file mode 100644 index 0000000..4c83ac7 --- /dev/null +++ b/firmware/libs/kaptan-file-mavlink/src/service.rs @@ -0,0 +1,1334 @@ +//! One-slot stop-and-wait transfer state machine. + +use crate::{ + CONTENT_BYTES, Content, ControlRequest, ControlResponse, DATA_BYTES, DataRequest, DataResponse, + FileFailure, FileMetadata, FileOperation, FilePath, FileRequest, FileResponse, FileStatus, + Principal, Target, +}; + +/// Format-agnostic owner of the one file currently pinned or staged. +/// +/// The service calls `abort` after any provider failure, explicit abort, lease +/// expiry, or protocol conflict that invalidates the transfer. +/// Reads must fill the complete supplied slice from the pinned snapshot. +pub trait FileProvider { + /// Pins one stable readable file and returns its exact metadata. + fn open_read(&mut self, path: &FilePath) -> Result; + + /// Opens bounded staging for one complete write. + fn open_write(&mut self, path: &FilePath, metadata: FileMetadata) -> Result<(), FileFailure>; + + /// Reads exactly `output.len()` bytes at `position` from the pinned file. + fn read(&mut self, position: u32, output: &mut [u8]) -> Result<(), FileFailure>; + + /// Appends one previously unaccepted sequential chunk. + fn write(&mut self, position: u32, data: &[u8]) -> Result<(), FileFailure>; + + /// Completes and, for writes, atomically applies the whole file. + fn close(&mut self) -> Result; + + /// Releases pinned or staged provider state without applying it. + fn abort(&mut self); +} + +/// Incremental CRC-32C used to verify a complete sequential upload. +struct Crc32c(u32); + +impl Crc32c { + /// Starts the canonical reflected CRC-32C calculation. + const fn new() -> Self { + Self(!0) + } + + /// Incorporates the next sequential byte span. + fn update(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 ^= u32::from(*byte); + for _ in 0..8 { + self.0 = (self.0 >> 1) ^ (0x82f6_3b78 & 0_u32.wrapping_sub(self.0 & 1)); + } + } + } + + /// Final checksum for all bytes supplied so far. + const fn checksum(&self) -> u32 { + !self.0 + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Mode { + Read, + Write, +} + +#[derive(Clone, Copy)] +struct LastChunk { + position: u32, + data: [u8; DATA_BYTES], +} + +struct Session { + owner: Principal, + transfer_id: u16, + path: FilePath, + mode: Mode, + metadata: FileMetadata, + position: u32, + checksum: Crc32c, + last_chunk: Option, +} + +#[derive(Clone, Copy)] +struct Completed { + owner: Principal, + transfer_id: u16, + request: FileRequest, + response: ControlResponse, +} + +enum TransferState { + Idle, + Open(Session), + Completed(Completed), +} + +/// One bounded stop-and-wait transfer slot. +pub struct FileService { + target: Target, + state: TransferState, +} + +impl FileService { + /// Creates an idle service for one exact endpoint. + pub const fn new(target: Target) -> Self { + Self { + target, + state: TransferState::Idle, + } + } + + /// Handles one already integrity-checked request. + /// + /// Untargeted requests are silently discarded. The caller may retry a + /// response whose transmission was not admitted: every state-changing + /// request has an exact bounded replay path. + pub fn dispatch( + &mut self, + provider: &mut P, + principal: Principal, + request: FileRequest, + ) -> Option { + let addressed = match request { + FileRequest::Control(request) => request.target, + FileRequest::Data(request) => request.target, + }; + if self.target != addressed { + return None; + } + Some(match request { + FileRequest::Control(request) if request.operation == FileOperation::Read => { + self.read(provider, principal, request) + } + FileRequest::Control(request) => { + FileResponse::Control(self.control(provider, principal, request)) + } + FileRequest::Data(request) => self.data(provider, principal, request), + }) + } + + /// Aborts the open transfer, if any, and clears replay state. + pub fn expire(&mut self, provider: &mut P) -> bool { + let expired = matches!(self.state, TransferState::Open(_)); + if expired { + provider.abort(); + } + self.state = TransferState::Idle; + expired + } + + fn control( + &mut self, + provider: &mut P, + principal: Principal, + request: ControlRequest, + ) -> ControlResponse { + match request.operation { + FileOperation::OpenRead => self.open(provider, principal, request, Mode::Read), + FileOperation::OpenWrite => self.open(provider, principal, request, Mode::Write), + FileOperation::Read => unreachable!("read requests are dispatched as data responses"), + FileOperation::Close => self.close(provider, principal, request), + FileOperation::Abort => self.abort(provider, principal, request), + FileOperation::WriteAck => { + self.failure(principal, request, FileStatus::InvalidState, Content::EMPTY) + } + } + } + + fn open( + &mut self, + provider: &mut P, + principal: Principal, + request: ControlRequest, + mode: Mode, + ) -> ControlResponse { + if request.transfer_id == 0 || request.position != 0 { + return self.failure( + principal, + request, + FileStatus::InvalidTransfer, + Content::EMPTY, + ); + } + if mode == Mode::Read && (request.file_size != 0 || request.checksum_crc32c != 0) { + return self.failure(principal, request, FileStatus::InvalidState, Content::EMPTY); + } + let Some(path) = FilePath::try_from_content(request.content) else { + return self.failure(principal, request, FileStatus::InvalidPath, Content::EMPTY); + }; + + let replace_open = if let TransferState::Open(session) = &self.state { + if session.owner != principal { + return self.failure(principal, request, FileStatus::Busy, Content::EMPTY); + } + let same = session.transfer_id == request.transfer_id + && session.position == 0 + && session.mode == mode + && session.path == path + && (mode == Mode::Read + || session.metadata + == (FileMetadata { + size: request.file_size, + checksum_crc32c: request.checksum_crc32c, + })); + if same { + return self.open_response(principal, request.operation, session); + } + if session.transfer_id == request.transfer_id { + return self.failure( + principal, + request, + FileStatus::RequestConflict, + Content::EMPTY, + ); + } + if session.position != 0 { + return self.failure( + principal, + request, + FileStatus::RequestConflict, + Content::EMPTY, + ); + } + true + } else { + false + }; + if replace_open { + provider.abort(); + self.state = TransferState::Idle; + } + if let TransferState::Completed(completed) = &self.state { + if completed.owner != principal { + return self.failure(principal, request, FileStatus::Busy, Content::EMPTY); + } + if completed.transfer_id == request.transfer_id { + return self.failure( + principal, + request, + FileStatus::RequestConflict, + Content::EMPTY, + ); + } + } + + let requested = FileMetadata { + size: request.file_size, + checksum_crc32c: request.checksum_crc32c, + }; + let opened = match mode { + Mode::Read => provider.open_read(&path), + Mode::Write => provider.open_write(&path, requested).map(|()| requested), + }; + let metadata = match opened { + Ok(metadata) => metadata, + Err(error) => { + provider.abort(); + return self.failure(principal, request, error.status(), error.content()); + } + }; + self.state = TransferState::Open(Session { + owner: principal, + transfer_id: request.transfer_id, + path, + mode, + metadata, + position: 0, + checksum: Crc32c::new(), + last_chunk: None, + }); + let TransferState::Open(session) = &self.state else { + unreachable!("session was installed"); + }; + self.open_response(principal, request.operation, session) + } + + fn read( + &mut self, + provider: &mut P, + principal: Principal, + request: ControlRequest, + ) -> FileResponse { + if !canonical_non_open(&request) { + return FileResponse::Control(self.failure( + principal, + request, + FileStatus::InvalidState, + Content::EMPTY, + )); + } + let TransferState::Open(session) = &mut self.state else { + return FileResponse::Control(self.replay_or_failure(principal, request)); + }; + if session.owner != principal || session.transfer_id != request.transfer_id { + return FileResponse::Control(response( + principal, + request.operation, + request.transfer_id, + request.position, + FileMetadata { + size: request.file_size, + checksum_crc32c: request.checksum_crc32c, + }, + FileStatus::InvalidTransfer, + Content::EMPTY, + )); + } + if session.mode != Mode::Read { + return FileResponse::Control(response( + principal, + request.operation, + request.transfer_id, + request.position, + session.metadata, + FileStatus::InvalidState, + Content::EMPTY, + )); + } + if request.position != session.position { + if let Some(last) = session + .last_chunk + .as_ref() + .filter(|last| last.position == request.position) + { + return FileResponse::Data(DataResponse { + target: principal.target(), + position: request.position, + transfer_id: request.transfer_id, + data: last.data, + }); + } + return FileResponse::Control(response( + principal, + request.operation, + request.transfer_id, + request.position, + session.metadata, + FileStatus::InvalidPosition, + Content::EMPTY, + )); + } + let Some(logical) = chunk_length(session.metadata.size, request.position) else { + return FileResponse::Control(response( + principal, + request.operation, + request.transfer_id, + request.position, + session.metadata, + FileStatus::InvalidPosition, + Content::EMPTY, + )); + }; + if logical == 0 { + return FileResponse::Control(response( + principal, + request.operation, + request.transfer_id, + request.position, + session.metadata, + FileStatus::InvalidPosition, + Content::EMPTY, + )); + } + let mut data = [0_u8; DATA_BYTES]; + if let Err(error) = provider.read(request.position, &mut data[..logical]) { + let metadata = session.metadata; + provider.abort(); + let response = response( + principal, + request.operation, + request.transfer_id, + request.position, + metadata, + error.status(), + error.content(), + ); + self.remember(principal, FileRequest::Control(request), response); + return FileResponse::Control(response); + } + session.last_chunk = Some(LastChunk { + position: request.position, + data, + }); + session.position += logical as u32; + FileResponse::Data(DataResponse { + target: principal.target(), + position: request.position, + transfer_id: request.transfer_id, + data, + }) + } + + fn data( + &mut self, + provider: &mut P, + principal: Principal, + request: DataRequest, + ) -> FileResponse { + let TransferState::Open(session) = &mut self.state else { + return FileResponse::Control(self.replay_data_or_failure(principal, request)); + }; + if session.owner != principal || session.transfer_id != request.transfer_id { + return FileResponse::Control(data_failure( + principal, + request, + FileStatus::InvalidTransfer, + )); + } + if session.mode != Mode::Write { + return FileResponse::Control(data_failure( + principal, + request, + FileStatus::InvalidState, + )); + } + let logical = match chunk_length(session.metadata.size, request.position) { + Some(length) if length != 0 => length, + _ => { + return FileResponse::Control(data_failure( + principal, + request, + FileStatus::InvalidPosition, + )); + } + }; + if request.data[logical..].iter().any(|byte| *byte != 0) { + return FileResponse::Control(data_failure( + principal, + request, + FileStatus::RequestConflict, + )); + } + if request.position < session.position { + let replayed = session + .last_chunk + .as_ref() + .is_some_and(|last| last.position == request.position && last.data == request.data); + return FileResponse::Control(if replayed { + write_ack(principal, session) + } else { + data_failure(principal, request, FileStatus::RequestConflict) + }); + } + if request.position != session.position { + return FileResponse::Control(data_failure( + principal, + request, + FileStatus::InvalidPosition, + )); + } + if let Err(error) = provider.write(request.position, &request.data[..logical]) { + provider.abort(); + let response = data_provider_failure(principal, request, error); + self.remember(principal, FileRequest::Data(request), response); + return FileResponse::Control(response); + } + session.checksum.update(&request.data[..logical]); + session.position += logical as u32; + session.last_chunk = Some(LastChunk { + position: request.position, + data: request.data, + }); + FileResponse::Control(write_ack(principal, session)) + } + + fn close( + &mut self, + provider: &mut P, + principal: Principal, + request: ControlRequest, + ) -> ControlResponse { + if !canonical_non_open(&request) { + return self.failure(principal, request, FileStatus::InvalidState, Content::EMPTY); + } + let TransferState::Open(session) = &self.state else { + return self.replay_or_failure(principal, request); + }; + if session.owner != principal || session.transfer_id != request.transfer_id { + return self.failure( + principal, + request, + FileStatus::InvalidTransfer, + Content::EMPTY, + ); + } + if request.position != session.position || session.position != session.metadata.size { + return self.failure( + principal, + request, + FileStatus::InvalidPosition, + Content::EMPTY, + ); + } + if session.mode == Mode::Write + && session.checksum.checksum() != session.metadata.checksum_crc32c + { + provider.abort(); + let response = self.failure( + principal, + request, + FileStatus::ChecksumMismatch, + Content::EMPTY, + ); + self.remember(principal, FileRequest::Control(request), response); + return response; + } + let completion = provider.close(); + let response = match completion { + Ok(completion) => response( + principal, + request.operation, + request.transfer_id, + request.position, + session.metadata, + FileStatus::Ok, + completion, + ), + Err(error) => { + provider.abort(); + response( + principal, + request.operation, + request.transfer_id, + request.position, + session.metadata, + error.status(), + error.content(), + ) + } + }; + self.remember(principal, FileRequest::Control(request), response); + response + } + + fn abort( + &mut self, + provider: &mut P, + principal: Principal, + request: ControlRequest, + ) -> ControlResponse { + if !canonical_non_open(&request) { + return self.failure(principal, request, FileStatus::InvalidState, Content::EMPTY); + } + let TransferState::Open(session) = &self.state else { + return self.replay_or_failure(principal, request); + }; + if session.owner != principal || session.transfer_id != request.transfer_id { + return self.failure( + principal, + request, + FileStatus::InvalidTransfer, + Content::EMPTY, + ); + } + let metadata = session.metadata; + provider.abort(); + let response = response( + principal, + request.operation, + request.transfer_id, + request.position, + metadata, + FileStatus::Ok, + Content::EMPTY, + ); + self.remember(principal, FileRequest::Control(request), response); + response + } + + fn open_response( + &self, + principal: Principal, + operation: FileOperation, + session: &Session, + ) -> ControlResponse { + response( + principal, + operation, + session.transfer_id, + session.position, + session.metadata, + FileStatus::Ok, + Content::EMPTY, + ) + } + + fn failure( + &self, + principal: Principal, + request: ControlRequest, + status: FileStatus, + content: Content, + ) -> ControlResponse { + response( + principal, + request.operation, + request.transfer_id, + request.position, + FileMetadata { + size: request.file_size, + checksum_crc32c: request.checksum_crc32c, + }, + status, + content, + ) + } + + fn replay_or_failure(&self, principal: Principal, request: ControlRequest) -> ControlResponse { + match &self.state { + TransferState::Completed(completed) + if completed.owner == principal + && completed.transfer_id == request.transfer_id + && completed.request == FileRequest::Control(request) => + { + completed.response + } + TransferState::Completed(completed) + if completed.owner == principal && completed.transfer_id == request.transfer_id => + { + self.failure( + principal, + request, + FileStatus::RequestConflict, + Content::EMPTY, + ) + } + TransferState::Idle | TransferState::Open(_) | TransferState::Completed(_) => self + .failure( + principal, + request, + FileStatus::InvalidTransfer, + Content::EMPTY, + ), + } + } + + fn replay_data_or_failure( + &self, + principal: Principal, + request: DataRequest, + ) -> ControlResponse { + match &self.state { + TransferState::Completed(completed) + if completed.owner == principal + && completed.transfer_id == request.transfer_id + && completed.request == FileRequest::Data(request) => + { + completed.response + } + TransferState::Completed(completed) + if completed.owner == principal && completed.transfer_id == request.transfer_id => + { + data_failure(principal, request, FileStatus::RequestConflict) + } + TransferState::Idle | TransferState::Open(_) | TransferState::Completed(_) => { + data_failure(principal, request, FileStatus::InvalidTransfer) + } + } + } + + fn remember(&mut self, owner: Principal, request: FileRequest, response: ControlResponse) { + let transfer_id = match request { + FileRequest::Control(request) => request.transfer_id, + FileRequest::Data(request) => request.transfer_id, + }; + self.state = TransferState::Completed(Completed { + owner, + transfer_id, + request, + response, + }); + } +} + +fn canonical_non_open(request: &ControlRequest) -> bool { + request.file_size == 0 && request.checksum_crc32c == 0 && request.content.is_empty() +} + +fn chunk_length(file_size: u32, position: u32) -> Option { + file_size + .checked_sub(position) + .map(|remaining| remaining.min(DATA_BYTES as u32) as usize) +} + +/// Calculates the CRC-32C used by complete file metadata. +pub fn crc32c(bytes: &[u8]) -> u32 { + let mut checksum = Crc32c::new(); + checksum.update(bytes); + checksum.checksum() +} + +fn response( + principal: Principal, + operation: FileOperation, + transfer_id: u16, + position: u32, + metadata: FileMetadata, + status: FileStatus, + content: Content, +) -> ControlResponse { + ControlResponse { + operation, + target: principal.target(), + position, + file_size: metadata.size, + checksum_crc32c: metadata.checksum_crc32c, + transfer_id, + status, + content, + } +} + +fn data_failure(principal: Principal, request: DataRequest, status: FileStatus) -> ControlResponse { + response( + principal, + FileOperation::WriteAck, + request.transfer_id, + request.position, + FileMetadata { + size: 0, + checksum_crc32c: 0, + }, + status, + Content::EMPTY, + ) +} + +fn data_provider_failure( + principal: Principal, + request: DataRequest, + error: FileFailure, +) -> ControlResponse { + let mut response = data_failure(principal, request, error.status()); + response.content = error.content(); + response +} + +fn write_ack(principal: Principal, session: &Session) -> ControlResponse { + response( + principal, + FileOperation::WriteAck, + session.transfer_id, + session.position, + session.metadata, + FileStatus::Ok, + Content::EMPTY, + ) +} + +const _: () = assert!(CONTENT_BYTES <= u8::MAX as usize); + +#[cfg(test)] +mod tests { + use super::*; + + const SERVICE: Target = Target::new(1, 1); + const CLIENT: Principal = Principal::new(42, 7); + const PATH: &[u8] = b"/test/data.bin"; + + #[derive(Clone, Copy, Eq, PartialEq)] + enum ProviderMode { + Idle, + Read, + Write, + } + + struct Provider { + mode: ProviderMode, + bytes: [u8; 500], + size: usize, + reads: u8, + writes: u8, + aborts: u8, + applied: bool, + fail_open: bool, + fail_read: bool, + fail_write: bool, + } + + impl Provider { + fn readable(bytes: &[u8]) -> Self { + let mut provider = Self { + mode: ProviderMode::Idle, + bytes: [0; 500], + size: bytes.len(), + reads: 0, + writes: 0, + aborts: 0, + applied: false, + fail_open: false, + fail_read: false, + fail_write: false, + }; + provider.bytes[..bytes.len()].copy_from_slice(bytes); + provider + } + } + + impl FileProvider for Provider { + fn open_read(&mut self, path: &FilePath) -> Result { + assert_eq!(path.as_bytes(), PATH); + self.mode = ProviderMode::Read; + if self.fail_open { + return Err(FileFailure::new(FileStatus::Internal, Content::EMPTY).unwrap()); + } + Ok(FileMetadata { + size: self.size as u32, + checksum_crc32c: crc32c(&self.bytes[..self.size]), + }) + } + + fn open_write( + &mut self, + path: &FilePath, + metadata: FileMetadata, + ) -> Result<(), FileFailure> { + assert_eq!(path.as_bytes(), PATH); + self.mode = ProviderMode::Write; + if self.fail_open { + return Err(FileFailure::new(FileStatus::Internal, Content::EMPTY).unwrap()); + } + if metadata.size as usize > self.bytes.len() { + return Err(FileFailure::new(FileStatus::TooLarge, Content::EMPTY).unwrap()); + } + self.size = 0; + Ok(()) + } + + fn read(&mut self, position: u32, output: &mut [u8]) -> Result<(), FileFailure> { + assert!(self.mode == ProviderMode::Read); + self.reads += 1; + if self.fail_read { + return Err(FileFailure::new( + FileStatus::Internal, + Content::try_new(b"read failed").unwrap(), + ) + .unwrap()); + } + let start = position as usize; + output.copy_from_slice(&self.bytes[start..start + output.len()]); + Ok(()) + } + + fn write(&mut self, position: u32, data: &[u8]) -> Result<(), FileFailure> { + assert!(self.mode == ProviderMode::Write); + if self.fail_write { + return Err(FileFailure::new( + FileStatus::ContentRejected, + Content::try_new(b"write rejected").unwrap(), + ) + .unwrap()); + } + assert_eq!(position as usize, self.size); + self.bytes[self.size..self.size + data.len()].copy_from_slice(data); + self.size += data.len(); + self.writes += 1; + Ok(()) + } + + fn close(&mut self) -> Result { + self.mode = ProviderMode::Idle; + self.applied = true; + Ok(Content::EMPTY) + } + + fn abort(&mut self) { + self.mode = ProviderMode::Idle; + self.aborts += 1; + } + } + + fn open(operation: FileOperation, transfer_id: u16, metadata: FileMetadata) -> FileRequest { + FileRequest::Control(ControlRequest { + operation, + target: SERVICE, + position: 0, + file_size: metadata.size, + checksum_crc32c: metadata.checksum_crc32c, + transfer_id, + content: Content::try_new(PATH).unwrap(), + }) + } + + fn control(operation: FileOperation, transfer_id: u16, position: u32) -> FileRequest { + FileRequest::Control(ControlRequest { + operation, + target: SERVICE, + position, + file_size: 0, + checksum_crc32c: 0, + transfer_id, + content: Content::EMPTY, + }) + } + + fn data(transfer_id: u16, position: u32, bytes: &[u8]) -> FileRequest { + let mut data = [0_u8; DATA_BYTES]; + data[..bytes.len()].copy_from_slice(bytes); + FileRequest::Data(DataRequest { + target: SERVICE, + position, + transfer_id, + data, + }) + } + + fn control_response(response: Option) -> ControlResponse { + let Some(FileResponse::Control(response)) = response else { + panic!("expected control response"); + }; + response + } + + #[test] + fn crc32c_matches_the_standard_check_value() { + assert_eq!(crc32c(b"123456789"), 0xe306_9283); + } + + #[test] + fn mutually_exclusive_transfer_state_stays_compact() { + // Exact retry identity costs one maximum wire chunk. Keep the complete + // one-slot service within a deliberate two-chunk SRAM budget. + assert!(core::mem::size_of::() <= 2 * DATA_BYTES); + } + + #[test] + fn canonical_paths_reject_aliases_and_non_ascii_bytes() { + assert!(FilePath::try_from_content(Content::try_new(PATH).unwrap()).is_some()); + for invalid in [ + b"test/data.bin".as_slice(), + b"/test//data.bin", + b"/test/../data.bin", + b"/test/data.bin/", + b"/test/data bin", + ] { + assert!( + FilePath::try_from_content(Content::try_new(invalid).unwrap()).is_none(), + "accepted {invalid:?}" + ); + } + } + + #[test] + fn read_is_sequential_and_immediate_retry_is_replayed() { + let source = [0x5a; 300]; + let mut provider = Provider::readable(&source); + let mut service = FileService::new(SERVICE); + let opened = control_response(service.dispatch( + &mut provider, + CLIENT, + open( + FileOperation::OpenRead, + 9, + FileMetadata { + size: 0, + checksum_crc32c: 0, + }, + ), + )); + assert_eq!(opened.status, FileStatus::Ok); + assert_eq!(opened.file_size, 300); + + let first = service.dispatch(&mut provider, CLIENT, control(FileOperation::Read, 9, 0)); + let Some(FileResponse::Data(first)) = first else { + panic!("expected data"); + }; + assert_eq!(&first.data, &[0x5a; DATA_BYTES]); + + let retry = service.dispatch(&mut provider, CLIENT, control(FileOperation::Read, 9, 0)); + assert_eq!(retry, Some(FileResponse::Data(first))); + assert_eq!(provider.reads, 1); + + let Some(FileResponse::Data(last)) = service.dispatch( + &mut provider, + CLIENT, + control(FileOperation::Read, 9, DATA_BYTES as u32), + ) else { + panic!("expected final data"); + }; + assert_eq!(&last.data[..54], &source[DATA_BYTES..]); + assert!(last.data[54..].iter().all(|byte| *byte == 0)); + } + + #[test] + fn a_progressed_transfer_rejects_every_open_until_its_lease_expires() { + let source = [0x5a; 300]; + let metadata = FileMetadata { + size: 0, + checksum_crc32c: 0, + }; + let mut provider = Provider::readable(&source); + let mut service = FileService::new(SERVICE); + + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 1, metadata), + )) + .status, + FileStatus::Ok + ); + assert!(matches!( + service.dispatch(&mut provider, CLIENT, control(FileOperation::Read, 1, 0),), + Some(FileResponse::Data(_)) + )); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 1, metadata), + )) + .status, + FileStatus::RequestConflict + ); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 2, metadata), + )) + .status, + FileStatus::RequestConflict + ); + assert_eq!(provider.aborts, 0); + + assert!(service.expire(&mut provider)); + let reopened = control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 2, metadata), + )); + assert_eq!(reopened.status, FileStatus::Ok); + assert_eq!(reopened.position, 0); + assert_eq!(provider.aborts, 1); + + assert!(matches!( + service.dispatch(&mut provider, CLIENT, control(FileOperation::Read, 2, 0)), + Some(FileResponse::Data(_)) + )); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 1, metadata), + )) + .status, + FileStatus::RequestConflict, + "a delayed old open must not replace a later progressed transfer" + ); + assert_eq!(provider.aborts, 1); + } + + #[test] + fn a_failed_provider_open_is_always_aborted() { + let mut provider = Provider::readable(&[]); + provider.fail_open = true; + let mut service = FileService::new(SERVICE); + let metadata = FileMetadata { + size: 0, + checksum_crc32c: 0, + }; + + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 1, metadata), + )) + .status, + FileStatus::Internal + ); + assert!(provider.mode == ProviderMode::Idle); + assert_eq!(provider.aborts, 1); + + provider.fail_open = false; + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 2, metadata), + )) + .status, + FileStatus::Ok + ); + } + + #[test] + fn provider_read_failure_is_replayed_exactly() { + let mut provider = Provider::readable(&[0x5a; 20]); + provider.fail_read = true; + let mut service = FileService::new(SERVICE); + let metadata = FileMetadata { + size: 0, + checksum_crc32c: 0, + }; + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenRead, 3, metadata), + )) + .status, + FileStatus::Ok + ); + let request = control(FileOperation::Read, 3, 0); + let failed = control_response(service.dispatch(&mut provider, CLIENT, request)); + assert_eq!(failed.status, FileStatus::Internal); + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, request)), + failed + ); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + control(FileOperation::Read, 3, 1), + )) + .status, + FileStatus::RequestConflict + ); + assert_eq!(provider.aborts, 1); + } + + #[test] + fn provider_write_failure_is_replayed_only_for_identical_data() { + let accepted = [0x39; DATA_BYTES]; + let metadata = FileMetadata { + size: DATA_BYTES as u32, + checksum_crc32c: crc32c(&accepted), + }; + let mut provider = Provider::readable(&[]); + provider.fail_write = true; + let mut service = FileService::new(SERVICE); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenWrite, 4, metadata), + )) + .status, + FileStatus::Ok + ); + let request = data(4, 0, &accepted); + let failed = control_response(service.dispatch(&mut provider, CLIENT, request)); + assert_eq!(failed.status, FileStatus::ContentRejected); + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, request)), + failed + ); + let mut conflicting = accepted; + conflicting[0] ^= 1; + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, data(4, 0, &conflicting),)) + .status, + FileStatus::RequestConflict + ); + assert_eq!(provider.aborts, 1); + } + + #[test] + fn duplicate_write_replays_ack_and_conflict_is_rejected() { + let expected = [0x39; 300]; + let metadata = FileMetadata { + size: expected.len() as u32, + checksum_crc32c: crc32c(&expected), + }; + let mut provider = Provider::readable(&[]); + let mut service = FileService::new(SERVICE); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenWrite, 10, metadata), + )) + .status, + FileStatus::Ok + ); + + let first = data(10, 0, &expected[..DATA_BYTES]); + let first_ack = control_response(service.dispatch(&mut provider, CLIENT, first)); + assert_eq!(first_ack.operation, FileOperation::WriteAck); + assert_eq!(first_ack.position, DATA_BYTES as u32); + assert_eq!(provider.writes, 1); + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, first)), + first_ack + ); + assert_eq!(provider.writes, 1); + + let mut conflict = [0x39; DATA_BYTES]; + conflict[17] = 0x40; + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, data(10, 0, &conflict),)) + .status, + FileStatus::RequestConflict + ); + assert_eq!(provider.writes, 1); + + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + data(10, DATA_BYTES as u32, &expected[DATA_BYTES..]), + )) + .position, + expected.len() as u32 + ); + let closed = control_response(service.dispatch( + &mut provider, + CLIENT, + control(FileOperation::Close, 10, expected.len() as u32), + )); + assert_eq!(closed.status, FileStatus::Ok); + assert!(provider.applied); + assert_eq!(&provider.bytes[..expected.len()], &expected); + + let replayed = control_response(service.dispatch( + &mut provider, + CLIENT, + control(FileOperation::Close, 10, expected.len() as u32), + )); + assert_eq!(replayed, closed); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + control(FileOperation::Close, 10, expected.len() as u32 - 1), + )) + .status, + FileStatus::RequestConflict + ); + + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenWrite, 10, metadata), + )) + .status, + FileStatus::RequestConflict + ); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenWrite, 11, metadata), + )) + .status, + FileStatus::Ok + ); + provider.applied = false; + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + control(FileOperation::Close, 10, expected.len() as u32), + )) + .status, + FileStatus::InvalidTransfer + ); + assert!( + !provider.applied, + "delayed close must not apply the new transfer" + ); + } + + #[test] + fn checksum_collision_is_not_accepted_as_the_same_write() { + fn collision_data(seed: u32) -> [u8; DATA_BYTES] { + let mut state = seed; + let mut data = [0_u8; DATA_BYTES]; + for byte in &mut data { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + *byte = (state >> 24) as u8; + } + data + } + + let accepted = collision_data(14_229); + let conflicting = collision_data(21_800); + assert_ne!(accepted, conflicting); + assert_eq!(crc32c(&accepted), 0xf39a_40e7); + assert_eq!(crc32c(&conflicting), 0xf39a_40e7); + + let metadata = FileMetadata { + size: DATA_BYTES as u32, + checksum_crc32c: crc32c(&accepted), + }; + let mut provider = Provider::readable(&[]); + let mut service = FileService::new(SERVICE); + assert_eq!( + control_response(service.dispatch( + &mut provider, + CLIENT, + open(FileOperation::OpenWrite, 12, metadata), + )) + .status, + FileStatus::Ok + ); + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, data(12, 0, &accepted))) + .status, + FileStatus::Ok + ); + assert_eq!( + control_response(service.dispatch(&mut provider, CLIENT, data(12, 0, &conflicting))) + .status, + FileStatus::RequestConflict + ); + assert_eq!(provider.writes, 1); + } + + #[test] + fn untargeted_request_is_silently_dropped() { + let mut provider = Provider::readable(&[]); + let mut service = FileService::new(SERVICE); + let request = FileRequest::Control(ControlRequest { + target: Target::new(2, 1), + ..match open( + FileOperation::OpenRead, + 1, + FileMetadata { + size: 0, + checksum_crc32c: 0, + }, + ) { + FileRequest::Control(request) => request, + FileRequest::Data(_) => unreachable!(), + } + }); + assert_eq!(service.dispatch(&mut provider, CLIENT, request), None); + assert!(provider.mode == ProviderMode::Idle); + } +} diff --git a/firmware/libs/kaptan-file-mavlink/src/wire.rs b/firmware/libs/kaptan-file-mavlink/src/wire.rs new file mode 100644 index 0000000..93abea9 --- /dev/null +++ b/firmware/libs/kaptan-file-mavlink/src/wire.rs @@ -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 { + 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 { + 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::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)); + } +} diff --git a/firmware/libs/kaptan-flight-config/src/cbor.rs b/firmware/libs/kaptan-flight-config/src/cbor.rs index 159f50e..ab5fd09 100644 --- a/firmware/libs/kaptan-flight-config/src/cbor.rs +++ b/firmware/libs/kaptan-flight-config/src/cbor.rs @@ -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 { 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 diff --git a/firmware/libs/kaptan-flight-config/src/profile/candidate.rs b/firmware/libs/kaptan-flight-config/src/profile/candidate.rs index 98f97cc..f6ed456 100644 --- a/firmware/libs/kaptan-flight-config/src/profile/candidate.rs +++ b/firmware/libs/kaptan-flight-config/src/profile/candidate.rs @@ -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 { @@ -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() ); } } diff --git a/firmware/libs/kaptan-flight-runtime/src/lib.rs b/firmware/libs/kaptan-flight-runtime/src/lib.rs index bcc1cf3..feb68f7 100644 --- a/firmware/libs/kaptan-flight-runtime/src/lib.rs +++ b/firmware/libs/kaptan-flight-runtime/src/lib.rs @@ -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 { @@ -45,20 +48,9 @@ where &self.configuration } - /// Mutably borrows the sole configuration manager for protocol dispatch. - pub fn configuration_mut(&mut self) -> &mut ConfigManager { - &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 { - 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 { 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 for Store { type Error = (); - fn load(&mut self) -> Result, Self::Error> { + fn load( + &mut self, + _: &kaptan_flight_config::CandidateProfile, + ) -> Result, 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 + ); } } diff --git a/firmware/libs/kaptan-log/src/facade.rs b/firmware/libs/kaptan-log/src/facade.rs index 1339cfc..d5c000b 100644 --- a/firmware/libs/kaptan-log/src/facade.rs +++ b/firmware/libs/kaptan-log/src/facade.rs @@ -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 = LogIngress::new(); @@ -33,11 +33,11 @@ impl LogIngress { } } - 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 LogIngress { /// 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(()) ); } diff --git a/firmware/libs/kaptan-log/src/hub.rs b/firmware/libs/kaptan-log/src/hub.rs index aff21e2..42949ff 100644 --- a/firmware/libs/kaptan-log/src/hub.rs +++ b/firmware/libs/kaptan-log/src/hub.rs @@ -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 Lane { } } - 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 { @@ -89,30 +72,10 @@ impl LogSinkQueue 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 LogSinkQueue::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(()) diff --git a/firmware/libs/kaptan-log/src/level.rs b/firmware/libs/kaptan-log/src/level.rs index b04fedf..f0a0a67 100644 --- a/firmware/libs/kaptan-log/src/level.rs +++ b/firmware/libs/kaptan-log/src/level.rs @@ -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::(), 1); assert_eq!(size_of::(), 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)); diff --git a/firmware/libs/kaptan-log/src/lib.rs b/firmware/libs/kaptan-log/src/lib.rs index 98dc509..6220b43 100644 --- a/firmware/libs/kaptan-log/src/lib.rs +++ b/firmware/libs/kaptan-log/src/lib.rs @@ -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)+)) }; } diff --git a/firmware/libs/kaptan-log/src/report.rs b/firmware/libs/kaptan-log/src/report.rs index 0085a82..630465a 100644 --- a/firmware/libs/kaptan-log/src/report.rs +++ b/firmware/libs/kaptan-log/src/report.rs @@ -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 diff --git a/firmware/libs/kaptan-log/src/text.rs b/firmware/libs/kaptan-log/src/text.rs index 33f2a9f..b057ff1 100644 --- a/firmware/libs/kaptan-log/src/text.rs +++ b/firmware/libs/kaptan-log/src/text.rs @@ -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 + ); } } diff --git a/firmware/libs/kaptan-mavlink/Cargo.toml b/firmware/libs/kaptan-mavlink/Cargo.toml index 70c0666..aa4809c 100644 --- a/firmware/libs/kaptan-mavlink/Cargo.toml +++ b/firmware/libs/kaptan-mavlink/Cargo.toml @@ -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" diff --git a/firmware/libs/kaptan-mavlink/build.rs b/firmware/libs/kaptan-mavlink/build.rs index 4037209..eff0bb9 100644 --- a/firmware/libs/kaptan-mavlink/build.rs +++ b/firmware/libs/kaptan-mavlink/build.rs @@ -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::XmlDefinitions::Files(vec![dialect]), - output_directory, - ) - .unwrap(); - mavlink_bindgen::emit_cargo_build_messages(&generated); - println!("cargo::rustc-cfg=kaptan_has_messages"); - } + let output_directory = std::env::var("OUT_DIR").unwrap(); + mavlink_bindgen::generate( + mavlink_bindgen::XmlDefinitions::Files(vec![dialect]), + &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(); } diff --git a/firmware/libs/kaptan-mavlink/definitions/exchange/.gitkeep b/firmware/libs/kaptan-mavlink/definitions/exchange/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan.xml index 1df5e7c..0375020 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan.xml +++ b/firmware/libs/kaptan-mavlink/definitions/kaptan.xml @@ -1,8 +1,13 @@ - - kaptan/config.xml - kaptan/log.xml - kaptan/error.xml + + publish/log.xml + publish/error.xml + publish/heartbeat.xml + publish/imu.xml + publish/range.xml + publish/optical_flow.xml + transfer/file_control.xml + transfer/file_data.xml diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml b/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml deleted file mode 100644 index 2aa81c0..0000000 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/config.xml +++ /dev/null @@ -1,237 +0,0 @@ - - - 3 - 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - The target configuration lease or transaction slot belongs to a client. - - - - - - - - - - - - - - - - - - - - - Correlated configuration protocol and schema metadata. - - - - - - - - - Requests correlated configuration protocol and schema metadata. - - - - - - Requests one field descriptor by stable schema index. - - - - - - - One self-describing editable scalar field. - - - - - - - Number of named enum choices available for indexed retrieval. - - - - Bit 0 marks minimum_bits present; bit 1 marks maximum_bits present. Other bits are reserved. - - - One named choice for the explicitly identified enum-backed descriptor. - - - - - - - - - Requests one named choice by descriptor and option index. - - - - - - - - Reads one active, committed, or transaction value. - - - - - - - - - One typed configuration scalar as canonical raw bits. - - - - - - - - - Opens the one lease-bound edit transaction. - - - - - - Writes one f32 field in the open transaction. - - - - - - - - - - Writes one i32 field in the open transaction. - - - - - - - - - - Writes one u32 field in the open transaction. - - - - - - - - - - Writes one u64 field in the open transaction. - - - - - - - - - - Validates the complete open transaction. - - - - - - - - Validates then atomically persists the open transaction. - - - - - - - - Discards the open RAM transaction. - - - - - - - Authoritative typed completion for a configuration request. - - - - - - - - - - - - - Writes one boolean field in the open transaction. - - - - - - - - - - Writes one f64 field in the open transaction. - - - - - - - - - - diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml b/firmware/libs/kaptan-mavlink/definitions/publish/error.xml similarity index 60% rename from firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml rename to firmware/libs/kaptan-mavlink/definitions/publish/error.xml index 36317e0..eb14730 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/error.xml +++ b/firmware/libs/kaptan-mavlink/definitions/publish/error.xml @@ -2,10 +2,9 @@ 3 0 - - - 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. + + 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. Stable explicitly assigned error catalog ID. Zero is reserved. Zero-padded compile-time file:line of the reporting point. diff --git a/firmware/libs/kaptan-mavlink/definitions/publish/heartbeat.xml b/firmware/libs/kaptan-mavlink/definitions/publish/heartbeat.xml new file mode 100644 index 0000000..2cd8a8d --- /dev/null +++ b/firmware/libs/kaptan-mavlink/definitions/publish/heartbeat.xml @@ -0,0 +1,18 @@ + + + 3 + 0 + + + + Initialization completed and the physical output routes remain inhibited. + + + + + + Periodic liveness and discovery publication for the initialized output-inhibited firmware composition. + + + + diff --git a/firmware/libs/kaptan-mavlink/definitions/publish/imu.xml b/firmware/libs/kaptan-mavlink/definitions/publish/imu.xml new file mode 100644 index 0000000..3e5e8ba --- /dev/null +++ b/firmware/libs/kaptan-mavlink/definitions/publish/imu.xml @@ -0,0 +1,14 @@ + + + 3 + 0 + + + One accepted body-frame inertial observation in the native bounded sensor representation. + Flight-controller monotonic acquisition time in microseconds. + Body-frame acceleration in milligravity. + Body-frame angular rate in milliradians per second. + Sensor temperature in centidegrees Celsius. + + + diff --git a/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml b/firmware/libs/kaptan-mavlink/definitions/publish/log.xml similarity index 51% rename from firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml rename to firmware/libs/kaptan-mavlink/definitions/publish/log.xml index 38ec44b..735b51c 100644 --- a/firmware/libs/kaptan-mavlink/definitions/kaptan/log.xml +++ b/firmware/libs/kaptan-mavlink/definitions/publish/log.xml @@ -2,22 +2,28 @@ 3 0 - - - - Record originated at Kaptan trace level and uses MAVLink DEBUG severity. + + + Filter-only sentinel. A LOG event must not use this value. - + + + + + + + + Message text was truncated to the bounded on-wire buffer. - + 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. - 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. - Known bits are TRACE=1 and TRUNCATED=2. Receivers ignore unknown future bits. + Kaptan wire level. + TRUNCATED=1. Receivers ignore unrecognized bits. Number of valid UTF-8 bytes in message; at most 160. Bounded UTF-8 message bytes. Bytes after message_length are zero. diff --git a/firmware/libs/kaptan-mavlink/definitions/publish/optical_flow.xml b/firmware/libs/kaptan-mavlink/definitions/publish/optical_flow.xml new file mode 100644 index 0000000..6adcbdd --- /dev/null +++ b/firmware/libs/kaptan-mavlink/definitions/publish/optical_flow.xml @@ -0,0 +1,14 @@ + + + 3 + 0 + + + One accepted ground-referenced optical-flow observation from the board's selected vertical sensor route. + Flight-controller monotonic reception time in microseconds. + Ground-referenced sensor-frame X/Y velocity in millimetres per second. + Ground distance in millimetres. + Device-reported observation quality. + + + diff --git a/firmware/libs/kaptan-mavlink/definitions/publish/range.xml b/firmware/libs/kaptan-mavlink/definitions/publish/range.xml new file mode 100644 index 0000000..765d6e4 --- /dev/null +++ b/firmware/libs/kaptan-mavlink/definitions/publish/range.xml @@ -0,0 +1,12 @@ + + + 3 + 0 + + + One accepted downward range observation from the board's selected vertical sensor route. + Flight-controller monotonic measurement-request time in microseconds. + Measured downward distance in millimetres. + + + diff --git a/firmware/libs/kaptan-mavlink/definitions/transfer/file_control.xml b/firmware/libs/kaptan-mavlink/definitions/transfer/file_control.xml new file mode 100644 index 0000000..084acf6 --- /dev/null +++ b/firmware/libs/kaptan-mavlink/definitions/transfer/file_control.xml @@ -0,0 +1,46 @@ + + + 3 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Opens, advances, completes, or aborts one bounded Kaptan virtual-file transfer. + Requested or next accepted byte position. + Exact complete file size advertised when the transfer is opened. + CRC-32C of the complete file. + Client-selected nonzero transfer identity retained for the complete transfer and not reused by that source during the ten-second replay lease. + + + + Requests use UNSPECIFIED; responses carry the authoritative outcome. + Logical bytes initialized in content. + Canonical path for open requests or bounded outcome detail in responses. + + + diff --git a/firmware/libs/kaptan-mavlink/definitions/transfer/file_data.xml b/firmware/libs/kaptan-mavlink/definitions/transfer/file_data.xml new file mode 100644 index 0000000..c9226b7 --- /dev/null +++ b/firmware/libs/kaptan-mavlink/definitions/transfer/file_data.xml @@ -0,0 +1,16 @@ + + + 3 + 0 + + + One directed file chunk. Its logical length is min(246, file_size - position) from the open transfer. + + + + + Upload requests use UNSPECIFIED; read responses use OK. + + + + diff --git a/firmware/libs/kaptan-mavlink/src/commands/arm.rs b/firmware/libs/kaptan-mavlink/src/commands/arm.rs deleted file mode 100644 index f1fca61..0000000 --- a/firmware/libs/kaptan-mavlink/src/commands/arm.rs +++ /dev/null @@ -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 { - 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); - } -} diff --git a/firmware/libs/kaptan-mavlink/src/commands/mod.rs b/firmware/libs/kaptan-mavlink/src/commands/mod.rs deleted file mode 100644 index 54bdbbc..0000000 --- a/firmware/libs/kaptan-mavlink/src/commands/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! MAVLink command decoders. - -/// Targeted arm/disarm command decoding. -pub mod arm; diff --git a/firmware/libs/kaptan-mavlink/src/encoder.rs b/firmware/libs/kaptan-mavlink/src/encoder.rs index 9b91d17..6dac08a 100644 --- a/firmware/libs/kaptan-mavlink/src/encoder.rs +++ b/firmware/libs/kaptan-mavlink/src/encoder.rs @@ -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(&mut self, message: &M) -> MavlinkFrame { - let mut raw = MavlinkFrame::new(); - raw.serialize_message_data( - crate::message::MavHeader { - sequence: self.next_sequence, - system_id: self.system_id, - component_id: self.component_id, - }, - message, - ); + fn header(&mut self) -> MavHeader { + let header = MavHeader { + sequence: self.next_sequence, + system_id: self.system_id, + component_id: self.component_id, + }; 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>(&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(&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 { + 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); } } diff --git a/firmware/libs/kaptan-mavlink/src/lib.rs b/firmware/libs/kaptan-mavlink/src/lib.rs index e81e10c..333fd66 100644 --- a/firmware/libs/kaptan-mavlink/src/lib.rs +++ b/firmware/libs/kaptan-mavlink/src/lib.rs @@ -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 { + use mavlink_core::Message; + + generated::kaptan::MavMessage::parse( + mavlink_core::MavlinkVersion::V2, + frame.message_id(), + frame.payload(), + ) + .ok() } diff --git a/firmware/libs/kaptan-mavlink/src/receiver.rs b/firmware/libs/kaptan-mavlink/src/receiver.rs index eb2082b..4035654 100644 --- a/firmware/libs/kaptan-mavlink/src/receiver.rs +++ b/firmware/libs/kaptan-mavlink/src/receiver.rs @@ -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::()] = + [(); core::mem::size_of::()]; +const _: [(); core::mem::align_of::()] = + [(); core::mem::align_of::()]; + +impl IntegrityCheckedFrame { + fn try_new(frame: MavlinkFrame) -> Option { + let message_id = frame.message_id(); + (MavMessage::all_ids().contains(&message_id) && frame.has_valid_crc::()) + .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 { + decode_message(&self.0) + } +} struct IntegrityFramer { frame: MavlinkFrame, @@ -17,7 +47,7 @@ impl IntegrityFramer { } } - fn receive_byte(&mut self, byte: u8) -> Option { + fn receive_byte(&mut self, byte: u8) -> Option { 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 { framer: IntegrityFramer, - frames: [Option; N], + frames: [Option; N], front: usize, length: usize, } @@ -76,7 +106,7 @@ impl IntegrityMavlinkReceiver { 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 IntegrityMavlinkReceiver { /// 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 { + pub fn take_received(&mut self) -> Option { if self.length == 0 { return None; } @@ -122,59 +152,107 @@ impl Default for IntegrityMavlinkReceiver { } } -fn has_valid_known_crc(frame: &MavlinkFrame) -> bool { - let common_valid = frame.has_valid_crc::(); - #[cfg(feature = "dialect-kaptan")] - { - common_valid || frame.has_valid_crc::() - } - #[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 { + 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 { + 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::(), + core::mem::size_of::() + ); + assert_eq!( + core::mem::align_of::(), + core::mem::align_of::() + ); } #[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()); } } diff --git a/firmware/libs/kaptan-mavlink/src/transmitter.rs b/firmware/libs/kaptan-mavlink/src/transmitter.rs index 375533e..3e42b1d 100644 --- a/firmware/libs/kaptan-mavlink/src/transmitter.rs +++ b/firmware/libs/kaptan-mavlink/src/transmitter.rs @@ -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> { - 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 { - 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> { - 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, MavlinkTransmitFault> { 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, MavlinkTransmitFault> { @@ -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::::new(Memory, Engine); - transmitter - .send_frame( - FrameClass::BestEffort, - encoder.heartbeat(MavState::MAV_STATE_STANDBY, false), - ) - .unwrap(); - transmitter - .send_frame( - FrameClass::BestEffort, - encoder.heartbeat(MavState::MAV_STATE_STANDBY, false), - ) - .unwrap(); + assert!( + transmitter + .admit_and_start(FrameClass::BestEffort, encoder.heartbeat()) + .start + .is_ok() + ); + assert!( + transmitter + .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]); diff --git a/firmware/platforms/stm32f7/Cargo.toml b/firmware/platforms/stm32f7/Cargo.toml index 77a8fdd..674423e 100644 --- a/firmware/platforms/stm32f7/Cargo.toml +++ b/firmware/platforms/stm32f7/Cargo.toml @@ -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" } diff --git a/firmware/platforms/stm32f7/src/f722/board.rs b/firmware/platforms/stm32f7/src/f722/board.rs index ba62803..0444b33 100644 --- a/firmware/platforms/stm32f7/src/f722/board.rs +++ b/firmware/platforms/stm32f7/src/f722/board.rs @@ -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, - config_protocol: ConfigProtocol, - receiver: IntegrityMavlinkReceiver<2>, - config_lease_ticks: u16, + config_file: F722ConfigFile, + 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( hardware: F722Hardware, ) -> Result<(), BoardError> where - C: Clone + Config, + C: Config, I: F7ImmutableConfig, { ::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( tim2: stm32f722::TIM2, config: C, immutable: I, - vertical: VerticalRoute, imu: F722Imu, range: Option, mtf01: Option, @@ -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::::new(factory); - let store = - F722ConfigStore::::new(F722Nor::new(flash), candidate.clone()); + let store = F722ConfigStore::::new(F722Nor::new(flash)); F722ConfigManager::::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; - } - visit_response(response, &mut sender) - }, - ); + 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 + } + }; + 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(&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, diff --git a/firmware/platforms/stm32f7/src/f722/config.rs b/firmware/platforms/stm32f7/src/f722/config.rs index b0ad462..73dcd1a 100644 --- a/firmware/platforms/stm32f7/src/f722/config.rs +++ b/firmware/platforms/stm32f7/src/f722/config.rs @@ -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, pub(super) optical_flow_hz: Option, pub(super) heartbeat_hz: u8, @@ -81,22 +87,11 @@ where fn validate(&self, context: &Self::Context) -> Result { ::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( - candidate: &C, - immutable: &I, -) -> Result<(), ConfigViolation> -where - C: Clone + Config, - I: F7ImmutableConfig, -{ - project_runtime(candidate, immutable).map(|_| ()) -} - pub(super) fn project_runtime( 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(candidate: &C, field: FieldId) -> Result { 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 = - DualSlotJournal, F722Nor, MAX_PAYLOAD_BYTES>; +pub(crate) type F722ConfigStore = + DualSlotJournal; /// 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 = - ConfigManager, F722ConfigStore>; + ConfigManager, F722ConfigStore>; + +#[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> for PersistConfigurationError { + fn from(error: JournalError) -> 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 { + Idle, + Read(KcfgExportPlan), + Write(KcfgImporter), +} + +/// 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 +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + manager: F722ConfigManager, + transfer: ConfigFileTransfer>, +} + +impl F722ConfigFile +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + pub(crate) const fn new(manager: F722ConfigManager) -> 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, + ) -> Result, 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 FileProvider for F722ConfigFile +where + C: Clone + Config, + I: F7ImmutableConfig, +{ + fn open_read( + &mut self, + path: &kaptan_file_mavlink::FilePath, + ) -> Result { + 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::>() + .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 { + 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)] diff --git a/firmware/platforms/stm32f7/src/f722/data_channel.rs b/firmware/platforms/stm32f7/src/f722/data_channel.rs index 8dfbc29..d5d048d 100644 --- a/firmware/platforms/stm32f7/src/f722/data_channel.rs +++ b/firmware/platforms/stm32f7/src/f722/data_channel.rs @@ -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), diff --git a/firmware/platforms/stm32f7/src/f722/firmware.rs b/firmware/platforms/stm32f7/src/f722/firmware.rs index 8566562..6842426 100644 --- a/firmware/platforms/stm32f7/src/f722/firmware.rs +++ b/firmware/platforms/stm32f7/src/f722/firmware.rs @@ -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); } diff --git a/firmware/platforms/stm32f7/src/lib.rs b/firmware/platforms/stm32f7/src/lib.rs index 0814986..4d42168 100644 --- a/firmware/platforms/stm32f7/src/lib.rs +++ b/firmware/platforms/stm32f7/src/lib.rs @@ -39,7 +39,7 @@ pub const fn config_snapshot_length() -> 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. diff --git a/firmware/platforms/stm32f7/tests/config_active_state.rs b/firmware/platforms/stm32f7/tests/config_active_state.rs index 2bcfe56..64611de 100644 --- a/firmware/platforms/stm32f7/tests/config_active_state.rs +++ b/firmware/platforms/stm32f7/tests/config_active_state.rs @@ -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(Option); impl ConfigStore for OneRecord { type Error = (); - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self, _: &C) -> Result, 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::::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::::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::::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)) ); } diff --git a/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs b/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs index d196ab3..8326cf6 100644 --- a/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs +++ b/firmware/platforms/stm32f7/tests/platform_safety_boundary.rs @@ -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>") + && 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")); diff --git a/firmware/scripts/mavlink-debug.py b/firmware/scripts/mavlink-debug.py deleted file mode 100755 index 4ceda72..0000000 --- a/firmware/scripts/mavlink-debug.py +++ /dev/null @@ -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) diff --git a/firmware/scripts/report-test-coverage.sh b/firmware/scripts/report-test-coverage.sh index eabdec2..f848a82 100755 --- a/firmware/scripts/report-test-coverage.sh +++ b/firmware/scripts/report-test-coverage.sh @@ -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/ diff --git a/firmware/tools/configurator/Cargo.toml b/firmware/tools/configurator/Cargo.toml index 491e201..0835f05 100644 --- a/firmware/tools/configurator/Cargo.toml +++ b/firmware/tools/configurator/Cargo.toml @@ -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" diff --git a/firmware/tools/configurator/src/api.rs b/firmware/tools/configurator/src/api.rs index 55624ee..5235b1d 100644 --- a/firmware/tools/configurator/src/api.rs +++ b/firmware/tools/configurator/src/api.rs @@ -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); + } } diff --git a/firmware/tools/configurator/src/config_client.rs b/firmware/tools/configurator/src/config_client.rs index bb871b9..98662a9 100644 --- a/firmware/tools/configurator/src/config_client.rs +++ b/firmware/tools/configurator/src/config_client.rs @@ -1,1770 +1,1645 @@ -//! Board-agnostic stop-and-wait client for the self-describing configuration service. +//! Board-agnostic configuration client over Kaptan's generic file service. use std::time::{Duration, Instant}; -pub use kaptan_config::{ConfigValue as Scalar, ValueKind}; -use kaptan_config_mavlink::{ - DESCRIPTOR_HAS_MAXIMUM, DESCRIPTOR_HAS_MINIMUM, PROTOCOL_MAJOR, PROTOCOL_MINOR, +use kaptan_config::{ConfigValue, FieldId, KcfgResultDetail, ValueKind}; +use kaptan_file_mavlink::{ + Content, ControlRequest, ControlResponse, DATA_BYTES, DataRequest, DataResponse, FileOperation, + FileRequest, FileResponse, FileStatus, Principal, TRANSFER_REPLAY_LEASE_SECONDS, Target, + crc32c, + wire::{decode_response, encode_request}, }; -use kaptan_mavlink::message::kaptan::{ - KAPTAN_CONFIG_ABORT_DATA, KAPTAN_CONFIG_BEGIN_DATA, KAPTAN_CONFIG_COMMIT_DATA, - KAPTAN_CONFIG_DESCRIPTOR_DATA, KAPTAN_CONFIG_DESCRIPTOR_REQUEST_DATA, - KAPTAN_CONFIG_ENUM_OPTION_DATA, KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA, - KAPTAN_CONFIG_INFO_DATA, KAPTAN_CONFIG_INFO_REQUEST_DATA, KAPTAN_CONFIG_SET_BOOL_DATA, - KAPTAN_CONFIG_SET_F32_DATA, KAPTAN_CONFIG_SET_F64_DATA, KAPTAN_CONFIG_SET_I32_DATA, - KAPTAN_CONFIG_SET_U32_DATA, KAPTAN_CONFIG_SET_U64_DATA, KAPTAN_CONFIG_VALIDATE_DATA, - KAPTAN_CONFIG_VALUE_DATA, KAPTAN_CONFIG_VALUE_REQUEST_DATA, KaptanConfigError, - KaptanConfigOperation, KaptanConfigResult, KaptanConfigState, KaptanConfigValueKind, - KaptanConfigValueSource, MavMessage, -}; -use kaptan_mavlink::message::{MAVLinkV2MessageRaw, MavHeader, Message}; -use mavlink_core::MavlinkVersion; +#[cfg(test)] +use kaptan_mavlink::receiver::IntegrityMavlinkReceiver; +use kaptan_mavlink::{MavlinkFrame, encoder::MavlinkEncoder, generated::kaptan::MavMessage}; +const CONFIG_PATH: &[u8] = b"/config/user.kcfg"; +const CHUNK_BYTES: usize = DATA_BYTES; +const MAX_CONFIG_FILE_BYTES: usize = 1024 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_millis(500); -// A commit erases one 128-KiB STM32F7 sector before programming the next -// journal record. The target cannot service serial traffic while flash is -// busy, so the ordinary interactive timeout is deliberately too short here. -const COMMIT_TIMEOUT: Duration = Duration::from_secs(5); +// Closing a write may erase a 128-KiB STM32F7 sector. The controller cannot +// service serial traffic during that erase, so this operation gets its own +// bounded timeout. +const CLOSE_WRITE_TIMEOUT: Duration = Duration::from_secs(5); const REQUEST_ATTEMPTS: u8 = 3; -const CLIENT_SYSTEM_ID: u8 = 255; -const CLIENT_COMPONENT_ID: u8 = 190; +// The extra second keeps the retry beyond the controller's lease despite +// independent host/controller clocks and service-loop scheduling. +const OPEN_CONFLICT_LEASE_WAIT: Duration = + Duration::from_secs(TRANSFER_REPLAY_LEASE_SECONDS as u64 + 1); -pub(crate) fn scalar_json(value: Scalar) -> serde_json::Value { +#[cfg(test)] +fn parse_test_message(frame: &MavlinkFrame) -> Option { + let mut receiver = IntegrityMavlinkReceiver::<1>::new(); + for byte in frame.raw_bytes() { + receiver.receive_byte(*byte); + } + receiver.take_received()?.decode() +} + +pub(crate) fn scalar_json(value: ConfigValue) -> serde_json::Value { match value { - Scalar::Bool(value) => value.into(), - Scalar::I32(value) => value.into(), - Scalar::U32(value) => value.into(), + ConfigValue::Bool(value) => value.into(), + ConfigValue::U8(value) => value.into(), + ConfigValue::U16(value) => value.into(), + ConfigValue::I32(value) => value.into(), + ConfigValue::U32(value) => value.into(), // JSON numbers cannot preserve the full u64 domain in a browser. - Scalar::U64(value) => value.to_string().into(), - Scalar::F32(value) => serde_json::Value::from(value), - Scalar::F64(value) => serde_json::Value::from(value), + ConfigValue::U64(value) => value.to_string().into(), + ConfigValue::F32(value) => serde_json::Value::from(value), + ConfigValue::F64(value) => serde_json::Value::from(value), } } -fn scalar_from_bits(kind: ValueKind, bits: u64) -> Result { - let value = Scalar::try_from_raw_bits(kind, bits) - .ok_or_else(|| "invalid scalar representation".to_owned())?; - match value { - Scalar::F32(value) if !value.is_finite() => Err("f32 value must be finite".into()), - Scalar::F64(value) if !value.is_finite() => Err("f64 value must be finite".into()), - _ => Ok(value), - } -} - -pub(crate) const fn scalar_less_than(left: Scalar, right: Scalar) -> bool { +pub(crate) const fn scalar_less_than(left: ConfigValue, right: ConfigValue) -> bool { match (left, right) { - (Scalar::I32(left), Scalar::I32(right)) => left < right, - (Scalar::U32(left), Scalar::U32(right)) => left < right, - (Scalar::U64(left), Scalar::U64(right)) => left < right, - (Scalar::F32(left), Scalar::F32(right)) => left < right, - (Scalar::F64(left), Scalar::F64(right)) => left < right, - (Scalar::Bool(left), Scalar::Bool(right)) => !left && right, + (ConfigValue::I32(left), ConfigValue::I32(right)) => left < right, + (ConfigValue::U8(left), ConfigValue::U8(right)) => left < right, + (ConfigValue::U16(left), ConfigValue::U16(right)) => left < right, + (ConfigValue::U32(left), ConfigValue::U32(right)) => left < right, + (ConfigValue::U64(left), ConfigValue::U64(right)) => left < right, + (ConfigValue::F32(left), ConfigValue::F32(right)) => left < right, + (ConfigValue::F64(left), ConfigValue::F64(right)) => left < right, + (ConfigValue::Bool(left), ConfigValue::Bool(right)) => !left && right, _ => false, } } -#[derive(Clone, Debug)] -pub struct RemoteEnumOption { - pub value: Scalar, - pub label: String, +#[derive(Clone)] +pub(crate) struct RemoteEnumOption { + pub(crate) value: u8, + pub(crate) label: String, } -#[derive(Clone, Debug)] -pub struct RemoteField { - pub id: u16, - pub key: String, - pub unit: String, - pub kind: ValueKind, - pub minimum: Option, - pub maximum: Option, - pub default: Scalar, - pub value: Scalar, - pub options: Vec, +#[derive(Clone)] +pub(crate) struct RemoteField { + pub(crate) id: FieldId, + pub(crate) key: String, + pub(crate) unit: String, + pub(crate) kind: ValueKind, + pub(crate) minimum: Option, + pub(crate) maximum: Option, + pub(crate) factory: ConfigValue, + pub(crate) value: ConfigValue, + pub(crate) options: Vec, } -#[derive(Clone, Debug)] -pub struct RemoteInfo { - pub schema_id: [u8; 16], - pub state: String, +#[derive(Clone)] +pub(crate) struct RemoteConfig { + pub(crate) schema_id: [u8; 16], + pub(crate) restart_required: bool, + pub(crate) fields: Vec, } -#[derive(Clone, Debug)] -pub struct RemoteConfig { - pub info: RemoteInfo, - pub fields: Vec, +pub(crate) struct Edit { + pub(crate) field_id: FieldId, + pub(crate) value: ConfigValue, } -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Edit { - pub field_id: u16, - pub value: Scalar, -} - -pub enum ClientEvent { +pub(crate) enum ClientEvent { Configuration(RemoteConfig), Failure(String), RediscoveryRequired(String), + DurableStateUnknown(String), } -type Outcome = (Option, Option); +#[expect( + clippy::large_enum_variant, + reason = "the bounded host result keeps one encoded frame allocation-free" +)] +pub(crate) enum Outcome { + Idle, + Event(ClientEvent), + Send(MavlinkFrame), +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Operation { - Info, - Descriptor(u16), - EnumOption { - descriptor_index: u16, - option_index: u8, - option_count: u8, - }, - Value(usize), - Begin, - Set(usize), - Validate, - Commit, - Abort, +enum PendingKind { + OpenRead { transfer_id: u16 }, + Read { position: u32 }, + CloseRead { position: u32 }, + OpenWrite, + Write { position: u32, next: u32 }, + CloseWrite { position: u32 }, + Abort { transfer_id: u16 }, } struct Pending { - id: u32, - operation: Operation, - raw: MAVLinkV2MessageRaw, + kind: PendingKind, + raw: MavlinkFrame, sent_at: Instant, attempts_left: u8, } -struct Save { - edits: Vec, - session_id: u32, - revision: u32, +struct ReadTransfer { + id: u16, + size: u32, + checksum: u32, + bytes: Vec, } -/// The client contains no board IDs, field names, schema IDs, or snapshot codec. -pub struct ConfigClient { - next_request_id: u32, - target: Option<(u8, u8)>, - info: Option, - field_count: u16, - fields: Vec, +struct WriteTransfer { + id: u16, + bytes: Vec, + position: u32, + checksum: u32, +} + +enum Transfer { + Read(ReadTransfer), + Write(WriteTransfer), +} + +struct DownloadedConfig { + document: Vec, + config: RemoteConfig, +} + +#[derive(Clone, Copy)] +enum OpenConflictRecovery { + Disabled, + FreshId, + LeaseDelay, + Waiting(Instant), +} + +/// Downloads and imports exactly one self-describing configuration artifact. +/// +/// The client contains no board IDs, field names, schema IDs, or field catalog. +/// The sole known resource is the stable generic virtual path. +pub(crate) struct ConfigClient { + local: Principal, + encoder: MavlinkEncoder, + next_transfer_id: u16, + target: Option, + downloaded: Option, + transfer: Option, pending: Option, - save: Option, - failure_after_abort: Option, - rediscover_after_abort: bool, -} - -impl Default for ConfigClient { - fn default() -> Self { - Self::new() - } + abort_failure: Option, + open_conflict_recovery: OpenConflictRecovery, } impl ConfigClient { - pub const fn new() -> Self { + pub(crate) const fn new(local: Principal) -> Self { Self { - next_request_id: 1, + local, + encoder: MavlinkEncoder::new(local.system(), local.component()), + next_transfer_id: 1, target: None, - info: None, - field_count: 0, - fields: Vec::new(), + downloaded: None, + transfer: None, pending: None, - save: None, - failure_after_abort: None, - rediscover_after_abort: false, + abort_failure: None, + open_conflict_recovery: OpenConflictRecovery::Disabled, } } - pub fn reset(&mut self) { - let next_request_id = self.next_request_id; - *self = Self { - next_request_id, - ..Self::new() - }; - } - - pub fn discover( - &mut self, - target: (u8, u8), - sequence: &mut u8, - ) -> Result { - if target.0 == 0 || target.1 == 0 { - return Err("configuration target must be exact".into()); - } - self.target = Some(target); - self.info = None; - self.field_count = 0; - self.fields.clear(); + pub(crate) fn reset(&mut self) { + self.target = None; + self.downloaded = None; + self.transfer = None; self.pending = None; - self.save = None; - self.failure_after_abort = None; - self.rediscover_after_abort = false; - self.request_info(sequence) + self.abort_failure = None; + self.open_conflict_recovery = OpenConflictRecovery::Disabled; } - pub fn save( - &mut self, - edits: Vec, - sequence: &mut u8, - ) -> Result { + pub(crate) fn discover(&mut self, target: Target) -> Result { + self.reset(); + self.target = Some(target); + self.open_conflict_recovery = OpenConflictRecovery::FreshId; + self.request_open_read() + } + + fn request_open_read(&mut self) -> Result { + let target = self.target()?; + let transfer_id = self.transfer_id(); + Ok(self.request_control( + PendingKind::OpenRead { transfer_id }, + ControlRequest { + operation: FileOperation::OpenRead, + target, + position: 0, + file_size: 0, + checksum_crc32c: 0, + transfer_id, + content: Content::try_new(CONFIG_PATH).expect("static config path is bounded"), + }, + )) + } + + pub(crate) fn save(&mut self, edits: Vec) -> Result { if edits.is_empty() { return Err("configuration has no changed fields".into()); } - if self.pending.is_some() || self.save.is_some() { - return Err("configuration protocol is busy".into()); + if self.pending.is_some() || self.transfer.is_some() { + return Err("configuration file transfer is busy".into()); } - for edit in &edits { - let Some(field) = self.fields.iter().find(|field| field.id == edit.field_id) else { - return Err(format!("unknown field id {}", edit.field_id)); - }; - if edit.value.kind() != field.kind { - return Err(format!("field {} has the wrong value type", edit.field_id)); - } - if !field.options.is_empty() - && !field - .options - .iter() - .any(|option| option.value == edit.value) - { - return Err(format!("field {} has an unnamed enum value", edit.field_id)); - } - } - self.save = Some(Save { - edits, - session_id: 0, - revision: 0, - }); - self.request_begin(sequence) + let downloaded = self + .downloaded + .as_ref() + .ok_or("configuration has not been downloaded")?; + validate_edits(&downloaded.config, &edits)?; + let bytes = encode_edited_document(&downloaded.document, &edits)?; + let size = u32::try_from(bytes.len()) + .map_err(|_| "configuration artifact exceeds the file protocol".to_owned())?; + let checksum = crc32c(&bytes); + let transfer_id = self.transfer_id(); + let target = self.target()?; + self.transfer = Some(Transfer::Write(WriteTransfer { + id: transfer_id, + bytes, + position: 0, + checksum, + })); + Ok(self.request_control( + PendingKind::OpenWrite, + ControlRequest { + operation: FileOperation::OpenWrite, + target, + position: 0, + file_size: size, + checksum_crc32c: checksum, + transfer_id, + content: Content::try_new(CONFIG_PATH).expect("static config path is bounded"), + }, + )) } - pub fn observe(&mut self, frame: &MAVLinkV2MessageRaw, sequence: &mut u8) -> Outcome { + pub(crate) fn observe(&mut self, source: Target, message: &MavMessage) -> Outcome { let Some(target) = self.target else { - return (None, None); + return Outcome::Idle; }; - if frame.incompatibility_flags() != 0 || (frame.system_id(), frame.component_id()) != target - { - return (None, None); + if source != target { + return Outcome::Idle; } - if !frame.has_valid_crc::() { - return (None, None); - } - let Ok(message) = - MavMessage::parse(MavlinkVersion::V2, frame.message_id(), frame.payload()) - else { - return (None, None); - }; - let Some(response_id) = response_id(&message) else { - return (None, None); - }; - let Some(pending) = self.pending.take() else { - return (None, None); - }; - if pending.id != response_id { - self.pending = Some(pending); - return (None, None); - } - - match (pending.operation, message) { - (Operation::Info, MavMessage::KAPTAN_CONFIG_INFO(data)) => { - self.accept_info(data, sequence) - } - (Operation::Descriptor(index), MavMessage::KAPTAN_CONFIG_DESCRIPTOR(data)) => { - self.accept_descriptor(index, data, sequence) - } - ( - Operation::EnumOption { - descriptor_index, - option_index, - option_count, - }, - MavMessage::KAPTAN_CONFIG_ENUM_OPTION(data), - ) => self.accept_enum_option( - descriptor_index, - option_index, - option_count, - data, - sequence, - ), - (Operation::Value(index), MavMessage::KAPTAN_CONFIG_VALUE(data)) => { - self.accept_value(index, data, sequence) - } - (operation, MavMessage::KAPTAN_CONFIG_RESULT(data)) => { - self.accept_result(operation, data, sequence) - } - _ => self.fail("controller returned an unexpected configuration response"), + match decode_response(message) { + Ok(FileResponse::Control(control)) => self.observe_control(control), + Ok(FileResponse::Data(data)) => self.observe_data(data), + Err(_) => Outcome::Idle, } } - pub fn retry_due(&mut self, sequence: &mut u8) -> Outcome { - let Some(pending) = self.pending.as_mut() else { - return (None, None); + #[cfg(test)] + fn observe_frame(&mut self, frame: &MavlinkFrame) -> Outcome { + let Some(message) = parse_test_message(frame) else { + return Outcome::Idle; }; - if pending.sent_at.elapsed() < request_timeout(pending.operation) { - return (None, None); + let Some(source) = Target::try_new(frame.system_id(), frame.component_id()) else { + return Outcome::Idle; + }; + self.observe(source, &message) + } + + pub(crate) fn retry_due(&mut self) -> Outcome { + if let OpenConflictRecovery::Waiting(retry_at) = self.open_conflict_recovery { + if Instant::now() < retry_at { + return Outcome::Idle; + } + self.open_conflict_recovery = OpenConflictRecovery::Disabled; + let request = self.request_open_read(); + return self.follow_up(request); + } + let Some(pending) = self.pending.as_mut() else { + return Outcome::Idle; + }; + if pending.sent_at.elapsed() < timeout(pending.kind) { + return Outcome::Idle; } if pending.attempts_left != 0 { pending.attempts_left -= 1; pending.sent_at = Instant::now(); - return (None, Some(pending.raw)); + return Outcome::Send(pending.raw); } - let operation = pending.operation; + let kind = pending.kind; self.pending = None; let detail = format!( - "configuration {} request timed out after bounded retries", - operation_name(operation) + "configuration file {} timed out after bounded retries", + operation_name(kind) ); - match operation { - Operation::Set(_) | Operation::Validate => self.abort_after(detail, sequence), - Operation::Commit => self.rediscovery_required(format!( - "{detail}; the commit outcome is indeterminate and the controller must be rediscovered before another Save" - )), - Operation::Abort => { - let original = self.failure_after_abort.take().unwrap_or(detail); - self.rediscovery_required(format!( - "{original}; transaction cleanup timed out and the controller must be rediscovered before another Save" - )) - } - Operation::Info - | Operation::Descriptor(_) - | Operation::EnumOption { .. } - | Operation::Value(_) - | Operation::Begin => self.fail(detail), - } - } - - fn accept_info(&mut self, data: KAPTAN_CONFIG_INFO_DATA, sequence: &mut u8) -> Outcome { - if data.protocol_major != PROTOCOL_MAJOR || data.protocol_minor != PROTOCOL_MINOR { - return self.fail(format!( - "unsupported configuration protocol {}.{}", - data.protocol_major, data.protocol_minor + if matches!(kind, PendingKind::CloseWrite { .. }) { + return self.durable_state_unknown(format!( + "{detail}; the durable state is unknown, so power-cycle the controller and reconnect before another save" )); } - self.info = Some(RemoteInfo { - schema_id: data.schema_id, - state: config_state(data.config_state), - }); - self.field_count = data.field_count; - self.fields.clear(); - if data.field_count == 0 { - return self.ready(); + if matches!(kind, PendingKind::Abort { .. }) { + let original = self.abort_failure.take().unwrap_or(detail); + return self.rediscovery_required(format!( + "{original}; the file transfer could not be released" + )); } - follow_up(self.request_descriptor(0, sequence)) + self.abort_after(detail) } - fn accept_descriptor( - &mut self, - expected: u16, - data: KAPTAN_CONFIG_DESCRIPTOR_DATA, - sequence: &mut u8, - ) -> Outcome { - if data.descriptor_index != expected || data.field_id == 0 || data.field_id == u16::MAX { - return self.fail("controller returned an invalid descriptor identity"); + fn observe_control(&mut self, data: ControlResponse) -> Outcome { + if data.target != self.local_target() { + return Outcome::Idle; } - if data.flags & !(DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM) != 0 { - return self.fail("controller returned unsupported descriptor flags"); - } - let key = match wire_text(&data.display_key) { - Ok(key) if !key.is_empty() => key, - Ok(_) => return self.fail("controller returned an empty configuration key"), - Err(error) => return self.fail(error), + let Some(kind) = self.pending.as_ref().map(|pending| pending.kind) else { + return Outcome::Idle; }; - let unit = match wire_text(&data.unit) { - Ok(unit) => unit, - Err(error) => return self.fail(error), - }; - if self - .fields - .iter() - .any(|field| field.id == data.field_id || field.key == key) - { - return self.fail("controller returned duplicate configuration metadata"); + if !control_matches(kind, &data) { + // A delayed duplicate may arrive after its retry was already + // accepted. It must not disturb the current stop-and-wait request. + return Outcome::Idle; } - let kind = value_kind(data.value_kind); - if data.enum_option_count != 0 && kind != ValueKind::U32 { - return self.fail("controller returned enum options for a non-enum scalar kind"); + if !self.response_identity_matches(kind, data.transfer_id) { + return Outcome::Idle; } - let default = match scalar_from_bits(kind, data.default_bits) { - Ok(value) => value, - Err(error) => return self.fail(error), - }; - let minimum = if data.flags & DESCRIPTOR_HAS_MINIMUM != 0 { - match scalar_from_bits(kind, data.minimum_bits) { - Ok(value) => Some(value), - Err(error) => return self.fail(error), - } - } else { - None - }; - let maximum = if data.flags & DESCRIPTOR_HAS_MAXIMUM != 0 { - match scalar_from_bits(kind, data.maximum_bits) { - Ok(value) => Some(value), - Err(error) => return self.fail(error), - } - } else { - None - }; - if minimum.is_some_and(|minimum| { - maximum.is_some_and(|maximum| scalar_less_than(maximum, minimum)) - }) || minimum.is_some_and(|minimum| scalar_less_than(default, minimum)) - || maximum.is_some_and(|maximum| scalar_less_than(maximum, default)) - { - return self.fail("controller returned inconsistent descriptor bounds"); - } - self.fields.push(RemoteField { - id: data.field_id, - key, - unit, - kind, - minimum, - maximum, - default, - value: default, - options: Vec::with_capacity(usize::from(data.enum_option_count)), - }); - if data.enum_option_count == 0 { - self.request_after_descriptor(expected, sequence) - } else { - follow_up(self.request_enum_option(expected, 0, data.enum_option_count, sequence)) - } - } - - fn accept_enum_option( - &mut self, - descriptor_index: u16, - option_index: u8, - option_count: u8, - data: KAPTAN_CONFIG_ENUM_OPTION_DATA, - sequence: &mut u8, - ) -> Outcome { - let label = match wire_text(&data.label) { - Ok(label) if !label.is_empty() => label, - Ok(_) => return self.fail("controller returned an empty enum option label"), - Err(error) => return self.fail(error), - }; - let Some(field) = self.fields.get(usize::from(descriptor_index)) else { - return self.fail("controller returned an enum option without its descriptor"); - }; - if data.descriptor_index != descriptor_index - || data.field_id != field.id - || data.option_index != option_index - || field.options.len() != usize::from(option_index) - { - return self.fail("controller returned an enum option with the wrong identity"); - } - let value = Scalar::U32(u32::from(data.value)); - if field - .minimum - .is_some_and(|minimum| scalar_less_than(value, minimum)) - || field - .maximum - .is_some_and(|maximum| scalar_less_than(maximum, value)) - || field - .options - .iter() - .any(|option| option.value == value || option.label == label) - { - return self.fail("controller returned an inconsistent enum option"); - } - self.fields[usize::from(descriptor_index)] - .options - .push(RemoteEnumOption { value, label }); - - let next = option_index + 1; - if next < option_count { - follow_up(self.request_enum_option(descriptor_index, next, option_count, sequence)) - } else { - let field = &self.fields[usize::from(descriptor_index)]; - if !field - .options - .iter() - .any(|option| option.value == field.default) - { - return self.fail("controller returned enum options without its default value"); - } - self.request_after_descriptor(descriptor_index, sequence) - } - } - - fn request_after_descriptor(&mut self, current: u16, sequence: &mut u8) -> Outcome { - let next = current + 1; - if next < self.field_count { - follow_up(self.request_descriptor(next, sequence)) - } else { - follow_up(self.request_value(0, sequence)) - } - } - - fn accept_value( - &mut self, - index: usize, - data: KAPTAN_CONFIG_VALUE_DATA, - sequence: &mut u8, - ) -> Outcome { - let Some(field) = self.fields.get_mut(index) else { - return self.fail("controller returned a value outside its descriptor set"); - }; - let kind = value_kind(data.value_kind); - if data.field_id != field.id - || kind != field.kind - || data.session_id != 0 - || data.revision != 0 - { - return self.fail("controller returned a value that disagrees with its descriptor"); - } - field.value = match scalar_from_bits(kind, data.value_bits) { - Ok(value) => value, - Err(error) => return self.fail(error), - }; - if !field.options.is_empty() - && !field - .options - .iter() - .any(|option| option.value == field.value) - { - return self.fail("controller returned an unnamed enum value"); - } - let next = index + 1; - if next < self.fields.len() { - follow_up(self.request_value(next, sequence)) - } else { - self.ready() - } - } - - fn accept_result( - &mut self, - operation: Operation, - data: kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA, - sequence: &mut u8, - ) -> Outcome { - if operation == Operation::Abort { - return self.accept_abort(data); - } - if data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED { - let detail = format!( - "configuration {:?} rejected: {:?} (field {}, related {}, rule {})", - data.operation, - data.error, - data.primary_field, - data.related_field, - data.validation_rule - ); - if operation == Operation::Begin - && data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN - && data.error == KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY - && data.session_id != 0 - { - let Some(save) = self.save.as_mut() else { - return self.fail(detail); - }; - save.session_id = data.session_id; - save.revision = data.revision; - return self.abort_after(detail, sequence); - } - if operation == Operation::Commit - && data.error == KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE - && self.save.as_ref().is_some_and(|save| save.session_id != 0) - { - return self.abort_then_rediscover( - format!( - "{detail}; persistence may have completed despite the storage error, so power-cycle and rediscover before another Save" - ), - sequence, - ); - } - if matches!( - operation, - Operation::Set(_) | Operation::Validate | Operation::Commit - ) && self.save.as_ref().is_some_and(|save| save.session_id != 0) - { - return self.abort_after(detail, sequence); - } - return self.fail(detail); - } - if data.error != KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE { - return self.fail_operation( - operation, - "controller returned an error with a successful result", - sequence, - ); - } - match operation { - Operation::Begin - if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN - && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED => - { - let Some(save) = self.save.as_mut() else { - return self.fail("missing save transaction"); - }; - if data.session_id == 0 || data.revision != 0 { - if data.session_id != 0 { - save.session_id = data.session_id; - save.revision = data.revision; - return self.abort_after( - "controller returned an invalid transaction lease".into(), - sequence, - ); - } - return self.fail("controller returned an invalid transaction lease"); - } - save.session_id = data.session_id; - if save.edits.is_empty() { - follow_up(self.request_validate(sequence)) - } else { - follow_up(self.request_set(0, sequence)) - } - } - Operation::Set(index) - if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET - && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED => - { - let (next_edit, edit_count) = { - let Some(save) = self.save.as_mut() else { - return self.fail("missing save transaction"); - }; - if data.session_id != save.session_id || data.revision != save.revision + 1 { - return self.abort_after( - "controller returned an invalid transaction revision".into(), - sequence, - ); - } - save.revision = data.revision; - (index + 1, save.edits.len()) - }; - if next_edit < edit_count { - follow_up(self.request_set(next_edit, sequence)) - } else { - follow_up(self.request_validate(sequence)) - } - } - Operation::Validate - if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE - && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED => - { - let Some(save) = self.save.as_ref() else { - return self.fail("missing save transaction"); - }; - if data.session_id != save.session_id || data.revision != save.revision { - return self.abort_after( - "controller returned an invalid validated transaction".into(), - sequence, - ); - } - follow_up(self.request_commit(sequence)) - } - Operation::Commit - if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT - && data.result - == KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED => - { - let Some(save) = self.save.as_ref() else { - return self.fail("missing committed transaction"); - }; - if data.session_id != 0 - || data.revision != save.revision - || data.config_state != KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED - { - return self.rediscovery_required( - "controller returned an invalid committed transaction; the commit outcome is indeterminate and the controller must be rediscovered before another Save", - ); - } - let Some(save) = self.save.take() else { - return self.fail("missing committed transaction"); - }; - for edit in save.edits { - if let Some(field) = self - .fields - .iter_mut() - .find(|field| field.id == edit.field_id) - { - field.value = edit.value; - } - } - if let Some(info) = self.info.as_mut() { - info.state = "restart required".into(); - } - self.ready() - } - Operation::Commit => self.rediscovery_required( - "controller returned an invalid commit response; the commit outcome is indeterminate and the controller must be rediscovered before another Save", - ), - _ => self.fail_operation( - operation, - "controller returned a result for the wrong operation", - sequence, - ), - } - } - - fn accept_abort( - &mut self, - data: kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA, - ) -> Outcome { - let original = self - .failure_after_abort - .take() - .unwrap_or_else(|| "configuration transaction cleanup was not requested".into()); - if data.operation == KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT - && data.result == KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED - && data.error == KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE - && data.session_id == 0 - { - if self.rediscover_after_abort { - return self.rediscovery_required(original); - } - return self.fail(original); - } - self.rediscovery_required(format!( - "{original}; transaction cleanup was not confirmed and the controller must be rediscovered before another Save" - )) - } - - fn fail_operation( - &mut self, - operation: Operation, - detail: impl Into, - sequence: &mut u8, - ) -> Outcome { - let detail = detail.into(); - match operation { - Operation::Set(_) | Operation::Validate => self.abort_after(detail, sequence), - Operation::Commit => self.rediscovery_required(format!( - "{detail}; the commit outcome is indeterminate and the controller must be rediscovered before another Save" - )), - _ => self.fail(detail), - } - } - - fn abort_after(&mut self, detail: String, sequence: &mut u8) -> Outcome { - if self.save.as_ref().is_none_or(|save| save.session_id == 0) { - return self.fail(detail); - } - self.failure_after_abort = Some(detail); - self.rediscover_after_abort = false; - match self.request_abort(sequence) { - Ok(request) => (None, Some(request)), - Err(error) => { - let original = self.failure_after_abort.take().unwrap_or_default(); - self.rediscovery_required(format!( - "{original}; cannot request transaction cleanup: {error}; the controller must be rediscovered before another Save" - )) - } - } - } - - fn abort_then_rediscover(&mut self, detail: String, sequence: &mut u8) -> Outcome { - if self.save.as_ref().is_none_or(|save| save.session_id == 0) { - return self.rediscovery_required(detail); - } - self.failure_after_abort = Some(detail); - self.rediscover_after_abort = true; - match self.request_abort(sequence) { - Ok(request) => (None, Some(request)), - Err(error) => { - let original = self.failure_after_abort.take().unwrap_or_default(); - self.rediscovery_required(format!( - "{original}; cannot request transaction cleanup: {error}" - )) - } - } - } - - fn ready(&self) -> Outcome { - let Some(info) = self.info.clone() else { - return ( - Some(ClientEvent::Failure("configuration INFO is missing".into())), - None, - ); - }; - ( - Some(ClientEvent::Configuration(RemoteConfig { - info, - fields: self.fields.clone(), - })), - None, - ) - } - - fn fail(&mut self, detail: impl Into) -> Outcome { self.pending = None; - self.save = None; - self.failure_after_abort = None; - self.rediscover_after_abort = false; - (Some(ClientEvent::Failure(detail.into())), None) - } - - fn rediscovery_required(&mut self, detail: impl Into) -> Outcome { - let detail = detail.into(); - self.reset(); - (Some(ClientEvent::RediscoveryRequired(detail)), None) - } - - fn request_info(&mut self, sequence: &mut u8) -> Result { - let target = self.target()?; - let id = self.request_id(); - Ok(self.send( - Operation::Info, - id, - MavMessage::KAPTAN_CONFIG_INFO_REQUEST(KAPTAN_CONFIG_INFO_REQUEST_DATA { - request_id: id, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) - } - - fn request_descriptor( - &mut self, - index: u16, - sequence: &mut u8, - ) -> Result { - let target = self.target()?; - let id = self.request_id(); - Ok(self.send( - Operation::Descriptor(index), - id, - MavMessage::KAPTAN_CONFIG_DESCRIPTOR_REQUEST(KAPTAN_CONFIG_DESCRIPTOR_REQUEST_DATA { - request_id: id, - descriptor_index: index, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) - } - - fn request_value( - &mut self, - index: usize, - sequence: &mut u8, - ) -> Result { - let target = self.target()?; - let field_id = self - .fields - .get(index) - .ok_or("missing descriptor for value request")? - .id; - let id = self.request_id(); - Ok(self.send( - Operation::Value(index), - id, - MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(KAPTAN_CONFIG_VALUE_REQUEST_DATA { - request_id: id, - session_id: 0, - field_id, - source: KaptanConfigValueSource::KAPTAN_CONFIG_VALUE_SOURCE_COMMITTED, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) - } - - fn request_enum_option( - &mut self, - descriptor_index: u16, - option_index: u8, - option_count: u8, - sequence: &mut u8, - ) -> Result { - let target = self.target()?; - let id = self.request_id(); - Ok(self.send( - Operation::EnumOption { - descriptor_index, - option_index, - option_count, - }, - id, - MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(KAPTAN_CONFIG_ENUM_OPTION_REQUEST_DATA { - request_id: id, - descriptor_index, - option_index, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) - } - - fn request_begin(&mut self, sequence: &mut u8) -> Result { - let target = self.target()?; - self.save.as_ref().ok_or("missing save transaction")?; - let id = self.request_id(); - Ok(self.send( - Operation::Begin, - id, - MavMessage::KAPTAN_CONFIG_BEGIN(KAPTAN_CONFIG_BEGIN_DATA { - request_id: id, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) - } - - fn request_set( - &mut self, - index: usize, - sequence: &mut u8, - ) -> Result { - let target = self.target()?; - let (edit, session_id, revision) = { - let save = self.save.as_ref().ok_or("missing save transaction")?; - ( - *save.edits.get(index).ok_or("missing configuration edit")?, - save.session_id, - save.revision, - ) - }; - let id = self.request_id(); - let message = match edit.value { - Scalar::Bool(value) => { - MavMessage::KAPTAN_CONFIG_SET_BOOL(KAPTAN_CONFIG_SET_BOOL_DATA { - request_id: id, - session_id, - expected_revision: revision, - field_id: edit.field_id, - value: u8::from(value), - target_system: target.0, - target_component: target.1, - }) + if matches!(kind, PendingKind::Abort { .. }) { + let original = self + .abort_failure + .take() + .unwrap_or_else(|| "configuration file transfer aborted".into()); + self.transfer = None; + return if data.status == FileStatus::Ok { + self.fail(original) + } else { + self.rediscovery_required(format!( + "{original}; controller rejected the transfer abort: {:?}", + data.status + )) + }; + } + if matches!(kind, PendingKind::OpenRead { .. }) + && data.status == FileStatus::RequestConflict + { + self.transfer = None; + match self.open_conflict_recovery { + OpenConflictRecovery::FreshId => { + self.open_conflict_recovery = OpenConflictRecovery::LeaseDelay; + let request = self.request_open_read(); + return self.follow_up(request); + } + OpenConflictRecovery::LeaseDelay => { + self.open_conflict_recovery = + OpenConflictRecovery::Waiting(Instant::now() + OPEN_CONFLICT_LEASE_WAIT); + return Outcome::Idle; + } + OpenConflictRecovery::Disabled | OpenConflictRecovery::Waiting(_) => {} } - Scalar::I32(value) => MavMessage::KAPTAN_CONFIG_SET_I32(KAPTAN_CONFIG_SET_I32_DATA { - value, - request_id: id, - session_id, - expected_revision: revision, - field_id: edit.field_id, - target_system: target.0, - target_component: target.1, - }), - Scalar::U32(value) => MavMessage::KAPTAN_CONFIG_SET_U32(KAPTAN_CONFIG_SET_U32_DATA { - value, - request_id: id, - session_id, - expected_revision: revision, - field_id: edit.field_id, - target_system: target.0, - target_component: target.1, - }), - Scalar::U64(value) => MavMessage::KAPTAN_CONFIG_SET_U64(KAPTAN_CONFIG_SET_U64_DATA { - value, - request_id: id, - session_id, - expected_revision: revision, - field_id: edit.field_id, - target_system: target.0, - target_component: target.1, - }), - Scalar::F32(value) => MavMessage::KAPTAN_CONFIG_SET_F32(KAPTAN_CONFIG_SET_F32_DATA { - value, - request_id: id, - session_id, - expected_revision: revision, - field_id: edit.field_id, - target_system: target.0, - target_component: target.1, - }), - Scalar::F64(value) => MavMessage::KAPTAN_CONFIG_SET_F64(KAPTAN_CONFIG_SET_F64_DATA { - value, - request_id: id, - session_id, - expected_revision: revision, - field_id: edit.field_id, - target_system: target.0, - target_component: target.1, - }), + } + if data.status != FileStatus::Ok { + let detail = rejected_detail(&data); + return match kind { + PendingKind::OpenRead { .. } => self.rediscovery_required(detail), + PendingKind::OpenWrite => self.fail(detail), + PendingKind::CloseWrite { .. } if data.status == FileStatus::StorageFailure => self + .durable_state_unknown(format!( + "{detail}; power-cycle the controller and reconnect before another save" + )), + PendingKind::Read { .. } + | PendingKind::Write { .. } + | PendingKind::CloseRead { .. } + | PendingKind::CloseWrite { .. } + if data.status == FileStatus::InvalidTransfer => + { + self.rediscovery_required(detail) + } + PendingKind::Read { .. } + | PendingKind::Write { .. } + | PendingKind::CloseRead { .. } + | PendingKind::CloseWrite { .. } + if matches!( + data.status, + FileStatus::InvalidPosition + | FileStatus::InvalidState + | FileStatus::RequestConflict + ) => + { + self.abort_after(detail) + } + PendingKind::Read { .. } + | PendingKind::Write { .. } + | PendingKind::CloseRead { .. } + | PendingKind::CloseWrite { .. } => self.fail(detail), + PendingKind::Abort { .. } => unreachable!("abort responses returned above"), + }; + } + match kind { + PendingKind::OpenRead { transfer_id } => { + self.open_conflict_recovery = OpenConflictRecovery::Disabled; + self.opened_read(data, transfer_id) + } + PendingKind::CloseRead { .. } => self.closed_read(data), + PendingKind::OpenWrite => self.opened_write(data), + PendingKind::Write { next, .. } => self.accepted_write(data, next), + PendingKind::CloseWrite { .. } => self.closed_write(data), + PendingKind::Abort { .. } => unreachable!("abort responses returned above"), + PendingKind::Read { .. } => Outcome::Idle, + } + } + + fn observe_data(&mut self, data: DataResponse) -> Outcome { + if data.target != self.local_target() { + return Outcome::Idle; + } + let Some(Pending { + kind: PendingKind::Read { position }, + .. + }) = self.pending.as_ref() + else { + return Outcome::Idle; }; - Ok(self.send(Operation::Set(index), id, message, sequence)) + let position = *position; + let Some(Transfer::Read(read)) = self.transfer.as_mut() else { + return self.fail("configuration read response has no open transfer"); + }; + if data.transfer_id != read.id || data.position != position { + return Outcome::Idle; + } + let remaining = read.size.saturating_sub(position); + let count = usize::try_from(remaining.min(CHUNK_BYTES as u32)) + .expect("bounded file chunk length fits usize"); + if count == 0 || read.bytes.len() != position as usize { + return self.abort_after("controller returned an invalid file position".into()); + } + if data.data[count..].iter().any(|byte| *byte != 0) { + return self.abort_after("controller returned a noncanonical file chunk".into()); + } + read.bytes.extend_from_slice(&data.data[..count]); + self.pending = None; + let next = position + count as u32; + if next < read.size { + let request = self.request_read(next); + self.follow_up(request) + } else if crc32c(&read.bytes) != read.checksum { + self.abort_after("configuration file checksum did not match".into()) + } else { + let transfer_id = read.id; + let document = std::mem::take(&mut read.bytes); + let config = match decode_document(&document) { + Ok(config) => config, + Err(error) => return self.abort_after(error), + }; + self.downloaded = Some(DownloadedConfig { document, config }); + let request = + self.request_close(PendingKind::CloseRead { position: next }, transfer_id, next); + self.follow_up(request) + } } - fn request_validate(&mut self, sequence: &mut u8) -> Result { - let target = self.target()?; - let save = self.save.as_ref().ok_or("missing save transaction")?; - let (session_id, revision) = (save.session_id, save.revision); - let id = self.request_id(); - Ok(self.send( - Operation::Validate, - id, - MavMessage::KAPTAN_CONFIG_VALIDATE(KAPTAN_CONFIG_VALIDATE_DATA { - request_id: id, - session_id, - expected_revision: revision, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) + fn opened_read(&mut self, data: ControlResponse, requested_id: u16) -> Outcome { + let Ok(size) = usize::try_from(data.file_size) else { + return self.fail("controller returned an unsupported configuration file size"); + }; + if data.status != FileStatus::Ok + || data.transfer_id != requested_id + || data.position != 0 + || size == 0 + || size > MAX_CONFIG_FILE_BYTES + { + return self.abort_transfer_after( + requested_id, + "controller returned an invalid configuration file size".into(), + ); + } + self.transfer = Some(Transfer::Read(ReadTransfer { + id: data.transfer_id, + size: data.file_size, + checksum: data.checksum_crc32c, + bytes: Vec::with_capacity(size), + })); + let request = self.request_read(0); + self.follow_up(request) } - fn request_commit(&mut self, sequence: &mut u8) -> Result { - let target = self.target()?; - let save = self.save.as_ref().ok_or("missing save transaction")?; - let (session_id, revision) = (save.session_id, save.revision); - let id = self.request_id(); - Ok(self.send( - Operation::Commit, - id, - MavMessage::KAPTAN_CONFIG_COMMIT(KAPTAN_CONFIG_COMMIT_DATA { - request_id: id, - session_id, - expected_revision: revision, - target_system: target.0, - target_component: target.1, - }), - sequence, - )) - } - - fn request_abort(&mut self, sequence: &mut u8) -> Result { - let target = self.target()?; - let session_id = self - .save + fn closed_read(&mut self, data: ControlResponse) -> Outcome { + let expected_position = match self.transfer.as_ref() { + Some(Transfer::Read(read)) => read.size, + _ => return self.fail("configuration export close has no transfer"), + }; + if data.status != FileStatus::Ok || data.position != expected_position { + return self.fail("controller did not close the configuration export"); + } + self.transfer = None; + let Some(config) = self + .downloaded .as_ref() - .filter(|save| save.session_id != 0) - .ok_or("missing configuration transaction lease")? - .session_id; - let id = self.request_id(); - Ok(self.send( - Operation::Abort, - id, - MavMessage::KAPTAN_CONFIG_ABORT(KAPTAN_CONFIG_ABORT_DATA { - request_id: id, - session_id, - target_system: target.0, - target_component: target.1, - }), - sequence, + .map(|downloaded| downloaded.config.clone()) + else { + return self.fail("configuration artifact was not decoded"); + }; + Outcome::Event(ClientEvent::Configuration(config)) + } + + fn opened_write(&mut self, data: ControlResponse) -> Outcome { + let Some(Transfer::Write(write)) = self.transfer.as_mut() else { + return self.fail("configuration write response has no pending artifact"); + }; + if data.status != FileStatus::Ok + || data.transfer_id == 0 + || data.transfer_id != write.id + || data.position != 0 + || usize::try_from(data.file_size).ok() != Some(write.bytes.len()) + || data.checksum_crc32c != write.checksum + { + return self.abort_after("controller returned an invalid write transfer".into()); + } + let request = self.request_write(); + self.follow_up(request) + } + + fn accepted_write(&mut self, data: ControlResponse, next: u32) -> Outcome { + let Some(Transfer::Write(write)) = self.transfer.as_mut() else { + return self.fail("configuration write acknowledgement has no transfer"); + }; + if data.status != FileStatus::Ok + || data.transfer_id != write.id + || data.position != next + || usize::try_from(data.file_size).ok() != Some(write.bytes.len()) + || data.checksum_crc32c != write.checksum + { + return self.abort_after("controller returned an invalid write acknowledgement".into()); + } + write.position = next; + if usize::try_from(next).ok() == Some(write.bytes.len()) { + let transfer_id = write.id; + let request = self.request_close( + PendingKind::CloseWrite { position: next }, + transfer_id, + next, + ); + self.follow_up(request) + } else { + let request = self.request_write(); + self.follow_up(request) + } + } + + fn closed_write(&mut self, data: ControlResponse) -> Outcome { + let (expected_position, expected_checksum) = match self.transfer.as_ref() { + Some(Transfer::Write(write)) => (write.bytes.len() as u32, write.checksum), + _ => return self.rediscovery_required("completed save has no local artifact"), + }; + let Some((result, _)) = KcfgResultDetail::decode(data.content.as_bytes()) else { + return self.rediscovery_required( + "controller returned a malformed configuration import result", + ); + }; + if data.status != FileStatus::Ok + || data.position != expected_position + || data.file_size != expected_position + || data.checksum_crc32c != expected_checksum + || result.primary().is_some() + || result.related_field().is_some() + || result.rule().is_some() + { + return self.rediscovery_required( + "controller returned an invalid configuration import result", + ); + } + let restart_required = result.restart_required(); + let Some(Transfer::Write(write)) = self.transfer.take() else { + return self.rediscovery_required("completed save has no local artifact"); + }; + let document = match encode_document_state(&write.bytes, restart_required) { + Ok(document) => document, + Err(error) => return self.rediscovery_required(error), + }; + let config = match decode_document(&document) { + Ok(config) => config, + Err(error) => return self.rediscovery_required(error), + }; + self.downloaded = Some(DownloadedConfig { + document, + config: config.clone(), + }); + Outcome::Event(ClientEvent::Configuration(config)) + } + + fn request_read(&mut self, position: u32) -> Result { + let id = match self.transfer.as_ref() { + Some(Transfer::Read(read)) => read.id, + _ => return Err("configuration read transfer is not open".into()), + }; + let target = self.target()?; + Ok(self.request_control( + PendingKind::Read { position }, + ControlRequest { + operation: FileOperation::Read, + target, + position, + file_size: 0, + checksum_crc32c: 0, + transfer_id: id, + content: Content::EMPTY, + }, )) } - fn target(&self) -> Result<(u8, u8), String> { + fn request_write(&mut self) -> Result { + let (id, position, data, next) = match self.transfer.as_ref() { + Some(Transfer::Write(write)) => { + let position = usize::try_from(write.position) + .map_err(|_| "configuration write position is invalid")?; + let end = (position + CHUNK_BYTES).min(write.bytes.len()); + if position >= end { + return Err("configuration write has no remaining bytes".into()); + } + ( + write.id, + write.position, + &write.bytes[position..end], + end as u32, + ) + } + _ => return Err("configuration write transfer is not open".into()), + }; + let target = self.target()?; + let mut bytes = [0_u8; CHUNK_BYTES]; + bytes[..data.len()].copy_from_slice(data); + let message = encode_request(FileRequest::Data(DataRequest { + target, + position, + transfer_id: id, + data: bytes, + })); + Ok(self.send(PendingKind::Write { position, next }, message)) + } + + fn request_close( + &mut self, + kind: PendingKind, + transfer_id: u16, + position: u32, + ) -> Result { + let target = self.target()?; + Ok(self.request_control( + kind, + ControlRequest { + operation: FileOperation::Close, + target, + position, + file_size: 0, + checksum_crc32c: 0, + transfer_id, + content: Content::EMPTY, + }, + )) + } + + fn request_control(&mut self, kind: PendingKind, request: ControlRequest) -> MavlinkFrame { + let message = encode_request(FileRequest::Control(request)); + self.send(kind, message) + } + + fn abort_after(&mut self, detail: String) -> Outcome { + let transfer_id = match self.transfer.as_ref() { + Some(Transfer::Read(read)) => read.id, + Some(Transfer::Write(write)) => write.id, + None => 0, + }; + self.abort_transfer_after(transfer_id, detail) + } + + fn abort_transfer_after(&mut self, transfer_id: u16, detail: String) -> Outcome { + if transfer_id == 0 { + return self.fail(detail); + } + self.abort_failure = Some(detail); + let target = match self.target() { + Ok(target) => target, + Err(error) => return self.rediscovery_required(error), + }; + let request = self.request_control( + PendingKind::Abort { transfer_id }, + ControlRequest { + operation: FileOperation::Abort, + target, + position: 0, + file_size: 0, + checksum_crc32c: 0, + transfer_id, + content: Content::EMPTY, + }, + ); + Outcome::Send(request) + } + + pub(crate) fn target(&self) -> Result { self.target .ok_or_else(|| "configuration target is unknown".into()) } - fn request_id(&mut self) -> u32 { - let id = self.next_request_id; - self.next_request_id = self.next_request_id.wrapping_add(1).max(1); + fn response_identity_matches(&self, kind: PendingKind, transfer_id: u16) -> bool { + let expected = match kind { + PendingKind::OpenRead { + transfer_id: expected, + } => Some(expected), + PendingKind::OpenWrite | PendingKind::Write { .. } | PendingKind::CloseWrite { .. } => { + match self.transfer.as_ref() { + Some(Transfer::Write(write)) => Some(write.id), + _ => None, + } + } + PendingKind::Read { .. } | PendingKind::CloseRead { .. } => { + match self.transfer.as_ref() { + Some(Transfer::Read(read)) => Some(read.id), + _ => None, + } + } + PendingKind::Abort { + transfer_id: expected, + } => Some(expected), + }; + expected == Some(transfer_id) + } + + fn transfer_id(&mut self) -> u16 { + let id = self.next_transfer_id; + self.next_transfer_id = self.next_transfer_id.wrapping_add(1).max(1); id } - fn send( - &mut self, - operation: Operation, - id: u32, - message: M, - sequence: &mut u8, - ) -> MAVLinkV2MessageRaw { - let mut raw = MAVLinkV2MessageRaw::new(); - raw.serialize_message( - MavHeader { - sequence: *sequence, - system_id: CLIENT_SYSTEM_ID, - component_id: CLIENT_COMPONENT_ID, - }, - &message, - ); - *sequence = sequence.wrapping_add(1); + fn send(&mut self, kind: PendingKind, message: MavMessage) -> MavlinkFrame { + let raw = match message { + MavMessage::FILE_CONTROL(data) => self.encoder.encode(&data), + MavMessage::FILE_DATA(data) => self.encoder.encode(&data), + _ => unreachable!("file request encoder returned another message"), + }; self.pending = Some(Pending { - id, - operation, + kind, raw, sent_at: Instant::now(), attempts_left: REQUEST_ATTEMPTS - 1, }); raw } -} -const fn request_timeout(operation: Operation) -> Duration { - match operation { - Operation::Commit => COMMIT_TIMEOUT, - Operation::Info - | Operation::Descriptor(_) - | Operation::EnumOption { .. } - | Operation::Value(_) - | Operation::Begin - | Operation::Set(_) - | Operation::Validate - | Operation::Abort => REQUEST_TIMEOUT, + fn fail(&mut self, detail: impl Into) -> Outcome { + self.pending = None; + self.transfer = None; + self.abort_failure = None; + self.open_conflict_recovery = OpenConflictRecovery::Disabled; + Outcome::Event(ClientEvent::Failure(detail.into())) + } + + fn durable_state_unknown(&mut self, detail: impl Into) -> Outcome { + let detail = detail.into(); + self.downloaded = None; + self.transfer = None; + self.pending = None; + self.abort_failure = None; + self.open_conflict_recovery = OpenConflictRecovery::Disabled; + Outcome::Event(ClientEvent::DurableStateUnknown(detail)) + } + + const fn local_target(&self) -> Target { + Target::new(self.local.system(), self.local.component()) + } + + fn follow_up(&mut self, request: Result) -> Outcome { + match request { + Ok(request) => Outcome::Send(request), + Err(error) => self.fail(error), + } + } + + fn rediscovery_required(&mut self, detail: impl Into) -> Outcome { + let detail = detail.into(); + self.reset(); + Outcome::Event(ClientEvent::RediscoveryRequired(detail)) } } -const fn operation_name(operation: Operation) -> &'static str { - match operation { - Operation::Info => "INFO", - Operation::Descriptor(_) => "DESCRIPTOR", - Operation::EnumOption { .. } => "ENUM_OPTION", - Operation::Value(_) => "VALUE", - Operation::Begin => "BEGIN", - Operation::Set(_) => "SET", - Operation::Validate => "VALIDATE", - Operation::Commit => "COMMIT", - Operation::Abort => "ABORT", +fn validate_edits(config: &RemoteConfig, edits: &[Edit]) -> Result<(), String> { + for edit in edits { + let Some(field) = config.fields.iter().find(|field| field.id == edit.field_id) else { + return Err(format!("unknown field id {}", edit.field_id.get())); + }; + if edit.value.kind() != field.kind { + return Err(format!( + "field {} has the wrong value type", + edit.field_id.get() + )); + } + if !field.options.is_empty() + && !matches!( + edit.value, + ConfigValue::U8(value) + if field.options.iter().any(|option| option.value == value) + ) + { + return Err(format!( + "field {} has an unnamed enum value", + edit.field_id.get() + )); + } + } + Ok(()) +} + +fn timeout(kind: PendingKind) -> Duration { + if matches!(kind, PendingKind::CloseWrite { .. }) { + CLOSE_WRITE_TIMEOUT + } else { + REQUEST_TIMEOUT } } -fn follow_up(request: Result) -> Outcome { - match request { - Ok(request) => (None, Some(request)), - Err(error) => (Some(ClientEvent::Failure(error)), None), - } -} - -fn response_id(message: &MavMessage) -> Option { - match message { - MavMessage::KAPTAN_CONFIG_INFO(data) => Some(data.request_id), - MavMessage::KAPTAN_CONFIG_DESCRIPTOR(data) => Some(data.request_id), - MavMessage::KAPTAN_CONFIG_ENUM_OPTION(data) => Some(data.request_id), - MavMessage::KAPTAN_CONFIG_VALUE(data) => Some(data.request_id), - MavMessage::KAPTAN_CONFIG_RESULT(data) => Some(data.request_id), - _ => None, - } -} - -fn value_kind(kind: KaptanConfigValueKind) -> ValueKind { +fn operation_name(kind: PendingKind) -> &'static str { match kind { - KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_BOOL => ValueKind::Bool, - KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_I32 => ValueKind::I32, - KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32 => ValueKind::U32, - KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U64 => ValueKind::U64, - KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F32 => ValueKind::F32, - KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_F64 => ValueKind::F64, + PendingKind::OpenRead { .. } => "open-read", + PendingKind::Read { .. } => "read", + PendingKind::CloseRead { .. } => "close-read", + PendingKind::OpenWrite => "open-write", + PendingKind::Write { .. } => "write", + PendingKind::CloseWrite { .. } => "close-write", + PendingKind::Abort { .. } => "abort", } } -fn config_state(state: KaptanConfigState) -> String { - match state { - KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE => "active", - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING => "editing", - KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED => "restart required", +fn control_matches(kind: PendingKind, data: &ControlResponse) -> bool { + let successful = data.status == FileStatus::Ok; + match kind { + PendingKind::OpenRead { .. } => { + data.operation == FileOperation::OpenRead && data.position == 0 + } + PendingKind::OpenWrite => data.operation == FileOperation::OpenWrite && data.position == 0, + PendingKind::Read { position } => { + data.operation == FileOperation::Read && !successful && data.position == position + } + PendingKind::Write { position, next } => { + data.operation == FileOperation::WriteAck + && data.position == if successful { next } else { position } + } + PendingKind::CloseRead { position } | PendingKind::CloseWrite { position } => { + data.operation == FileOperation::Close && data.position == position + } + PendingKind::Abort { .. } => data.operation == FileOperation::Abort && data.position == 0, } - .into() } -fn wire_text(bytes: &[u8; N]) -> Result { - let length = bytes.iter().position(|byte| *byte == 0).unwrap_or(N); - std::str::from_utf8(&bytes[..length]) - .map(str::to_owned) - .map_err(|_| "controller returned non-UTF-8 descriptor text".into()) +fn rejected_detail(data: &ControlResponse) -> String { + if let Some((detail, text)) = KcfgResultDetail::decode(data.content.as_bytes()) { + let mut identities = Vec::new(); + if let Some(field) = detail.primary() { + identities.push(format!("field {}", field.get())); + } + if let Some(field) = detail.related_field() { + identities.push(format!("related field {}", field.get())); + } + if let Some(rule) = detail.rule() { + identities.push(format!("rule {} ({rule:?})", rule.code())); + } + let suffix = if identities.is_empty() { + String::new() + } else { + format!(" [{}]", identities.join(", ")) + }; + return if text.is_empty() { + format!("configuration file rejected: {:?}{suffix}", data.status) + } else { + format!( + "configuration file rejected: {:?}: {text}{suffix}", + data.status + ) + }; + } + let context = std::str::from_utf8(data.content.as_bytes()) + .ok() + .filter(|text| !text.is_empty()); + match context { + Some(context) => format!("configuration file rejected: {:?}: {context}", data.status), + None => format!("configuration file rejected: {:?}", data.status), + } +} + +// KCFG owns the schema and value representation. These two adapters are the +// only place where the host configurator depends on that artifact codec. +fn decode_document(bytes: &[u8]) -> Result { + kaptan_config::decode_kcfg(bytes) + .map_err(|error| format!("invalid KCFG artifact: {error:?}")) + .and_then(remote_config) +} + +fn remote_config(artifact: kaptan_config::KcfgArtifact<'_>) -> Result { + let mut fields = Vec::with_capacity(usize::from(artifact.field_count())); + for field in artifact.fields() { + let mut options = Vec::with_capacity(field.enum_options().len()); + for option in field.enum_options() { + options.push(RemoteEnumOption { + value: option.value(), + label: option.label().into(), + }); + } + let field = RemoteField { + id: field.id(), + key: field.key().into(), + unit: field.unit().into(), + kind: field.kind(), + minimum: field.minimum(), + maximum: field.maximum(), + factory: field.factory_value(), + value: field.value(), + options, + }; + if fields + .iter() + .any(|previous: &RemoteField| previous.key == field.key) + { + return Err("KCFG artifact contains a duplicate display key".into()); + } + validate_remote_field(&field)?; + fields.push(field); + } + Ok(RemoteConfig { + schema_id: artifact.schema_id(), + restart_required: artifact.restart_required(), + fields, + }) +} + +fn validate_remote_field(field: &RemoteField) -> Result<(), String> { + let values = [ + Some(field.value), + Some(field.factory), + field.minimum, + field.maximum, + ]; + if values.into_iter().flatten().any(non_finite) { + return Err(format!( + "KCFG field {} contains a non-finite value", + field.id.get() + )); + } + if field.minimum.is_some_and(|minimum| { + field + .maximum + .is_some_and(|maximum| scalar_less_than(maximum, minimum)) + }) || field + .minimum + .is_some_and(|minimum| scalar_less_than(field.value, minimum)) + || field + .maximum + .is_some_and(|maximum| scalar_less_than(maximum, field.value)) + || field + .minimum + .is_some_and(|minimum| scalar_less_than(field.factory, minimum)) + || field + .maximum + .is_some_and(|maximum| scalar_less_than(maximum, field.factory)) + { + return Err(format!( + "KCFG field {} has inconsistent bounds", + field.id.get() + )); + } + if !field.options.is_empty() + && ![field.value, field.factory].into_iter().all(|value| { + matches!( + value, + ConfigValue::U8(value) + if field.options.iter().any(|option| option.value == value) + ) + }) + { + return Err(format!( + "KCFG field {} contains an unnamed enum value", + field.id.get() + )); + } + Ok(()) +} + +const fn non_finite(value: ConfigValue) -> bool { + match value { + ConfigValue::F32(value) => !value.is_finite(), + ConfigValue::F64(value) => !value.is_finite(), + ConfigValue::Bool(_) + | ConfigValue::U8(_) + | ConfigValue::U16(_) + | ConfigValue::I32(_) + | ConfigValue::U32(_) + | ConfigValue::U64(_) => false, + } +} + +fn encode_edited_document(document: &[u8], edits: &[Edit]) -> Result, String> { + let artifact = kaptan_config::decode_kcfg(document) + .map_err(|error| format!("cannot edit KCFG artifact: {error:?}"))?; + let mut output = vec![0_u8; document.len()]; + let encoded = kaptan_config::encode_edited_kcfg(&artifact, true, &mut output, |field| { + edits + .iter() + .find(|edit| edit.field_id == field.id()) + .map_or_else(|| field.value(), |edit| edit.value) + }) + .map_err(|error| format!("cannot encode KCFG artifact: {error:?}"))?; + let length = encoded.len(); + output.truncate(length); + Ok(output) +} + +fn encode_document_state(document: &[u8], restart_required: bool) -> Result, String> { + let artifact = kaptan_config::decode_kcfg(document) + .map_err(|error| format!("cannot update KCFG artifact state: {error:?}"))?; + let mut output = vec![0_u8; document.len()]; + let encoded = + kaptan_config::encode_edited_kcfg(&artifact, restart_required, &mut output, |field| { + field.value() + }) + .map_err(|error| format!("cannot encode KCFG artifact state: {error:?}"))?; + let length = encoded.len(); + output.truncate(length); + Ok(output) } #[cfg(test)] mod tests { use super::*; + use kaptan_file_mavlink::{ + ControlResponse, DataResponse, FileRequest, FileResponse, FileStatus, + wire::{decode_request, encode_response}, + }; - const TARGET: (u8, u8) = (42, 9); + const CONTROLLER: Target = Target::new(42, 7); + const CLIENT: Principal = Principal::new(255, 190); + const FIELD_ID: FieldId = FieldId::new(401); - fn response(message: MavMessage) -> MAVLinkV2MessageRaw { - let mut raw = MAVLinkV2MessageRaw::new(); - raw.serialize_message( - MavHeader { - sequence: 0, - system_id: TARGET.0, - component_id: TARGET.1, - }, - &message, - ); - raw + fn artifact(current: u8) -> Vec { + let key = b"telemetry.heartbeat_hz"; + let unit = b"hz"; + let mut bytes = Vec::new(); + bytes.extend_from_slice(b"KCFG"); + bytes.push(1); // KCFG version 1. + bytes.push(0); + bytes.extend_from_slice(b"UNKNOWN-SCHEMA01"); + bytes.extend_from_slice(&1_u16.to_le_bytes()); + bytes.extend_from_slice(&FIELD_ID.get().to_le_bytes()); + bytes.push(ValueKind::U8 as u8); + bytes.push(3); + bytes.push(key.len() as u8); + bytes.push(unit.len() as u8); + bytes.push(0); + bytes.extend_from_slice(&[current, 2, 1, 100]); + bytes.extend_from_slice(key); + bytes.extend_from_slice(unit); + let checksum = crc32c(&bytes); + bytes.extend_from_slice(&checksum.to_be_bytes()); + bytes } - fn request_id(raw: &MAVLinkV2MessageRaw) -> u32 { - let message = - MavMessage::parse(MavlinkVersion::V2, raw.message_id(), raw.payload()).unwrap(); - match message { - MavMessage::KAPTAN_CONFIG_ABORT(data) => data.request_id, - MavMessage::KAPTAN_CONFIG_BEGIN(data) => data.request_id, - MavMessage::KAPTAN_CONFIG_SET_U32(data) => data.request_id, - MavMessage::KAPTAN_CONFIG_VALIDATE(data) => data.request_id, - MavMessage::KAPTAN_CONFIG_COMMIT(data) => data.request_id, - _ => panic!("unexpected request in save transcript"), + fn response(response: FileResponse) -> MavlinkFrame { + let mut encoder = MavlinkEncoder::new(CONTROLLER.system(), CONTROLLER.component()); + match encode_response(response) { + MavMessage::FILE_CONTROL(data) => encoder.encode(&data), + MavMessage::FILE_DATA(data) => encoder.encode(&data), + _ => unreachable!("file response encoder returned another message"), } } - fn rejected_result( - request_id: u32, - operation: KaptanConfigOperation, - session_id: u32, - revision: u32, - error: KaptanConfigError, - ) -> MAVLinkV2MessageRaw { - response(MavMessage::KAPTAN_CONFIG_RESULT( - kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA { - request_id, - session_id, - revision, - primary_field: 401, - related_field: 0, - validation_rule: 0, - operation, - result: KaptanConfigResult::KAPTAN_CONFIG_RESULT_REJECTED, - error, - config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - }, - )) + fn request(raw: &MavlinkFrame) -> FileRequest { + let message = parse_test_message(raw).unwrap(); + decode_request(&message).unwrap() } - fn configured_client() -> (ConfigClient, u8) { - let mut client = ConfigClient::new(); - client.target = Some(TARGET); - client.info = Some(RemoteInfo { - schema_id: *b"UNKNOWN-SCHEMA01", - state: "active".into(), - }); - client.field_count = 1; - client.fields.push(RemoteField { - id: 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), - options: Vec::new(), - }); - (client, 0) + fn sent(outcome: Outcome) -> MavlinkFrame { + let Outcome::Send(frame) = outcome else { + panic!("expected a follow-up frame"); + }; + frame } - fn open_save(client: &mut ConfigClient, sequence: &mut u8) -> MAVLinkV2MessageRaw { - let begin = client - .save( - vec![Edit { - field_id: 401, - value: Scalar::U32(30), - }], - sequence, + fn event(outcome: Outcome) -> ClientEvent { + let Outcome::Event(event) = outcome else { + panic!("expected a client event"); + }; + event + } + + fn control_response( + operation: FileOperation, + transfer_id: u16, + position: u32, + file_size: u32, + checksum: u32, + status: FileStatus, + ) -> MavlinkFrame { + control_response_with_content( + operation, + transfer_id, + position, + file_size, + checksum, + status, + Content::EMPTY, + ) + } + + fn control_response_with_content( + operation: FileOperation, + transfer_id: u16, + position: u32, + file_size: u32, + checksum: u32, + status: FileStatus, + content: Content, + ) -> MavlinkFrame { + response(FileResponse::Control(ControlResponse { + operation, + target: Target::new(CLIENT.system(), CLIENT.component()), + position, + file_size, + checksum_crc32c: checksum, + transfer_id, + status, + content, + })) + } + + fn discover(client: &mut ConfigClient, document: &[u8]) -> RemoteConfig { + let open = client.discover(CONTROLLER).unwrap(); + let FileRequest::Control(open) = request(&open) else { + panic!("open-read must be a control request"); + }; + assert_eq!(open.operation, FileOperation::OpenRead); + assert_eq!(open.content.as_bytes(), CONFIG_PATH); + assert_ne!(open.transfer_id, 0); + + let size = document.len() as u32; + let checksum = crc32c(document); + let read = sent(client.observe_frame(&control_response( + FileOperation::OpenRead, + open.transfer_id, + 0, + size, + checksum, + FileStatus::Ok, + ))); + let FileRequest::Control(read) = request(&read) else { + panic!("read must be a control request"); + }; + assert_eq!(read.operation, FileOperation::Read); + assert_eq!(read.position, 0); + + let mut data = [0_u8; CHUNK_BYTES]; + data[..document.len()].copy_from_slice(document); + let close = sent( + client.observe_frame(&response(FileResponse::Data(DataResponse { + target: Target::new(CLIENT.system(), CLIENT.component()), + position: 0, + transfer_id: open.transfer_id, + data, + }))), + ); + let FileRequest::Control(close) = request(&close) else { + panic!("close-read must be a control request"); + }; + assert_eq!(close.operation, FileOperation::Close); + assert_eq!(close.position, size); + + let event = event(client.observe_frame(&control_response( + FileOperation::Close, + open.transfer_id, + size, + size, + checksum, + FileStatus::Ok, + ))); + let ClientEvent::Configuration(config) = event else { + panic!("closed export must publish configuration"); + }; + config + } + + #[test] + fn transfer_crc_is_crc32c() { + assert_eq!(crc32c(b"123456789"), 0xe306_9283); + } + + #[test] + fn configurator_knows_only_the_canonical_config_path() { + assert_eq!(CONFIG_PATH, b"/config/user.kcfg"); + } + + #[test] + fn file_data_uses_the_full_declared_chunk() { + assert_eq!(CHUNK_BYTES, 246); + } + + #[test] + fn exact_local_endpoints_isolate_clients_with_the_same_transfer_id() { + let left_endpoint = Principal::new(250, 190); + let right_endpoint = Principal::new(251, 190); + let mut left = ConfigClient::new(left_endpoint); + let mut right = ConfigClient::new(right_endpoint); + let left_open = left.discover(CONTROLLER).unwrap(); + let right_open = right.discover(CONTROLLER).unwrap(); + assert_eq!(left_open.system_id(), left_endpoint.system()); + assert_eq!(right_open.system_id(), right_endpoint.system()); + let FileRequest::Control(left_request) = request(&left_open) else { + panic!("left discovery must open a file"); + }; + let FileRequest::Control(right_request) = request(&right_open) else { + panic!("right discovery must open a file"); + }; + assert_eq!(left_request.transfer_id, right_request.transfer_id); + + let document = artifact(20); + let reply = response(FileResponse::Control(ControlResponse { + operation: FileOperation::OpenRead, + target: Target::new(left_endpoint.system(), left_endpoint.component()), + position: 0, + file_size: document.len() as u32, + checksum_crc32c: crc32c(&document), + transfer_id: left_request.transfer_id, + status: FileStatus::Ok, + content: Content::EMPTY, + })); + assert!(matches!(left.observe_frame(&reply), Outcome::Send(_))); + assert!(matches!(right.observe_frame(&reply), Outcome::Idle)); + assert!(matches!( + right.pending.as_ref().map(|pending| pending.kind), + Some(PendingKind::OpenRead { .. }) + )); + } + + #[test] + fn discovery_rejection_resets_for_a_later_heartbeat() { + let mut client = ConfigClient::new(CLIENT); + let open = client.discover(CONTROLLER).unwrap(); + let FileRequest::Control(open) = request(&open) else { + panic!("discovery must open a file"); + }; + let event = event(client.observe_frame(&control_response( + FileOperation::OpenRead, + open.transfer_id, + 0, + 0, + 0, + FileStatus::Busy, + ))); + assert!(matches!(event, ClientEvent::RediscoveryRequired(_))); + assert_eq!(client.target, None); + assert!(client.pending.is_none()); + } + + #[test] + fn stateful_rejection_and_noncanonical_data_emit_abort() { + let document = artifact(20); + for noncanonical_data in [false, true] { + let mut client = ConfigClient::new(CLIENT); + let open = client.discover(CONTROLLER).unwrap(); + let FileRequest::Control(open) = request(&open) else { + panic!("discovery must open a file"); + }; + let read = sent(client.observe_frame(&control_response( + FileOperation::OpenRead, + open.transfer_id, + 0, + document.len() as u32, + crc32c(&document), + FileStatus::Ok, + ))); + assert!(matches!(request(&read), FileRequest::Control(_))); + + let abort = sent(if noncanonical_data { + let mut data = [0_u8; CHUNK_BYTES]; + data[..document.len()].copy_from_slice(&document); + data[document.len()] = 1; + client.observe_frame(&response(FileResponse::Data(DataResponse { + target: Target::new(CLIENT.system(), CLIENT.component()), + position: 0, + transfer_id: open.transfer_id, + data, + }))) + } else { + client.observe_frame(&control_response( + FileOperation::Read, + open.transfer_id, + 0, + document.len() as u32, + crc32c(&document), + FileStatus::InvalidPosition, + )) + }); + let FileRequest::Control(abort) = request(&abort) else { + panic!("rejected active transfer must abort"); + }; + assert_eq!(abort.operation, FileOperation::Abort); + assert_eq!(abort.transfer_id, open.transfer_id); + } + } + + #[test] + fn delayed_write_ack_does_not_consume_the_next_pending_chunk() { + let response = |position, status| ControlResponse { + operation: FileOperation::WriteAck, + target: Target::new(CLIENT.system(), CLIENT.component()), + position, + file_size: 600, + checksum_crc32c: 0, + transfer_id: 9, + status, + content: Content::EMPTY, + }; + let pending = PendingKind::Write { + position: CHUNK_BYTES as u32, + next: 2 * CHUNK_BYTES as u32, + }; + + assert!(!control_matches( + pending, + &response(CHUNK_BYTES as u32, FileStatus::Ok) + )); + assert!(control_matches( + pending, + &response(2 * CHUNK_BYTES as u32, FileStatus::Ok) + )); + assert!(control_matches( + pending, + &response(CHUNK_BYTES as u32, FileStatus::InvalidPosition) + )); + } + + #[test] + fn one_file_export_discovers_an_unknown_schema_without_a_catalog() { + let mut client = ConfigClient::new(CLIENT); + let config = discover(&mut client, &artifact(20)); + + assert_eq!(config.schema_id, *b"UNKNOWN-SCHEMA01"); + assert!(!config.restart_required); + assert_eq!(config.fields.len(), 1); + assert_eq!(config.fields[0].id, FIELD_ID); + assert_eq!(config.fields[0].key, "telemetry.heartbeat_hz"); + assert_eq!(config.fields[0].value, ConfigValue::U8(20)); + assert_eq!(config.fields[0].factory, ConfigValue::U8(2)); + assert_eq!(config.fields[0].minimum, Some(ConfigValue::U8(1))); + assert_eq!(config.fields[0].maximum, Some(ConfigValue::U8(100))); + } + + #[test] + fn reconnect_retries_once_then_waits_quietly_past_the_replay_lease() { + let mut client = ConfigClient::new(CLIENT); + let first = client.discover(CONTROLLER).unwrap(); + let FileRequest::Control(first) = request(&first) else { + panic!("discovery must open the configuration file"); + }; + + let retry = sent(client.observe_frame(&control_response( + FileOperation::OpenRead, + first.transfer_id, + 0, + 0, + 0, + FileStatus::RequestConflict, + ))); + let FileRequest::Control(retry) = request(&retry) else { + panic!("conflict recovery must reopen the configuration file"); + }; + assert_eq!(retry.operation, FileOperation::OpenRead); + assert_ne!(retry.transfer_id, first.transfer_id); + + assert!(matches!( + client.observe_frame(&control_response( + FileOperation::OpenRead, + retry.transfer_id, + 0, + 0, + 0, + FileStatus::RequestConflict, + )), + Outcome::Idle + )); + + let OpenConflictRecovery::Waiting(retry_at) = client.open_conflict_recovery else { + panic!("the second conflict must wait for the controller lease"); + }; + assert!( + retry_at.duration_since(Instant::now()) + > Duration::from_secs(u64::from(TRANSFER_REPLAY_LEASE_SECONDS)) + ); + let next_transfer_id = client.next_transfer_id; + for _ in 0..3 { + assert!(matches!(client.retry_due(), Outcome::Idle)); + assert_eq!(client.next_transfer_id, next_transfer_id); + } + + client.open_conflict_recovery = + OpenConflictRecovery::Waiting(Instant::now() - REQUEST_TIMEOUT); + let delayed = sent(client.retry_due()); + let FileRequest::Control(delayed) = request(&delayed) else { + panic!("lease expiry must reopen the configuration file"); + }; + assert_eq!(delayed.operation, FileOperation::OpenRead); + assert_ne!(delayed.transfer_id, first.transfer_id); + assert_ne!(delayed.transfer_id, retry.transfer_id); + + let document = artifact(20); + let read = sent(client.observe_frame(&control_response( + FileOperation::OpenRead, + delayed.transfer_id, + 0, + document.len() as u32, + crc32c(&document), + FileStatus::Ok, + ))); + let FileRequest::Control(read) = request(&read) else { + panic!("the reopened file must advance to its first read"); + }; + assert_eq!(read.operation, FileOperation::Read); + assert_eq!(read.transfer_id, delayed.transfer_id); + } + + #[test] + fn save_uploads_one_complete_edited_artifact_and_closes_it_once() { + let mut client = ConfigClient::new(CLIENT); + let original = artifact(20); + discover(&mut client, &original); + + let open = client + .save(vec![Edit { + field_id: FIELD_ID, + value: ConfigValue::U8(30), + }]) + .unwrap(); + let FileRequest::Control(open) = request(&open) else { + panic!("open-write must be a control request"); + }; + assert_eq!(open.operation, FileOperation::OpenWrite); + assert_eq!(open.content.as_bytes(), CONFIG_PATH); + assert_eq!(open.file_size as usize, original.len()); + + let data = sent(client.observe_frame(&control_response( + FileOperation::OpenWrite, + open.transfer_id, + 0, + open.file_size, + open.checksum_crc32c, + FileStatus::Ok, + ))); + let FileRequest::Data(data) = request(&data) else { + panic!("save body must be file data"); + }; + let written = &data.data[..open.file_size as usize]; + assert_eq!(crc32c(written), open.checksum_crc32c); + let edited = kaptan_config::decode_kcfg(written).unwrap(); + assert!(edited.restart_required()); + assert_eq!(edited.fields().next().unwrap().value(), ConfigValue::U8(30)); + + let close = sent(client.observe_frame(&control_response( + FileOperation::WriteAck, + open.transfer_id, + open.file_size, + open.file_size, + open.checksum_crc32c, + FileStatus::Ok, + ))); + let FileRequest::Control(close) = request(&close) else { + panic!("complete upload must close"); + }; + assert_eq!(close.operation, FileOperation::Close); + assert_eq!(close.position, open.file_size); + + let event = event(client.observe_frame(&control_response_with_content( + FileOperation::Close, + open.transfer_id, + open.file_size, + open.file_size, + open.checksum_crc32c, + FileStatus::Ok, + Content::try_new(&KcfgResultDetail::success(true).encode()).unwrap(), + ))); + let ClientEvent::Configuration(config) = event else { + panic!("completed import must publish configuration"); + }; + assert!(config.restart_required); + assert_eq!(config.fields[0].value, ConfigValue::U8(30)); + } + + #[test] + fn save_can_restore_the_boot_active_values_without_forcing_restart_state() { + let document = encode_edited_document( + &artifact(20), + &[Edit { + field_id: FIELD_ID, + value: ConfigValue::U8(2), + }], + ) + .unwrap(); + let mut client = ConfigClient::new(CLIENT); + client.transfer = Some(Transfer::Write(WriteTransfer { + id: 9, + position: document.len() as u32, + bytes: document.clone(), + checksum: crc32c(&document), + })); + + let event = event(client.closed_write(ControlResponse { + operation: FileOperation::Close, + target: Target::new(CLIENT.system(), CLIENT.component()), + position: document.len() as u32, + file_size: document.len() as u32, + checksum_crc32c: crc32c(&document), + transfer_id: 9, + status: FileStatus::Ok, + content: Content::try_new(&KcfgResultDetail::success(false).encode()).unwrap(), + })); + + let ClientEvent::Configuration(config) = event else { + panic!("restored values must publish configuration"); + }; + assert!(!config.restart_required); + assert!( + !kaptan_config::decode_kcfg(&client.downloaded.as_ref().unwrap().document) + .unwrap() + .restart_required() + ); + } + + fn client_awaiting_close() -> (ConfigClient, Vec) { + let document = encode_edited_document( + &artifact(20), + &[Edit { + field_id: FIELD_ID, + value: ConfigValue::U8(30), + }], + ) + .unwrap(); + let mut client = ConfigClient::new(CLIENT); + client.target = Some(CONTROLLER); + client.transfer = Some(Transfer::Write(WriteTransfer { + id: 9, + position: document.len() as u32, + checksum: crc32c(&document), + bytes: document.clone(), + })); + client + .request_close( + PendingKind::CloseWrite { + position: document.len() as u32, + }, + 9, + document.len() as u32, ) .unwrap(); - let accepted = result( - request_id(&begin), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 0, - ); - let (event, set) = client.observe(&accepted, sequence); - assert!(event.is_none()); - set.expect("accepted BEGIN must advance to SET") - } - - fn accept_set( - client: &mut ConfigClient, - sequence: &mut u8, - set: &MAVLinkV2MessageRaw, - ) -> MAVLinkV2MessageRaw { - let accepted = result( - request_id(set), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 1, - ); - let (event, validate) = client.observe(&accepted, sequence); - assert!(event.is_none()); - validate.expect("accepted SET must advance to VALIDATE") - } - - fn assert_abort(request: &MAVLinkV2MessageRaw, session_id: u32) { - let MavMessage::KAPTAN_CONFIG_ABORT(data) = - MavMessage::parse(MavlinkVersion::V2, request.message_id(), request.payload()).unwrap() - else { - panic!("cleanup request was not ABORT"); - }; - assert_eq!(data.session_id, session_id); - assert_eq!((data.target_system, data.target_component), TARGET); - } - - fn accept_abort( - client: &mut ConfigClient, - sequence: &mut u8, - abort: &MAVLinkV2MessageRaw, - revision: u32, - ) -> String { - let accepted = result( - request_id(abort), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, - 0, - revision, - ); - let (event, next) = client.observe(&accepted, sequence); - assert!(next.is_none()); - let Some(ClientEvent::Failure(detail)) = event else { - panic!("confirmed cleanup must report the original failure"); - }; - detail - } - - fn result( - request_id: u32, - operation: KaptanConfigOperation, - result: KaptanConfigResult, - state: KaptanConfigState, - session_id: u32, - revision: u32, - ) -> MAVLinkV2MessageRaw { - response(MavMessage::KAPTAN_CONFIG_RESULT( - kaptan_mavlink::message::kaptan::KAPTAN_CONFIG_RESULT_DATA { - request_id, - session_id, - revision, - primary_field: 0, - related_field: 0, - validation_rule: 0, - operation, - result, - error: KaptanConfigError::KAPTAN_CONFIG_ERROR_NONE, - config_state: state, - }, - )) + (client, document) } #[test] - fn scalar_bits_preserve_every_wire_type() { - assert_eq!(scalar_from_bits(ValueKind::Bool, 1), Ok(Scalar::Bool(true))); - assert_eq!( - scalar_from_bits(ValueKind::I32, (-17_i32) as u32 as u64), - Ok(Scalar::I32(-17)) - ); - assert_eq!(scalar_from_bits(ValueKind::U32, 17), Ok(Scalar::U32(17))); - assert_eq!( - scalar_from_bits(ValueKind::U64, u64::MAX), - Ok(Scalar::U64(u64::MAX)) - ); - assert_eq!( - scalar_from_bits(ValueKind::F32, 1.25_f32.to_bits() as u64), - Ok(Scalar::F32(1.25)) - ); - assert_eq!( - scalar_from_bits(ValueKind::F64, 1.25_f64.to_bits()), - Ok(Scalar::F64(1.25)) - ); - assert_eq!( - scalar_json(Scalar::U64(u64::MAX)), - serde_json::Value::String(u64::MAX.to_string()) - ); - assert!(scalar_from_bits(ValueKind::F32, f32::NAN.to_bits().into()).is_err()); + fn malformed_success_and_storage_uncertainty_never_publish_configuration() { + let (mut malformed, document) = client_awaiting_close(); + let malformed_event = event(malformed.observe_frame(&control_response( + FileOperation::Close, + 9, + document.len() as u32, + document.len() as u32, + crc32c(&document), + FileStatus::Ok, + ))); + assert!(matches!( + malformed_event, + ClientEvent::RediscoveryRequired(_) + )); + + let (mut uncertain, document) = client_awaiting_close(); + let uncertain_event = event(uncertain.observe_frame(&control_response( + FileOperation::Close, + 9, + document.len() as u32, + document.len() as u32, + crc32c(&document), + FileStatus::StorageFailure, + ))); + assert!(matches!( + uncertain_event, + ClientEvent::DurableStateUnknown(_) + )); } #[test] - fn client_has_no_compiled_board_or_schema_identity() { - let client = ConfigClient::new(); - assert!(client.target.is_none()); - assert!(client.info.is_none()); - assert!(client.fields.is_empty()); - } - - #[test] - fn commit_timeout_covers_the_targets_synchronous_flash_erase() { - assert_eq!(request_timeout(Operation::Commit), Duration::from_secs(5)); - assert_eq!(request_timeout(Operation::Validate), REQUEST_TIMEOUT); - } - - #[test] - fn rejected_set_is_aborted_before_the_original_failure_is_reported() { - let (mut client, mut sequence) = configured_client(); - let set = open_save(&mut client, &mut sequence); - let rejected = rejected_result( - request_id(&set), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET, - 50, - 0, - KaptanConfigError::KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM, - ); - let (event, abort) = client.observe(&rejected, &mut sequence); - assert!(event.is_none()); - let abort = abort.expect("rejected SET must be followed by ABORT"); - assert_abort(&abort, 50); - // ABORT has no expected-revision precondition. The target's current - // revision is informative and may be newer than the last accepted SET. - let detail = accept_abort(&mut client, &mut sequence, &abort, 9); - assert!(detail.contains("KAPTAN_CONFIG_ERROR_ABOVE_MAXIMUM")); - assert!(open_save(&mut client, &mut sequence).message_id() != 0); - } - - #[test] - fn rejected_validate_is_aborted_at_the_last_known_revision() { - let (mut client, mut sequence) = configured_client(); - let set = open_save(&mut client, &mut sequence); - let validate = accept_set(&mut client, &mut sequence, &set); - let rejected = rejected_result( - request_id(&validate), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, - 50, - 1, - KaptanConfigError::KAPTAN_CONFIG_ERROR_CROSS_FIELD, - ); - let (event, abort) = client.observe(&rejected, &mut sequence); - assert!(event.is_none()); - let abort = abort.expect("rejected VALIDATE must be followed by ABORT"); - assert_abort(&abort, 50); - let detail = accept_abort(&mut client, &mut sequence, &abort, 1); - assert!(detail.contains("KAPTAN_CONFIG_ERROR_CROSS_FIELD")); - } - - #[test] - fn same_principal_busy_begin_uses_the_returned_lease_for_cleanup() { - let (mut client, mut sequence) = configured_client(); - let begin = client - .save( - vec![Edit { - field_id: 401, - value: Scalar::U32(30), - }], - &mut sequence, - ) - .unwrap(); - let rejected = rejected_result( - request_id(&begin), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN, - 77, - 4, - KaptanConfigError::KAPTAN_CONFIG_ERROR_SESSION_BUSY, - ); - let (event, abort) = client.observe(&rejected, &mut sequence); - assert!(event.is_none()); - let abort = abort.expect("owned busy lease must be aborted"); - assert_abort(&abort, 77); - let detail = accept_abort(&mut client, &mut sequence, &abort, 4); - assert!(detail.contains("KAPTAN_CONFIG_ERROR_SESSION_BUSY")); - } - - #[test] - fn exhausted_set_retries_abort_but_commit_retries_require_rediscovery() { - let (mut client, mut sequence) = configured_client(); - let _set = open_save(&mut client, &mut sequence); + fn exhausted_close_retries_leave_durable_state_unknown() { + let (mut client, _) = client_awaiting_close(); let pending = client.pending.as_mut().unwrap(); pending.attempts_left = 0; - pending.sent_at = Instant::now() - REQUEST_TIMEOUT; - let (event, abort) = client.retry_due(&mut sequence); - assert!(event.is_none()); - let abort = abort.expect("known timed-out SET session must be aborted"); - assert_abort(&abort, 50); - let detail = accept_abort(&mut client, &mut sequence, &abort, 0); - assert!(detail.contains("SET request timed out")); - - let (mut client, mut sequence) = configured_client(); - let set = open_save(&mut client, &mut sequence); - let validate = accept_set(&mut client, &mut sequence, &set); - let accepted = result( - request_id(&validate), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 1, - ); - let (event, commit) = client.observe(&accepted, &mut sequence); - assert!(event.is_none()); - assert!(commit.is_some()); - let pending = client.pending.as_mut().unwrap(); - pending.attempts_left = 0; - pending.sent_at = Instant::now() - COMMIT_TIMEOUT; - let (event, next) = client.retry_due(&mut sequence); - assert!(next.is_none(), "an uncertain COMMIT must never be aborted"); - let Some(ClientEvent::RediscoveryRequired(detail)) = event else { - panic!("an uncertain COMMIT must invalidate discovery"); - }; - assert!(detail.contains("commit outcome is indeterminate")); - assert!(client.target.is_none()); - assert!(client.fields.is_empty()); - assert!(client.save.is_none()); + pending.sent_at = Instant::now() - CLOSE_WRITE_TIMEOUT; + let event = event(client.retry_due()); + assert!(matches!(event, ClientEvent::DurableStateUnknown(_))); + assert!(client.pending.is_none()); + assert!(client.transfer.is_none()); } #[test] - fn storage_failed_commit_cleans_up_but_remains_indeterminate() { - let (mut client, mut sequence) = configured_client(); - let set = open_save(&mut client, &mut sequence); - let validate = accept_set(&mut client, &mut sequence, &set); - let accepted = result( - request_id(&validate), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 1, + fn typed_kcfg_rejection_retains_field_and_rule_identity() { + let violation = kaptan_config::ConfigViolation::related( + kaptan_config::FieldId::new(401), + kaptan_config::FieldId::new(402), + kaptan_config::ValidationRule::Inconsistent, ); - let (event, commit) = client.observe(&accepted, &mut sequence); - assert!(event.is_none()); - let commit = commit.unwrap(); - let rejected = rejected_result( - request_id(&commit), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT, - 50, - 1, - KaptanConfigError::KAPTAN_CONFIG_ERROR_STORAGE_FAILURE, - ); - let (event, abort) = client.observe(&rejected, &mut sequence); - assert!(event.is_none()); - let abort = abort.expect("storage-failed COMMIT must clean up its RAM lease"); - assert_abort(&abort, 50); - - let accepted = result( - request_id(&abort), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_ABORT, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, - 0, - 1, - ); - let (event, next) = client.observe(&accepted, &mut sequence); - assert!(next.is_none()); - let Some(ClientEvent::RediscoveryRequired(detail)) = event else { - panic!("storage failure must not be presented as a definitive commit rejection"); - }; - assert!(detail.contains("power-cycle and rediscover")); - assert!(client.target.is_none()); - } - - #[test] - fn enum_options_are_discovered_one_at_a_time_without_a_host_catalog() { - let mut client = ConfigClient::new(); - let mut sequence = 0; - client.discover(TARGET, &mut sequence).unwrap(); - - let info = response(MavMessage::KAPTAN_CONFIG_INFO(KAPTAN_CONFIG_INFO_DATA { - request_id: 1, - schema_id: *b"UNKNOWN-SCHEMA01", - field_count: 1, - protocol_major: PROTOCOL_MAJOR, - protocol_minor: PROTOCOL_MINOR, - config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, - })); - let (_, descriptor_request) = client.observe(&info, &mut sequence); - assert!(descriptor_request.is_some()); - - let descriptor = response(MavMessage::KAPTAN_CONFIG_DESCRIPTOR( - KAPTAN_CONFIG_DESCRIPTOR_DATA { - minimum_bits: 3, - maximum_bits: 4, - default_bits: 3, - request_id: 2, - descriptor_index: 0, - field_id: 108, - enum_option_count: 2, - display_key: "future.logging.level_filter".into(), - unit: "log_level".into(), - value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, - flags: DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM, - }, - )); - let (event, first_request) = client.observe(&descriptor, &mut sequence); - assert!(event.is_none()); - let first_request = first_request.expect("first enum option is requested"); - let MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(first) = MavMessage::parse( - MavlinkVersion::V2, - first_request.message_id(), - first_request.payload(), - ) - .unwrap() else { - panic!("descriptor with choices must request an enum option"); - }; - assert_eq!((first.descriptor_index, first.option_index), (0, 0)); - - let first_option = response(MavMessage::KAPTAN_CONFIG_ENUM_OPTION( - KAPTAN_CONFIG_ENUM_OPTION_DATA { - value: 3, - request_id: first.request_id, - descriptor_index: 0, - field_id: 108, - option_index: 0, - label: "Info".into(), - }, - )); - let (event, second_request) = client.observe(&first_option, &mut sequence); - assert!(event.is_none()); - let second_request = second_request.expect("second enum option is requested"); - let MavMessage::KAPTAN_CONFIG_ENUM_OPTION_REQUEST(second) = MavMessage::parse( - MavlinkVersion::V2, - second_request.message_id(), - second_request.payload(), - ) - .unwrap() else { - panic!("enum discovery must remain stop-and-wait"); - }; - assert_eq!((second.descriptor_index, second.option_index), (0, 1)); - - let second_option = response(MavMessage::KAPTAN_CONFIG_ENUM_OPTION( - KAPTAN_CONFIG_ENUM_OPTION_DATA { - value: 4, - request_id: second.request_id, - descriptor_index: 0, - field_id: 108, - option_index: 1, - label: "Debug".into(), - }, - )); - let (event, value_request) = client.observe(&second_option, &mut sequence); - assert!(event.is_none()); - let value_request = value_request.expect("enum discovery advances to its value"); - let value_request_id = match MavMessage::parse( - MavlinkVersion::V2, - value_request.message_id(), - value_request.payload(), - ) - .unwrap() - { - MavMessage::KAPTAN_CONFIG_VALUE_REQUEST(data) => data.request_id, - _ => panic!("enum discovery must advance to VALUE_REQUEST"), - }; - let value = response(MavMessage::KAPTAN_CONFIG_VALUE(KAPTAN_CONFIG_VALUE_DATA { - value_bits: 3, - request_id: value_request_id, - session_id: 0, - revision: 0, - field_id: 108, - value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, - })); - let (event, next) = client.observe(&value, &mut sequence); - assert!(next.is_none()); - let Some(ClientEvent::Configuration(configuration)) = event else { - panic!("complete enum discovery must publish the configuration"); - }; - assert_eq!(configuration.fields[0].options.len(), 2); - assert_eq!(configuration.fields[0].options[0].label, "Info"); - assert_eq!(configuration.fields[0].options[0].value, Scalar::U32(3)); - assert_eq!(configuration.fields[0].options[1].label, "Debug"); - } - - #[test] - fn discovery_rejects_an_older_incompatible_protocol_version() { - let mut client = ConfigClient::new(); - let mut sequence = 0; - client.discover(TARGET, &mut sequence).unwrap(); - let info = response(MavMessage::KAPTAN_CONFIG_INFO(KAPTAN_CONFIG_INFO_DATA { - request_id: 1, - schema_id: *b"UNKNOWN-SCHEMA01", - field_count: 0, - protocol_major: 1, - protocol_minor: 1, - config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, - })); - - let (event, next) = client.observe(&info, &mut sequence); - assert!(next.is_none()); - let Some(ClientEvent::Failure(detail)) = event else { - panic!("incompatible minor version must fail discovery"); - }; - assert!(detail.contains("unsupported configuration protocol 1.1")); - } - - #[test] - fn unknown_schema_is_discovered_and_saved_by_descriptor_identity() { - let mut client = ConfigClient::new(); - let mut sequence = 0; - client.discover(TARGET, &mut sequence).unwrap(); - - let info = response(MavMessage::KAPTAN_CONFIG_INFO(KAPTAN_CONFIG_INFO_DATA { - request_id: 1, - schema_id: *b"UNKNOWN-SCHEMA01", - field_count: 1, - protocol_major: PROTOCOL_MAJOR, - protocol_minor: PROTOCOL_MINOR, - config_state: KaptanConfigState::KAPTAN_CONFIG_STATE_ACTIVE, - })); - let (event, descriptor_request) = client.observe(&info, &mut sequence); - assert!(event.is_none()); - assert!(descriptor_request.is_some()); - - let descriptor = response(MavMessage::KAPTAN_CONFIG_DESCRIPTOR( - KAPTAN_CONFIG_DESCRIPTOR_DATA { - minimum_bits: 1, - maximum_bits: 100, - default_bits: 20, - request_id: 2, - descriptor_index: 0, - field_id: 401, - enum_option_count: 0, - display_key: "future.sensor.rate_hz".into(), - unit: "Hz".into(), - value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, - flags: DESCRIPTOR_HAS_MINIMUM | DESCRIPTOR_HAS_MAXIMUM, - }, - )); - let (event, value_request) = client.observe(&descriptor, &mut sequence); - assert!(event.is_none()); - assert!(value_request.is_some()); - - let value = response(MavMessage::KAPTAN_CONFIG_VALUE(KAPTAN_CONFIG_VALUE_DATA { - value_bits: 25, - request_id: 3, - session_id: 0, - revision: 0, - field_id: 401, - value_kind: KaptanConfigValueKind::KAPTAN_CONFIG_VALUE_KIND_U32, - })); - let (event, next) = client.observe(&value, &mut sequence); - assert!(next.is_none()); - let ClientEvent::Configuration(configuration) = event.unwrap() else { - panic!("discovery did not produce a configuration"); - }; - assert_eq!(configuration.fields[0].key, "future.sensor.rate_hz"); - - let begin = client - .save( - vec![Edit { - field_id: 401, - value: Scalar::U32(30), - }], - &mut sequence, - ) - .unwrap(); - let begin_result = result( - request_id(&begin), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_BEGIN, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 0, - ); - let (_, set) = client.observe(&begin_result, &mut sequence); - let set = set.unwrap(); - let MavMessage::KAPTAN_CONFIG_SET_U32(set_data) = - MavMessage::parse(MavlinkVersion::V2, set.message_id(), set.payload()).unwrap() - else { - panic!("typed setter was not selected from the descriptor kind"); - }; - assert_eq!((set_data.field_id, set_data.value), (401, 30)); - - let set_result = result( - request_id(&set), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_SET, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 1, - ); - let (_, validate) = client.observe(&set_result, &mut sequence); - let validate = validate.unwrap(); - let validate_result = result( - request_id(&validate), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_VALIDATE, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_ACCEPTED, - KaptanConfigState::KAPTAN_CONFIG_STATE_EDITING, - 50, - 1, - ); - let (_, commit) = client.observe(&validate_result, &mut sequence); - let commit = commit.unwrap(); - let commit_result = result( - request_id(&commit), - KaptanConfigOperation::KAPTAN_CONFIG_OPERATION_COMMIT, - KaptanConfigResult::KAPTAN_CONFIG_RESULT_COMMITTED_RESTART_REQUIRED, - KaptanConfigState::KAPTAN_CONFIG_STATE_RESTART_REQUIRED, - 0, - 1, - ); - let (event, next) = client.observe(&commit_result, &mut sequence); - assert!(next.is_none()); - let ClientEvent::Configuration(configuration) = event.unwrap() else { - panic!("commit did not refresh the configuration"); - }; - assert_eq!(configuration.info.state, "restart required"); - assert_eq!(configuration.fields[0].value, Scalar::U32(30)); + let prefix = KcfgResultDetail::violation(violation).encode(); + let detail = rejected_detail(&ControlResponse { + operation: FileOperation::Close, + target: Target::new(CLIENT.system(), CLIENT.component()), + position: 0, + file_size: 0, + checksum_crc32c: 0, + transfer_id: 9, + status: FileStatus::ContentRejected, + content: Content::try_new(&prefix).unwrap(), + }); + assert!(detail.contains("field 401")); + assert!(detail.contains("related field 402")); + assert!(detail.contains("rule 5 (Inconsistent)")); } } diff --git a/firmware/tools/configurator/src/lib.rs b/firmware/tools/configurator/src/lib.rs index b44aa41..aaa4ea4 100644 --- a/firmware/tools/configurator/src/lib.rs +++ b/firmware/tools/configurator/src/lib.rs @@ -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; diff --git a/firmware/tools/configurator/src/log_monitor.rs b/firmware/tools/configurator/src/log_monitor.rs index 0d100e6..9359d00 100644 --- a/firmware/tools/configurator/src/log_monitor.rs +++ b/firmware/tools/configurator/src/log_monitor.rs @@ -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 { - 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 { 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] diff --git a/firmware/tools/configurator/src/main.rs b/firmware/tools/configurator/src/main.rs index 9df6dac..ae4dacf 100644 --- a/firmware/tools/configurator/src/main.rs +++ b/firmware/tools/configurator/src/main.rs @@ -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, baud: SerialBaud, + source_system: u8, } fn arguments() -> std::io::Result { @@ -49,6 +52,7 @@ fn arguments_from(mut values: impl Iterator) -> std::io::Result { @@ -67,16 +71,30 @@ fn arguments_from(mut values: impl Iterator) -> std::io::Result { + let value = values.next().ok_or_else(|| invalid_arguments(&flag))?; + source_system = value.parse::().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)) - .expect("recovery endpoint is accepted"); + 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"); diff --git a/firmware/tools/configurator/src/packet_monitor.rs b/firmware/tools/configurator/src/packet_monitor.rs index 0e5980b..828aa35 100644 --- a/firmware/tools/configurator/src/packet_monitor.rs +++ b/firmware/tools/configurator/src/packet_monitor.rs @@ -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()); } diff --git a/firmware/tools/configurator/src/serial_worker.rs b/firmware/tools/configurator/src/serial_worker.rs index 91b4618..34fcd7e 100644 --- a/firmware/tools/configurator/src/serial_worker.rs +++ b/firmware/tools/configurator/src/serial_worker.rs @@ -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), Shutdown, } @@ -82,11 +78,15 @@ pub struct SerialController { } impl SerialController { - pub fn new(state: Arc, baud: SerialBaud) -> Result { + pub fn new( + state: Arc, + baud: SerialBaud, + local: Principal, + ) -> Result { 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, 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, state: Arc) { - let mut session = Session::default(); +fn worker(commands: Receiver, state: Arc, 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 { + 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) { - 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) { 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) { - if frame.has_valid_crc::() - && 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::() - && 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 { +fn log_observation(frame: &MavlinkFrame, message: &MavMessage) -> Option { 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), - bounded_text(&log.message, log.message_length)?, - ) - } - message::kaptan::MavMessage::KAPTAN_ERROR(error) => { + 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; + } 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(bytes: &[u8; N], length: u8) -> Option { 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()), } - state.configuration_event(event); } 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::() { +fn decoded_heartbeat_target(frame: &MavlinkFrame, message: &MavMessage) -> Option { + 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 { + let message = parse_test_message(frame)?; + decoded_heartbeat_target(frame, &message) } fn open_serial(path: &str, baud: SerialBaud) -> Result { @@ -481,11 +495,18 @@ fn lock(mutex: &Mutex) -> 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>( + 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); diff --git a/firmware/tools/configurator/src/state.rs b/firmware/tools/configurator/src/state.rs index a7747a3..9d30937 100644 --- a/firmware/tools/configurator/src/state.rs +++ b/firmware/tools/configurator/src/state.rs @@ -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, 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, pub maximum: Option, - pub default: serde_json::Value, + pub factory: serde_json::Value, pub value: serde_json::Value, pub help: String, pub options: Vec, @@ -58,11 +57,6 @@ pub struct EnumOptionView { pub label: String, } -#[derive(Clone)] -pub struct SaveRequest { - pub edits: Vec, -} - #[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, config: Option, - draft: BTreeMap, + draft: BTreeMap, 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 { + 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 { + pub(crate) fn begin_save(&self) -> Result, 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 - .draft - .iter() - .map(|(field_id, value)| Edit { - field_id: *field_id, - value: *value, - }) - .collect(), - }; + let edits = state + .draft + .iter() + .map(|(field_id, value)| Edit { + field_id: *field_id, + value: *value, + }) + .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) -> FieldView { +fn field_view(field: &RemoteField, draft: Option) -> 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) -> 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 { +fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result { let text = value .as_str() .map(str::to_owned) @@ -338,19 +350,27 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result 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::() @@ -358,7 +378,7 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result text @@ -367,14 +387,20 @@ fn parse_scalar(kind: ValueKind, value: &serde_json::Value) -> Result 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(mutex: &Mutex) -> 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(), - }, + schema_id: *b"UNKNOWN-SCHEMA01", + 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(), }, ]; diff --git a/firmware/tools/configurator/web/app.js b/firmware/tools/configurator/web/app.js index 67c2046..7c97ea3 100644 --- a/firmware/tools/configurator/web/app.js +++ b/firmware/tools/configurator/web/app.js @@ -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) { diff --git a/firmware/tools/sitl/Cargo.toml b/firmware/tools/sitl/Cargo.toml index f17a4d0..fa0847b 100644 --- a/firmware/tools/sitl/Cargo.toml +++ b/firmware/tools/sitl/Cargo.toml @@ -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 } diff --git a/firmware/tools/sitl/src/config/mod.rs b/firmware/tools/sitl/src/config/mod.rs index 7c62a98..8f5f0ef 100644 --- a/firmware/tools/sitl/src/config/mod.rs +++ b/firmware/tools/sitl/src/config/mod.rs @@ -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, }; diff --git a/firmware/tools/sitl/src/config/runtime.rs b/firmware/tools/sitl/src/config/runtime.rs index 6dbe370..0342175 100644 --- a/firmware/tools/sitl/src/config/runtime.rs +++ b/firmware/tools/sitl/src/config/runtime.rs @@ -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, - protocol: ConfigProtocol, } impl SitlConfigRuntime { - /// Dispatches one command for a caller-owned lease principal. - pub fn dispatch(&mut self, input: (Principal, ConfigCommand)) -> Vec { - 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 { - 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 { + #[cfg(test)] + pub(super) fn pending_log_payload(&mut self) -> Option { 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 { 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 }) } diff --git a/firmware/tools/sitl/src/config/store.rs b/firmware/tools/sitl/src/config/store.rs index c73f11e..f08f946 100644 --- a/firmware/tools/sitl/src/config/store.rs +++ b/firmware/tools/sitl/src/config/store.rs @@ -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 for FileConfigStore { type Error = FileStoreError; - fn load(&mut self) -> Result, Self::Error> { + fn load(&mut self, _: &CandidateProfile) -> Result, Self::Error> { let Some(path) = self.path.as_deref() else { return Ok(None); }; diff --git a/firmware/tools/sitl/src/config/tests.rs b/firmware/tools/sitl/src/config/tests.rs index 379cdb2..18da4ca 100644 --- a/firmware/tools/sitl/src/config/tests.rs +++ b/firmware/tools/sitl/src/config/tests.rs @@ -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 { + 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