From 6c5e0613594998f0723f362f10a63dcffb80f264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20=C3=96zkan?= Date: Sun, 9 Aug 2026 22:06:10 +0300 Subject: [PATCH] feat(config): add validated profile core --- Cargo.toml | 4 + libs/kaptan-config-derive/Cargo.toml | 22 + libs/kaptan-config-derive/src/expand.rs | 187 ++++++++ libs/kaptan-config-derive/src/lib.rs | 17 + libs/kaptan-config-derive/src/parse.rs | 372 ++++++++++++++++ .../tests/pass/root_bool.rs | 11 + libs/kaptan-config-derive/tests/ui.rs | 6 + .../tests/ui/duplicate_id.rs | 11 + .../tests/ui/duplicate_id.stderr | 5 + .../tests/ui/duplicate_range.rs | 9 + .../tests/ui/duplicate_range.stderr | 5 + .../tests/ui/missing_id.rs | 9 + .../tests/ui/missing_id.stderr | 5 + .../tests/ui/missing_metadata.rs | 9 + .../tests/ui/missing_metadata.stderr | 5 + libs/kaptan-config/Cargo.toml | 14 + libs/kaptan-config/src/error.rs | 85 ++++ libs/kaptan-config/src/field.rs | 200 +++++++++ libs/kaptan-config/src/lib.rs | 25 ++ libs/kaptan-config/src/lifecycle.rs | 420 ++++++++++++++++++ libs/kaptan-config/src/storage.rs | 48 ++ libs/kaptan-config/src/validation.rs | 13 + libs/kaptan-flight-config/Cargo.toml | 15 + libs/kaptan-flight-config/src/cbor.rs | 289 ++++++++++++ libs/kaptan-flight-config/src/lib.rs | 18 + .../src/profile/active.rs | 237 ++++++++++ .../src/profile/candidate.rs | 339 ++++++++++++++ .../src/profile/control.rs | 235 ++++++++++ libs/kaptan-flight-config/src/profile/ids.rs | 41 ++ libs/kaptan-flight-config/src/profile/mod.rs | 17 + .../src/profile/observability.rs | 41 ++ .../src/profile/registry.rs | 408 +++++++++++++++++ .../src/profile/safety.rs | 20 + .../src/profile/timing.rs | 57 +++ libs/kaptan-flight-runtime/Cargo.toml | 15 + libs/kaptan-flight-runtime/src/lib.rs | 129 ++++++ 36 files changed, 3343 insertions(+) create mode 100644 libs/kaptan-config-derive/Cargo.toml create mode 100644 libs/kaptan-config-derive/src/expand.rs create mode 100644 libs/kaptan-config-derive/src/lib.rs create mode 100644 libs/kaptan-config-derive/src/parse.rs create mode 100644 libs/kaptan-config-derive/tests/pass/root_bool.rs create mode 100644 libs/kaptan-config-derive/tests/ui.rs create mode 100644 libs/kaptan-config-derive/tests/ui/duplicate_id.rs create mode 100644 libs/kaptan-config-derive/tests/ui/duplicate_id.stderr create mode 100644 libs/kaptan-config-derive/tests/ui/duplicate_range.rs create mode 100644 libs/kaptan-config-derive/tests/ui/duplicate_range.stderr create mode 100644 libs/kaptan-config-derive/tests/ui/missing_id.rs create mode 100644 libs/kaptan-config-derive/tests/ui/missing_id.stderr create mode 100644 libs/kaptan-config-derive/tests/ui/missing_metadata.rs create mode 100644 libs/kaptan-config-derive/tests/ui/missing_metadata.stderr create mode 100644 libs/kaptan-config/Cargo.toml create mode 100644 libs/kaptan-config/src/error.rs create mode 100644 libs/kaptan-config/src/field.rs create mode 100644 libs/kaptan-config/src/lib.rs create mode 100644 libs/kaptan-config/src/lifecycle.rs create mode 100644 libs/kaptan-config/src/storage.rs create mode 100644 libs/kaptan-config/src/validation.rs create mode 100644 libs/kaptan-flight-config/Cargo.toml create mode 100644 libs/kaptan-flight-config/src/cbor.rs create mode 100644 libs/kaptan-flight-config/src/lib.rs create mode 100644 libs/kaptan-flight-config/src/profile/active.rs create mode 100644 libs/kaptan-flight-config/src/profile/candidate.rs create mode 100644 libs/kaptan-flight-config/src/profile/control.rs create mode 100644 libs/kaptan-flight-config/src/profile/ids.rs create mode 100644 libs/kaptan-flight-config/src/profile/mod.rs create mode 100644 libs/kaptan-flight-config/src/profile/observability.rs create mode 100644 libs/kaptan-flight-config/src/profile/registry.rs create mode 100644 libs/kaptan-flight-config/src/profile/safety.rs create mode 100644 libs/kaptan-flight-config/src/profile/timing.rs create mode 100644 libs/kaptan-flight-runtime/Cargo.toml create mode 100644 libs/kaptan-flight-runtime/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index b5e498b..31b7166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,13 @@ members = [ "libs/kaptan-actuator", "libs/kaptan-board", "libs/kaptan-control", + "libs/kaptan-config", + "libs/kaptan-config-derive", "libs/kaptan-data-channel", "libs/kaptan-dma", "libs/kaptan-drivers", + "libs/kaptan-flight-config", + "libs/kaptan-flight-runtime", "libs/kaptan-i2c", "libs/kaptan-math", "libs/kaptan-mavlink", diff --git a/libs/kaptan-config-derive/Cargo.toml b/libs/kaptan-config-derive/Cargo.toml new file mode 100644 index 0000000..81ba1f5 --- /dev/null +++ b/libs/kaptan-config-derive/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "kaptan-config-derive" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + +[dev-dependencies] +kaptan-config = { path = "../kaptan-config" } +trybuild = "1" diff --git a/libs/kaptan-config-derive/src/expand.rs b/libs/kaptan-config-derive/src/expand.rs new file mode 100644 index 0000000..1cf5452 --- /dev/null +++ b/libs/kaptan-config-derive/src/expand.rs @@ -0,0 +1,187 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::Result; + +use crate::parse::{Check, Model}; + +pub(crate) fn expand(model: Model) -> Result { + let Model { + ident, + generics, + crate_path, + post_validate, + fields, + } = model; + let (impl_generics, type_generics, where_clause) = generics.split_for_impl(); + let mut checks = Vec::new(); + let mut descriptors = Vec::new(); + for field in fields { + let FieldMetadata { + descriptor, + checks: field_checks, + } = expand_field(&crate_path, &field)?; + if let Some(descriptor) = descriptor { + descriptors.push(descriptor); + } + checks.extend(field_checks); + } + let post_validate = post_validate.map(|hook| quote! { #hook(self)?; }); + Ok(quote! { + impl #impl_generics #crate_path::ValidateFields for #ident #type_generics #where_clause { + fn validate_fields(&self) -> Result<(), #crate_path::ConfigViolation> { + #(#checks)* + #post_validate + Ok(()) + } + } + + impl #impl_generics #ident #type_generics #where_clause { + /// Field metadata generated from the same attributes as validation. + pub const DESCRIPTORS: &'static [#crate_path::FieldDescriptor] = &[ + #(#descriptors),* + ]; + } + }) +} + +struct FieldMetadata { + descriptor: Option, + checks: Vec, +} + +fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result { + let mut checks = Vec::new(); + let Some(id) = field.id else { + return Ok(FieldMetadata { + descriptor: None, + checks, + }); + }; + let minimum = field.checks.iter().find_map(|check| match check { + Check::Minimum(value) => Some(value), + _ => None, + }); + let maximum = field.checks.iter().find_map(|check| match check { + Check::Maximum(value) => Some(value), + _ => None, + }); + for check in &field.checks { + checks.push(expand_check(crate_path, &field.ident, id, check.clone())); + } + let key = field.display_key.as_ref().expect("id fields have a key"); + let unit = field.unit.as_ref().expect("id fields have a unit"); + let default = field.default.as_ref().expect("id fields have a default"); + let (kind, value) = config_value_tokens(crate_path, &field.ty)?; + let minimum = option_value_tokens(value.clone(), minimum); + let maximum = option_value_tokens(value.clone(), maximum); + Ok(FieldMetadata { + descriptor: Some(quote! { + #crate_path::FieldDescriptor { + id: #crate_path::FieldId::new(#id), + display_key: #key, + kind: #kind, + minimum: #minimum, + maximum: #maximum, + default: #value(#default), + unit: #unit, + apply_policy: #crate_path::ApplyPolicy::RestartRequired, + } + }), + checks, + }) +} + +fn option_value_tokens(value: TokenStream, expression: Option<&syn::Expr>) -> TokenStream { + match expression { + Some(expression) => quote! { Some(#value(#expression)) }, + None => quote! { None }, + } +} + +fn config_value_tokens( + crate_path: &syn::Path, + ty: &syn::Type, +) -> Result<(TokenStream, TokenStream)> { + let syn::Type::Path(path) = ty else { + return Err(syn::Error::new_spanned( + ty, + "configuration metadata requires a scalar type", + )); + }; + let Some(segment) = path.path.segments.last() else { + return Err(syn::Error::new_spanned( + ty, + "configuration metadata requires a scalar type", + )); + }; + let (kind, value) = match segment.ident.to_string().as_str() { + "bool" => ( + quote! { #crate_path::ValueKind::Bool }, + quote! { #crate_path::ConfigValue::Bool }, + ), + "i32" => ( + quote! { #crate_path::ValueKind::I32 }, + quote! { #crate_path::ConfigValue::I32 }, + ), + "u32" => ( + quote! { #crate_path::ValueKind::U32 }, + quote! { #crate_path::ConfigValue::U32 }, + ), + "u64" => ( + quote! { #crate_path::ValueKind::U64 }, + quote! { #crate_path::ConfigValue::U64 }, + ), + "f32" => ( + quote! { #crate_path::ValueKind::F32 }, + quote! { #crate_path::ConfigValue::F32 }, + ), + "f64" => ( + quote! { #crate_path::ValueKind::F64 }, + quote! { #crate_path::ConfigValue::F64 }, + ), + _ => { + return Err(syn::Error::new_spanned( + ty, + "configuration metadata supports bool, i32, u32, u64, f32, and f64", + )); + } + }; + Ok((kind, value)) +} + +fn expand_check(crate_path: &syn::Path, field: &syn::Ident, id: u16, check: Check) -> TokenStream { + match check { + Check::Finite => quote! { + if !self.#field.is_finite() { + return Err(#crate_path::ConfigViolation::field( + #crate_path::FieldId::new(#id), + #crate_path::ValidationRule::NonFinite, + )); + } + }, + Check::Nonzero => quote! { + if self.#field == 0 { + return Err(#crate_path::ConfigViolation::field( + #crate_path::FieldId::new(#id), + #crate_path::ValidationRule::Zero, + )); + } + }, + Check::Minimum(minimum) => quote! { + if self.#field < (#minimum) { + return Err(#crate_path::ConfigViolation::field( + #crate_path::FieldId::new(#id), + #crate_path::ValidationRule::BelowMinimum, + )); + } + }, + Check::Maximum(maximum) => quote! { + if self.#field > (#maximum) { + return Err(#crate_path::ConfigViolation::field( + #crate_path::FieldId::new(#id), + #crate_path::ValidationRule::AboveMaximum, + )); + } + }, + } +} diff --git a/libs/kaptan-config-derive/src/lib.rs b/libs/kaptan-config-derive/src/lib.rs new file mode 100644 index 0000000..3b97579 --- /dev/null +++ b/libs/kaptan-config-derive/src/lib.rs @@ -0,0 +1,17 @@ +//! Procedural validation derive for Kaptan configuration records. + +mod expand; +mod parse; + +use proc_macro::TokenStream; +use syn::parse_macro_input; + +/// Implements `kaptan_config::ValidateFields` from `#[config(...)]` helpers. +#[proc_macro_derive(ConfigValidate, attributes(config))] +pub fn derive_config_validate(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as syn::DeriveInput); + match parse::Model::parse(input).and_then(expand::expand) { + Ok(tokens) => tokens.into(), + Err(error) => error.into_compile_error().into(), + } +} diff --git a/libs/kaptan-config-derive/src/parse.rs b/libs/kaptan-config-derive/src/parse.rs new file mode 100644 index 0000000..3c301ba --- /dev/null +++ b/libs/kaptan-config-derive/src/parse.rs @@ -0,0 +1,372 @@ +use syn::{ + Attribute, Data, DeriveInput, Expr, Field, Fields, Ident, Lit, Path, Result, Type, UnOp, + spanned::Spanned, +}; + +pub(crate) struct Model { + pub(crate) ident: Ident, + pub(crate) generics: syn::Generics, + pub(crate) crate_path: Path, + pub(crate) post_validate: Option, + pub(crate) fields: Vec, +} + +pub(crate) struct FieldModel { + pub(crate) ident: Ident, + pub(crate) ty: Type, + pub(crate) id: Option, + pub(crate) display_key: Option, + pub(crate) unit: Option, + pub(crate) default: Option, + pub(crate) checks: Vec, +} + +#[derive(Clone)] +pub(crate) enum Check { + Finite, + Nonzero, + Minimum(Expr), + Maximum(Expr), +} + +impl Model { + pub(crate) fn parse(input: DeriveInput) -> Result { + let (crate_path, post_validate) = parse_container_attributes(&input.attrs)?; + let Data::Struct(data) = input.data else { + return Err(syn::Error::new( + input.ident.span(), + "ConfigValidate can only be derived for structs", + )); + }; + let Fields::Named(fields) = data.fields else { + return Err(syn::Error::new( + input.ident.span(), + "ConfigValidate requires named struct fields", + )); + }; + let mut models = Vec::with_capacity(fields.named.len()); + let mut ids = Vec::new(); + for field in fields.named { + let model = parse_field(field)?; + if let Some(id) = model.id { + if ids.contains(&id) { + return Err(syn::Error::new( + model.ident.span(), + "duplicate configuration field id", + )); + } + ids.push(id); + } + models.push(model); + } + for field in &models { + if field.id.is_some() { + if field.display_key.is_none() { + return Err(syn::Error::new( + field.ident.span(), + "configuration fields require config(key = \"...\")", + )); + } + if field.unit.is_none() { + return Err(syn::Error::new( + field.ident.span(), + "configuration fields require config(unit = \"...\")", + )); + } + if field.default.is_none() { + return Err(syn::Error::new( + field.ident.span(), + "configuration fields require config(default = ...)", + )); + } + } + } + Ok(Self { + ident: input.ident, + generics: input.generics, + crate_path, + post_validate, + fields: models, + }) + } +} + +fn parse_container_attributes(attributes: &[Attribute]) -> Result<(Path, Option)> { + let mut crate_path = syn::parse_quote!(::kaptan_config); + let mut has_crate_path = false; + let mut post_validate = None; + for attribute in attributes + .iter() + .filter(|attribute| attribute.path().is_ident("config")) + { + attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("crate") { + if has_crate_path { + return Err(meta.error("duplicate crate path override")); + } + crate_path = meta.value()?.parse()?; + has_crate_path = true; + return Ok(()); + } + if meta.path.is_ident("post") { + if post_validate.is_some() { + return Err(meta.error("duplicate post validation hook")); + } + post_validate = Some(meta.value()?.parse()?); + return Ok(()); + } + Err(meta.error("struct config options are crate = path and post = path")) + })?; + } + Ok((crate_path, post_validate)) +} + +fn parse_field(field: Field) -> Result { + let ident = field + .ident + .clone() + .ok_or_else(|| syn::Error::new(field.span(), "configuration fields must be named"))?; + let mut checks = Vec::new(); + let mut id = None; + let mut display_key = None; + let mut unit = None; + let mut default = None; + let mut finite = false; + let mut nonzero = false; + let mut range_minimum = false; + let mut range_maximum = false; + for attribute in field + .attrs + .iter() + .filter(|attribute| attribute.path().is_ident("config")) + { + attribute.parse_nested_meta(|meta| { + if meta.path.is_ident("id") { + if id.is_some() { + return Err(meta.error("duplicate configuration field id")); + } + let literal: syn::LitInt = meta.value()?.parse()?; + let parsed = literal.base10_parse::()?; + if parsed == 0 || parsed == u16::MAX { + return Err(meta.error("configuration field id is reserved")); + } + id = Some(parsed); + return Ok(()); + } + if meta.path.is_ident("key") { + if display_key.is_some() { + return Err(meta.error("duplicate configuration display key")); + } + display_key = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("unit") { + if unit.is_some() { + return Err(meta.error("duplicate configuration unit")); + } + unit = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("default") { + if default.is_some() { + return Err(meta.error("duplicate configuration default")); + } + let expression = meta.value()?.parse()?; + validate_default_literal(&expression, &field.ty)?; + default = Some(expression); + return Ok(()); + } + if meta.path.is_ident("finite") { + if finite { + return Err(meta.error("duplicate finite validation rule")); + } + require_float(&field.ty, &meta.path)?; + checks.push(Check::Finite); + finite = true; + return Ok(()); + } + if meta.path.is_ident("nonzero") { + if nonzero { + return Err(meta.error("duplicate nonzero validation rule")); + } + require_numeric(&field.ty, &meta.path)?; + checks.push(Check::Nonzero); + nonzero = true; + return Ok(()); + } + if meta.path.is_ident("range") { + require_numeric(&field.ty, &meta.path)?; + let (minimum, maximum) = parse_range(meta)?; + if minimum.is_none() && maximum.is_none() { + return Err(syn::Error::new( + field.span(), + "range requires min and/or max", + )); + } + if let Some(minimum) = minimum { + if range_minimum { + return Err(syn::Error::new(field.span(), "duplicate range minimum")); + } + checks.push(Check::Minimum(minimum)); + range_minimum = true; + } + if let Some(maximum) = maximum { + if range_maximum { + return Err(syn::Error::new(field.span(), "duplicate range maximum")); + } + checks.push(Check::Maximum(maximum)); + range_maximum = true; + } + return Ok(()); + } + Err(meta.error( + "supported field config rules are id, key, unit, default, finite, nonzero, and range", + )) + })?; + } + if !checks.is_empty() && id.is_none() { + return Err(syn::Error::new( + ident.span(), + "validated configuration fields require config(id = N)", + )); + } + Ok(FieldModel { + ident, + ty: field.ty, + id, + display_key, + unit, + default, + checks, + }) +} + +fn parse_range(meta: syn::meta::ParseNestedMeta<'_>) -> Result<(Option, Option)> { + let mut minimum = None; + let mut maximum = None; + meta.parse_nested_meta(|nested| { + if nested.path.is_ident("min") { + if minimum.is_some() { + return Err(nested.error("duplicate range minimum")); + } + let expression = nested.value()?.parse()?; + validate_numeric_literal(&expression)?; + minimum = Some(expression); + return Ok(()); + } + if nested.path.is_ident("max") { + if maximum.is_some() { + return Err(nested.error("duplicate range maximum")); + } + let expression = nested.value()?.parse()?; + validate_numeric_literal(&expression)?; + maximum = Some(expression); + return Ok(()); + } + Err(nested.error("range accepts only min and max")) + })?; + Ok((minimum, maximum)) +} + +fn validate_numeric_literal(expression: &Expr) -> Result<()> { + match expression { + Expr::Lit(expression) if matches!(expression.lit, Lit::Int(_) | Lit::Float(_)) => Ok(()), + Expr::Unary(expression) + if matches!(expression.op, UnOp::Neg(_)) + && matches!( + expression.expr.as_ref(), + Expr::Lit(literal) if matches!(literal.lit, Lit::Int(_) | Lit::Float(_)) + ) => + { + Ok(()) + } + _ => Err(syn::Error::new( + expression.span(), + "range bounds must be numeric literals", + )), + } +} + +fn validate_default_literal(expression: &Expr, ty: &Type) -> Result<()> { + if scalar_type_name(ty) == Some("bool") { + return match expression { + Expr::Lit(expression) if matches!(expression.lit, Lit::Bool(_)) => Ok(()), + _ => Err(syn::Error::new( + expression.span(), + "bool configuration defaults must be bool literals", + )), + }; + } + validate_numeric_literal(expression) +} + +fn require_float(ty: &Type, span: &impl Spanned) -> Result<()> { + if scalar_type_name(ty).is_some_and(|name| name == "f32" || name == "f64") { + Ok(()) + } else { + Err(syn::Error::new( + span.span(), + "finite requires an f32 or f64 field", + )) + } +} + +fn require_numeric(ty: &Type, span: &impl Spanned) -> Result<()> { + if scalar_type_name(ty).is_some_and(|name| { + matches!( + name, + "f32" + | "f64" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + ) + }) { + Ok(()) + } else { + Err(syn::Error::new( + span.span(), + "nonzero and range require a primitive numeric field", + )) + } +} + +fn scalar_type_name(ty: &Type) -> Option<&str> { + let Type::Path(path) = ty else { + return None; + }; + if path.qself.is_some() || path.path.segments.len() != 1 { + return None; + } + let segment = path.path.segments.first()?; + if !matches!(segment.arguments, syn::PathArguments::None) { + return None; + } + Some(match &segment.ident { + ident if ident == "bool" => "bool", + ident if ident == "f32" => "f32", + ident if ident == "f64" => "f64", + ident if ident == "i8" => "i8", + ident if ident == "i16" => "i16", + ident if ident == "i32" => "i32", + ident if ident == "i64" => "i64", + ident if ident == "i128" => "i128", + ident if ident == "isize" => "isize", + ident if ident == "u8" => "u8", + ident if ident == "u16" => "u16", + ident if ident == "u32" => "u32", + ident if ident == "u64" => "u64", + ident if ident == "u128" => "u128", + ident if ident == "usize" => "usize", + _ => return None, + }) +} diff --git a/libs/kaptan-config-derive/tests/pass/root_bool.rs b/libs/kaptan-config-derive/tests/pass/root_bool.rs new file mode 100644 index 0000000..cf6e187 --- /dev/null +++ b/libs/kaptan-config-derive/tests/pass/root_bool.rs @@ -0,0 +1,11 @@ +use kaptan_config_derive::ConfigValidate; + +#[derive(ConfigValidate)] +struct RootBooleanConfig { + #[config(id = 1, key = "enabled", unit = "bool", default = true)] + enabled: bool, +} + +fn main() { + assert_eq!(RootBooleanConfig::DESCRIPTORS.len(), 1); +} diff --git a/libs/kaptan-config-derive/tests/ui.rs b/libs/kaptan-config-derive/tests/ui.rs new file mode 100644 index 0000000..c7c08d6 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui.rs @@ -0,0 +1,6 @@ +#[test] +fn rejects_malformed_field_validation_attributes() { + let cases = trybuild::TestCases::new(); + cases.compile_fail("tests/ui/*.rs"); + cases.pass("tests/pass/*.rs"); +} diff --git a/libs/kaptan-config-derive/tests/ui/duplicate_id.rs b/libs/kaptan-config-derive/tests/ui/duplicate_id.rs new file mode 100644 index 0000000..9f0bdfe --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/duplicate_id.rs @@ -0,0 +1,11 @@ +use kaptan_config_derive::ConfigValidate; + +#[derive(ConfigValidate)] +struct DuplicateId { + #[config(id = 7, nonzero)] + first: u32, + #[config(id = 7, nonzero)] + second: u32, +} + +fn main() {} diff --git a/libs/kaptan-config-derive/tests/ui/duplicate_id.stderr b/libs/kaptan-config-derive/tests/ui/duplicate_id.stderr new file mode 100644 index 0000000..2c2c628 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/duplicate_id.stderr @@ -0,0 +1,5 @@ +error: duplicate configuration field id + --> tests/ui/duplicate_id.rs:8:5 + | +8 | second: u32, + | ^^^^^^ diff --git a/libs/kaptan-config-derive/tests/ui/duplicate_range.rs b/libs/kaptan-config-derive/tests/ui/duplicate_range.rs new file mode 100644 index 0000000..6774236 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/duplicate_range.rs @@ -0,0 +1,9 @@ +use kaptan_config_derive::ConfigValidate; + +#[derive(ConfigValidate)] +struct DuplicateRange { + #[config(id = 7, range(min = 1), range(min = 2))] + value: u32, +} + +fn main() {} diff --git a/libs/kaptan-config-derive/tests/ui/duplicate_range.stderr b/libs/kaptan-config-derive/tests/ui/duplicate_range.stderr new file mode 100644 index 0000000..51e2f7a --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/duplicate_range.stderr @@ -0,0 +1,5 @@ +error: duplicate range minimum + --> tests/ui/duplicate_range.rs:5:5 + | +5 | #[config(id = 7, range(min = 1), range(min = 2))] + | ^ diff --git a/libs/kaptan-config-derive/tests/ui/missing_id.rs b/libs/kaptan-config-derive/tests/ui/missing_id.rs new file mode 100644 index 0000000..7998675 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/missing_id.rs @@ -0,0 +1,9 @@ +use kaptan_config_derive::ConfigValidate; + +#[derive(ConfigValidate)] +struct MissingId { + #[config(nonzero)] + value: u32, +} + +fn main() {} diff --git a/libs/kaptan-config-derive/tests/ui/missing_id.stderr b/libs/kaptan-config-derive/tests/ui/missing_id.stderr new file mode 100644 index 0000000..5b1287b --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/missing_id.stderr @@ -0,0 +1,5 @@ +error: validated configuration fields require config(id = N) + --> tests/ui/missing_id.rs:6:5 + | +6 | value: u32, + | ^^^^^ diff --git a/libs/kaptan-config-derive/tests/ui/missing_metadata.rs b/libs/kaptan-config-derive/tests/ui/missing_metadata.rs new file mode 100644 index 0000000..ac97ca3 --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/missing_metadata.rs @@ -0,0 +1,9 @@ +use kaptan_config_derive::ConfigValidate; + +#[derive(ConfigValidate)] +struct MissingMetadata { + #[config(id = 7, unit = "count", default = 1, range(min = 0, max = 10))] + value: u32, +} + +fn main() {} diff --git a/libs/kaptan-config-derive/tests/ui/missing_metadata.stderr b/libs/kaptan-config-derive/tests/ui/missing_metadata.stderr new file mode 100644 index 0000000..56b964f --- /dev/null +++ b/libs/kaptan-config-derive/tests/ui/missing_metadata.stderr @@ -0,0 +1,5 @@ +error: configuration fields require config(key = "...") + --> tests/ui/missing_metadata.rs:6:5 + | +6 | value: u32, + | ^^^^^ diff --git a/libs/kaptan-config/Cargo.toml b/libs/kaptan-config/Cargo.toml new file mode 100644 index 0000000..fb5e7ca --- /dev/null +++ b/libs/kaptan-config/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "kaptan-config" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +kaptan-config-derive = { path = "../kaptan-config-derive" } +kaptan-log = { path = "../kaptan-log" } diff --git a/libs/kaptan-config/src/error.rs b/libs/kaptan-config/src/error.rs new file mode 100644 index 0000000..f48f978 --- /dev/null +++ b/libs/kaptan-config/src/error.rs @@ -0,0 +1,85 @@ +//! Bounded, transport-neutral configuration validation errors. + +use crate::FieldId; + +/// The invariant violated by a configuration field or complete profile. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u16)] +pub enum ValidationRule { + /// A floating-point value was NaN or infinite. + NonFinite = 1, + /// A value required to be nonzero was zero. + Zero = 2, + /// A value was below its inclusive lower bound. + BelowMinimum = 3, + /// A value exceeded its inclusive upper bound. + AboveMaximum = 4, + /// Individually valid fields do not form a valid complete configuration. + Inconsistent = 5, + /// The candidate requires a board capability that is unavailable. + UnsupportedCapability = 6, + /// The encoded snapshot format cannot be used by this firmware. + UnsupportedFormat = 7, +} + +impl ValidationRule { + /// Stable numeric protocol/error-catalog code. + pub const fn code(self) -> u16 { + self as u16 + } +} + +/// An allocation-free validation issue. +/// +/// The control station resolves numeric fields and rules through the schema +/// metadata. `related` identifies the other field for relational invariants. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigViolation { + primary: Option, + related: Option, + rule: ValidationRule, +} + +impl ConfigViolation { + /// Constructs a field-local violation. + pub const fn field(primary: FieldId, rule: ValidationRule) -> Self { + Self { + primary: Some(primary), + related: None, + rule, + } + } + + /// Constructs a relational violation. + pub const fn related(primary: FieldId, related: FieldId, rule: ValidationRule) -> Self { + Self { + primary: Some(primary), + related: Some(related), + rule, + } + } + + /// Constructs a profile-level violation with no scalar identity. + pub const fn global(rule: ValidationRule) -> Self { + Self { + primary: None, + related: None, + rule, + } + } + + /// First field associated with this issue. + pub const fn primary(self) -> Option { + self.primary + } + + /// Related field for cross-field validation. + pub const fn related_field(self) -> Option { + self.related + } + + /// Violated invariant. + pub const fn rule(self) -> ValidationRule { + self.rule + } +} diff --git a/libs/kaptan-config/src/field.rs b/libs/kaptan-config/src/field.rs new file mode 100644 index 0000000..fb0e1ec --- /dev/null +++ b/libs/kaptan-config/src/field.rs @@ -0,0 +1,200 @@ +//! Transport-neutral typed configuration fields and descriptors. + +use crate::{ConfigViolation, ValidationRule}; +use kaptan_log::KaptanError; + +/// A stable, schema-local identity for one editable configuration field. +/// +/// IDs are never reused within a schema generation. They are compact wire +/// identities; display strings belong to metadata and diagnostics only. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)] +#[repr(transparent)] +pub struct FieldId(u16); + +impl FieldId { + /// Creates a stable field identity. + pub const fn new(value: u16) -> Self { + assert!( + value != 0 && value != u16::MAX, + "reserved configuration field id" + ); + Self(value) + } + + /// Parses a field identity received from an untrusted transport. + pub const fn try_new(value: u16) -> Option { + if value == 0 || value == u16::MAX { + None + } else { + Some(Self(value)) + } + } + + /// Returns the transport representation. + pub const fn get(self) -> u16 { + self.0 + } +} + +/// The scalar representation of a configuration value. +/// +/// The variants deliberately preserve native widths: durations, for example, +/// remain `u64` microseconds instead of being lossy display-unit conversions. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ConfigValue { + /// Boolean value. + Bool(bool), + /// Signed 32-bit integer. + I32(i32), + /// Unsigned 32-bit integer. + U32(u32), + /// Unsigned 64-bit integer. + U64(u64), + /// IEEE-754 single-precision value. + F32(f32), + /// IEEE-754 double-precision value. + F64(f64), +} + +impl ConfigValue { + /// The declared value kind. + pub const fn kind(self) -> ValueKind { + match self { + Self::Bool(_) => ValueKind::Bool, + Self::I32(_) => ValueKind::I32, + Self::U32(_) => ValueKind::U32, + Self::U64(_) => ValueKind::U64, + Self::F32(_) => ValueKind::F32, + Self::F64(_) => ValueKind::F64, + } + } + + /// Canonical scalar bits for compact transport encodings. + pub const fn raw_bits(self) -> u64 { + match self { + Self::Bool(value) => value as u64, + Self::I32(value) => value as u32 as u64, + Self::U32(value) => value as u64, + Self::U64(value) => value, + Self::F32(value) => value.to_bits() as u64, + Self::F64(value) => value.to_bits(), + } + } +} + +/// The scalar kind carried in a descriptor or typed protocol message. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum ValueKind { + /// Boolean. + Bool = 1, + /// Signed 32-bit integer. + I32 = 2, + /// Unsigned 32-bit integer. + U32 = 3, + /// Unsigned 64-bit integer. + U64 = 4, + /// Single-precision float. + F32 = 5, + /// Double-precision float. + F64 = 6, +} + +/// When a committed field may affect the active runtime. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum ApplyPolicy { + /// Reserved for individually proven live-safe changes. + Live = 1, + /// Reserved for controlled output-inhibited reconfiguration. + DisarmedReset = 2, + /// P1 policy: the field takes effect only at the next boot. + RestartRequired = 3, +} + +/// Static metadata for one configuration field. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FieldDescriptor { + /// Stable schema-local identity. + pub id: FieldId, + /// GCS display key; never used as a wire identity. + pub display_key: &'static str, + /// Scalar storage type. + pub kind: ValueKind, + /// Inclusive lower bound when the value is ordered. + pub minimum: Option, + /// Inclusive upper bound when the value is ordered. + pub maximum: Option, + /// Firmware-build default. + pub default: ConfigValue, + /// Human-readable unit for schema metadata. + pub unit: &'static str, + /// Activation policy advertised to a control station. + pub apply_policy: ApplyPolicy, +} + +/// Failure while changing one transaction field. +#[derive(Clone, Copy, Debug, PartialEq, KaptanError)] +pub enum FieldWriteError { + /// No descriptor exposes the requested identity. + UnknownField(FieldId), + /// The supplied scalar type differs from the descriptor type. + WrongType { + /// Requested field. + field: FieldId, + /// Expected descriptor kind. + expected: ValueKind, + /// Supplied scalar kind. + actual: ValueKind, + }, + /// A locally validated scalar violated a stable rule. + Violation(ConfigViolation), +} + +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)) + } +} + +/// Vehicle-specific editable candidate model. +/// +/// Implementations expose every operator-editable scalar through the static +/// descriptor registry. A field write performs only local validation; complete +/// relational and capability validation happens through [`Self::validate`]. +pub trait ConfigModel: Clone { + /// Immutable configuration consumed by the runtime at boot. + type Active; + /// Immutable compiled board/vehicle capability contract. + type Context; + + /// Exact identifier for the current externally transferable schema. + const SCHEMA_ID: [u8; 16]; + + /// Returns the descriptor at a stable zero-based schema index. + fn descriptor(index: u16) -> Option<&'static FieldDescriptor>; + + /// Number of fields exposed by the compiled schema. + fn field_count() -> u16; + + /// Returns a candidate value by stable ID. + fn field_value(&self, id: FieldId) -> Option; + + /// Returns one value from the immutable boot-active view. + fn active_field_value(active: &Self::Active, id: FieldId) -> Option; + + /// Applies exactly one locally valid scalar to this candidate. + fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError>; + + /// Fully validates and converts the candidate to an immutable active view. + fn validate(&self, context: &Self::Context) -> Result; +} diff --git a/libs/kaptan-config/src/lib.rs b/libs/kaptan-config/src/lib.rs new file mode 100644 index 0000000..c92e370 --- /dev/null +++ b/libs/kaptan-config/src/lib.rs @@ -0,0 +1,25 @@ +//! 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 +//! parameter metadata; vehicle crates provide the typed schema and complete +//! validation. CBOR storage and MAVLink transport are separate adapters. + +#![no_std] + +extern crate self as kaptan_config; + +pub mod error; +pub mod field; +pub mod lifecycle; +pub mod storage; +pub mod validation; + +pub use error::{ConfigViolation, ValidationRule}; +pub use field::{ + ApplyPolicy, ConfigModel, ConfigValue, FieldDescriptor, FieldId, FieldWriteError, ValueKind, +}; +pub use kaptan_config_derive::ConfigValidate; +pub use lifecycle::{BeginError, CommitError, ConfigManager, ConfigState, SetError, StartupError}; +pub use storage::{Generation, ProfileStore}; +pub use validation::ValidateFields; diff --git a/libs/kaptan-config/src/lifecycle.rs b/libs/kaptan-config/src/lifecycle.rs new file mode 100644 index 0000000..28a6933 --- /dev/null +++ b/libs/kaptan-config/src/lifecycle.rs @@ -0,0 +1,420 @@ +//! Transactional committed-versus-active configuration lifecycle. + +use crate::{ + ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, + Generation, ProfileStore, +}; +use kaptan_log::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 reboot. + RestartRequired, +} + +/// Failure while recovering a boot configuration from a store. +#[derive(Debug, KaptanError)] +pub enum StartupError { + /// The storage backend could not provide a committed record. + Store(E), + /// A committed record did not validate against this firmware's schema. + InvalidPersisted(ConfigViolation), +} + +/// Failure while beginning a transaction. +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] +pub enum BeginError { + /// Exactly one transaction is already open. + TransactionActive, +} + +/// Failure while writing a scalar into the bounded transaction. +#[derive(Clone, Copy, Debug, PartialEq, KaptanError)] +pub enum SetError { + /// No transaction is open. + NoTransaction, + /// The candidate rejected the local scalar without mutation. + Field(FieldWriteError), +} + +/// Failure while validating or committing a transaction. +#[derive(Debug, PartialEq, KaptanError)] +pub enum CommitError { + /// No transaction is open. + NoTransaction, + /// The complete candidate violates a relational or capability invariant. + InvalidCandidate(ConfigViolation), + /// The storage backend could not commit the snapshot. + Store(E), +} + +/// Owns the immutable boot-active profile, the latest durable candidate, and +/// exactly one RAM edit transaction. +/// +/// P1 deliberately has no activation operation: [`Self::active`] never +/// changes after boot. `commit` atomically persists a complete validated +/// candidate, then reports [`ConfigState::RestartRequired`]. +pub struct ConfigManager { + active: C::Active, + committed: C, + transaction: Option, + context: C::Context, + active_generation: Option, + committed_generation: Option, + store: S, + restart_required: bool, +} + +impl ConfigManager +where + C: ConfigModel, + S: ProfileStore, +{ + /// 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, + committed: factory, + transaction: None, + context, + active_generation: None, + committed_generation: None, + store, + restart_required: false, + }) + } + + /// Recovers the newest valid committed snapshot or uses the supplied + /// factory candidate when the store is empty. + pub fn recover( + factory: C, + context: C::Context, + mut store: S, + ) -> Result> { + let Some((generation, committed)) = store.load().map_err(StartupError::Store)? else { + return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted); + }; + let active = committed + .validate(&context) + .map_err(StartupError::InvalidPersisted)?; + Ok(Self { + active, + committed, + transaction: None, + context, + active_generation: Some(generation), + committed_generation: Some(generation), + store, + restart_required: false, + }) + } + + /// Returns the immutable configuration selected at boot. + pub const fn active(&self) -> &C::Active { + &self.active + } + + /// Returns the last durably committed candidate value. + pub fn committed_value(&self, id: FieldId) -> Option { + self.committed.field_value(id) + } + + /// Returns a value from the immutable boot-active snapshot. + pub fn active_value(&self, id: FieldId) -> Option { + C::active_field_value(&self.active, id) + } + + /// 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 + } + } + + /// Returns the boot-active snapshot generation. + pub const fn active_generation(&self) -> Option { + self.active_generation + } + + /// Returns the most recently recovered or committed generation. + pub const fn committed_generation(&self) -> Option { + self.committed_generation + } + + /// Returns the most recently recovered or committed generation. + /// + /// Retained as a concise alias for optimistic commit callers. + pub const fn generation(&self) -> Option { + self.committed_generation + } + + /// 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()); + Ok(()) + } + + /// Applies one locally valid scalar in the open transaction. + /// + /// 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)?; + candidate + .field_value(id) + .ok_or(SetError::Field(FieldWriteError::UnknownField(id))) + } + + /// 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)?; + candidate + .validate(&self.context) + .map(|_| ()) + .map_err(CommitError::InvalidCandidate) + } + + /// Replaces the open RAM transaction with one complete imported candidate. + /// + /// Unlike scalar edits, imports are accepted only after complete + /// relational and capability validation. Neither the boot-active nor the + /// committed snapshot is changed here. + pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError> { + candidate + .validate(&self.context) + .map_err(CommitError::InvalidCandidate)?; + let transaction = self + .transaction + .as_mut() + .ok_or(CommitError::NoTransaction)?; + *transaction = candidate; + Ok(()) + } + + /// Validates and durably commits the complete open transaction. + pub fn commit(&mut self) -> Result> { + let candidate = self + .transaction + .as_ref() + .ok_or(CommitError::NoTransaction)?; + candidate + .validate(&self.context) + .map_err(CommitError::InvalidCandidate)?; + let generation = self.store.persist(candidate).map_err(CommitError::Store)?; + self.committed = candidate.clone(); + self.transaction = None; + self.committed_generation = Some(generation); + self.restart_required = true; + Ok(generation) + } + + /// Drops the RAM transaction without changing active or committed state. + pub fn abort(&mut self) -> bool { + self.transaction.take().is_some() + } + + /// Releases the owned store when the configuration owner shuts down. + pub fn into_store(self) -> S { + self.store + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ApplyPolicy, ConfigValue, ValidationRule, ValueKind}; + + const VALUE: FieldId = FieldId::new(1); + const OTHER: FieldId = FieldId::new(2); + const DESCRIPTORS: [FieldDescriptor; 2] = [ + FieldDescriptor { + id: VALUE, + display_key: "test.value", + kind: ValueKind::U32, + minimum: Some(ConfigValue::U32(1)), + maximum: Some(ConfigValue::U32(10)), + default: ConfigValue::U32(2), + unit: "count", + apply_policy: ApplyPolicy::RestartRequired, + }, + FieldDescriptor { + id: OTHER, + display_key: "test.other", + kind: ValueKind::U32, + minimum: Some(ConfigValue::U32(1)), + maximum: Some(ConfigValue::U32(10)), + default: ConfigValue::U32(4), + unit: "count", + apply_policy: ApplyPolicy::RestartRequired, + }, + ]; + + #[derive(Clone)] + struct Candidate { + value: u32, + other: u32, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct Active { + value: u32, + other: u32, + } + + impl ConfigModel for Candidate { + type Active = Active; + type Context = (); + const SCHEMA_ID: [u8; 16] = *b"CONFIGLIFECYCLE1"; + + 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 { + VALUE => Some(ConfigValue::U32(self.value)), + OTHER => Some(ConfigValue::U32(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)), + _ => None, + } + } + + fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { + let ConfigValue::U32(value) = value else { + return Err(FieldWriteError::WrongType { + field: id, + expected: ValueKind::U32, + 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, + _ => return Err(FieldWriteError::UnknownField(id)), + } + Ok(()) + } + + fn validate(&self, _: &Self::Context) -> Result { + if self.value > self.other { + return Err(ConfigViolation::related( + VALUE, + OTHER, + ValidationRule::Inconsistent, + )); + } + Ok(Active { + value: self.value, + other: self.other, + }) + } + } + + struct MemoryStore; + + impl ProfileStore for MemoryStore { + type Error = (); + + fn load(&mut self) -> Result, Self::Error> { + Ok(None) + } + + fn persist(&mut self, _: &Candidate) -> Result { + Ok(Generation::new(7)) + } + } + + #[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); + } + + #[test] + fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() { + 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(Generation::new(7))); + assert_eq!(manager.active(), &Active { value: 2, other: 4 }); + assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3))); + assert_eq!(manager.active_generation(), None); + assert_eq!(manager.committed_generation(), Some(Generation::new(7))); + 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); + } +} diff --git a/libs/kaptan-config/src/storage.rs b/libs/kaptan-config/src/storage.rs new file mode 100644 index 0000000..75be0dc --- /dev/null +++ b/libs/kaptan-config/src/storage.rs @@ -0,0 +1,48 @@ +//! Persistence interface for complete validated candidate snapshots. + +use core::num::NonZeroU64; + +/// Storage-owned monotonically increasing committed snapshot generation. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Generation(NonZeroU64); + +impl Generation { + /// Creates a generation supplied only by a persistence backend. + pub const fn new(value: u64) -> Self { + match NonZeroU64::new(value) { + Some(value) => Self(value), + None => panic!("configuration generation must be nonzero"), + } + } + + /// Parses an on-disk or wire generation, rejecting the factory sentinel. + pub const fn try_new(value: u64) -> Option { + match NonZeroU64::new(value) { + Some(value) => Some(Self(value)), + None => None, + } + } + + /// Returns the committed generation value. + pub const fn get(self) -> u64 { + self.0.get() + } +} + +/// Durable storage of complete configuration snapshots. +/// +/// Flight implementations use a two-slot journal: a record contains a header, +/// bounded CBOR payload, integrity check, and a commit marker written last. +/// A backend must retain its previous valid record until the replacement is +/// fully verified. Host file stores may implement the same interface without +/// claiming flash power-loss semantics. +pub trait ProfileStore { + /// Backend-specific I/O or integrity failure. + type Error; + + /// Loads the newest valid committed candidate, if one exists. + fn load(&mut self) -> Result, Self::Error>; + + /// Persists one complete already-validated candidate atomically. + fn persist(&mut self, candidate: &C) -> Result; +} diff --git a/libs/kaptan-config/src/validation.rs b/libs/kaptan-config/src/validation.rs new file mode 100644 index 0000000..89e66ca --- /dev/null +++ b/libs/kaptan-config/src/validation.rs @@ -0,0 +1,13 @@ +//! Local scalar validation implemented by the configuration derive macro. + +use crate::ConfigViolation; + +/// Validates local scalar invariants of one record. +/// +/// The derive macro handles finite, nonzero, and inclusive range checks. +/// Cross-field, capability, calibration, and lifecycle invariants belong in a +/// handwritten post-validation hook on the owning vehicle schema. +pub trait ValidateFields { + /// Rejects the first violated scalar or declared post-validation rule. + fn validate_fields(&self) -> Result<(), ConfigViolation>; +} diff --git a/libs/kaptan-flight-config/Cargo.toml b/libs/kaptan-flight-config/Cargo.toml new file mode 100644 index 0000000..8bdbe28 --- /dev/null +++ b/libs/kaptan-flight-config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "kaptan-flight-config" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +kaptan-config = { path = "../kaptan-config" } +kaptan-log = { path = "../kaptan-log" } +minicbor = { version = "0.25", features = ["derive"] } diff --git a/libs/kaptan-flight-config/src/cbor.rs b/libs/kaptan-flight-config/src/cbor.rs new file mode 100644 index 0000000..a09ba1e --- /dev/null +++ b/libs/kaptan-flight-config/src/cbor.rs @@ -0,0 +1,289 @@ +//! Bounded CBOR import/export for one explicit current record. + +use kaptan_config::{ + ConfigModel, ConfigValue, ConfigViolation, FieldWriteError, ValidationRule, ValueKind, +}; +use kaptan_log::KaptanError; +use minicbor::{Decoder, Encoder, encode::Write}; + +use crate::{CandidateProfile, FORMAT_VERSION, SCHEMA_ID, VehicleCapabilities}; + +/// Maximum complete candidate snapshot accepted by the current vehicle schema. +pub const MAXIMUM_CANDIDATE_BYTES: usize = 4096; +const FORMAT_KEY: u16 = 0; +const SCHEMA_KEY: u16 = u16::MAX; + +/// CBOR import failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] +pub enum CborError { + /// The supplied payload exceeded the bounded configuration storage budget. + TooLarge, + /// CBOR was malformed or did not match a supported versioned wire record. + Malformed, + /// A second CBOR item followed the candidate snapshot. + TrailingData, + /// The decoded candidate violated complete schema/capability validation. + InvalidProfile(ConfigViolation), +} + +/// Decodes exactly one bounded current record and validates it against immutable +/// board capabilities before returning a candidate to its caller. +pub fn decode_candidate( + bytes: &[u8], + capabilities: &VehicleCapabilities, +) -> Result { + if bytes.len() > MAXIMUM_CANDIDATE_BYTES { + return Err(CborError::TooLarge); + } + let candidate = decode_current(bytes)?; + candidate + .validate_against(capabilities) + .map_err(CborError::InvalidProfile)?; + Ok(candidate) +} + +/// Encodes one complete current snapshot to a caller-owned CBOR writer. +pub fn encode_candidate( + candidate: CandidateProfile, + writer: W, +) -> Result<(), minicbor::encode::Error> +where + W: Write, +{ + let mut encoder = Encoder::new(writer); + encoder.map(CandidateProfile::FIELD_COUNT as u64 + 2)?; + encoder.u16(FORMAT_KEY)?.u16(FORMAT_VERSION)?; + encoder.u16(SCHEMA_KEY)?.bytes(&SCHEMA_ID)?; + for descriptor in CandidateProfile::descriptors() { + encoder.u16(descriptor.id.get())?; + encode_value( + &mut encoder, + candidate + .field_value(descriptor.id) + .expect("descriptor field"), + )?; + } + Ok(()) +} + +fn decode_current(bytes: &[u8]) -> Result { + let mut decoder = Decoder::new(bytes); + let entry_count = CandidateProfile::FIELD_COUNT as u64 + 2; + if decoder.map().map_err(|_| CborError::Malformed)? != Some(entry_count) { + return Err(CborError::Malformed); + } + let mut format_version = None; + let mut schema_id = None; + let mut values = [None; CandidateProfile::FIELD_COUNT]; + for _ in 0..entry_count { + let key = decoder.u16().map_err(|_| CborError::Malformed)?; + match key { + FORMAT_KEY => { + if format_version + .replace(decoder.u16().map_err(|_| CborError::Malformed)?) + .is_some() + { + return Err(CborError::Malformed); + } + } + SCHEMA_KEY => { + let bytes = decoder.bytes().map_err(|_| CborError::Malformed)?; + let value: [u8; 16] = bytes.try_into().map_err(|_| CborError::Malformed)?; + if schema_id.replace(value).is_some() { + return Err(CborError::Malformed); + } + } + _ => { + let index = CandidateProfile::descriptors() + .iter() + .position(|descriptor| descriptor.id.get() == key) + .ok_or(CborError::Malformed)?; + if values[index].is_some() { + return Err(CborError::Malformed); + } + values[index] = Some(decode_value( + &mut decoder, + CandidateProfile::descriptors()[index].kind, + )?); + } + } + } + if decoder.position() != bytes.len() { + return Err(CborError::TrailingData); + } + if format_version != Some(FORMAT_VERSION) || schema_id != Some(SCHEMA_ID) { + return Err(CborError::InvalidProfile(ConfigViolation::global( + ValidationRule::UnsupportedFormat, + ))); + } + if values.iter().any(Option::is_none) { + return Err(CborError::Malformed); + } + let mut candidate = CandidateProfile::default(); + for (index, value) in values.into_iter().enumerate() { + let descriptor = CandidateProfile::descriptors() + .get(index) + .ok_or(CborError::Malformed)?; + candidate + .set_field(descriptor.id, value.ok_or(CborError::Malformed)?) + .map_err(field_write_error)?; + } + Ok(candidate) +} + +fn encode_value( + encoder: &mut Encoder, + value: ConfigValue, +) -> Result<(), minicbor::encode::Error> +where + W: Write, +{ + match value { + ConfigValue::Bool(value) => encoder.bool(value)?, + ConfigValue::I32(value) => encoder.i32(value)?, + ConfigValue::U32(value) => encoder.u32(value)?, + ConfigValue::U64(value) => encoder.u64(value)?, + ConfigValue::F32(value) => encoder.f32(value)?, + ConfigValue::F64(value) => encoder.f64(value)?, + }; + Ok(()) +} + +fn decode_value(decoder: &mut Decoder<'_>, kind: ValueKind) -> Result { + match kind { + ValueKind::Bool => decoder.bool().map(ConfigValue::Bool), + ValueKind::I32 => decoder.i32().map(ConfigValue::I32), + ValueKind::U32 => decoder.u32().map(ConfigValue::U32), + ValueKind::U64 => decoder.u64().map(ConfigValue::U64), + ValueKind::F32 => decoder.f32().map(ConfigValue::F32), + ValueKind::F64 => decoder.f64().map(ConfigValue::F64), + } + .map_err(|_| CborError::Malformed) +} + +fn field_write_error(error: FieldWriteError) -> CborError { + match error { + FieldWriteError::Violation(issue) => CborError::InvalidProfile(issue), + FieldWriteError::UnknownField(_) | FieldWriteError::WrongType { .. } => { + CborError::Malformed + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_and_trailing_rejection_are_exact() { + let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); + let capabilities = VehicleCapabilities::quad_x(); + encode_candidate(CandidateProfile::default(), &mut writer).unwrap(); + let length = writer.position(); + let bytes = writer.into_inner(); + assert!(decode_candidate(&bytes[..length], &capabilities).is_ok()); + let mut trailing = bytes; + trailing[length] = 0xf6; + assert!(matches!( + decode_candidate(&trailing[..=length], &capabilities), + Err(CborError::TrailingData) + )); + } + + #[test] + fn rejects_wrong_format_and_schema_before_candidate_conversion() { + let capabilities = VehicleCapabilities::quad_x(); + let (bytes, length) = encoded_test_record(FORMAT_VERSION + 1, SCHEMA_ID, |key| key); + assert!(matches!( + decode_candidate(&bytes[..length], &capabilities), + Err(CborError::InvalidProfile(issue)) + if issue.rule() == ValidationRule::UnsupportedFormat + )); + + let (bytes, length) = encoded_test_record(FORMAT_VERSION, [0; 16], |key| key); + assert!(matches!( + decode_candidate(&bytes[..length], &capabilities), + Err(CborError::InvalidProfile(issue)) + if issue.rule() == kaptan_config::ValidationRule::UnsupportedFormat + )); + } + + #[test] + fn exact_map_rejects_unknown_duplicate_and_missing_fields() { + let capabilities = VehicleCapabilities::quad_x(); + let (bytes, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| key); + assert!(decode_candidate(&bytes[..length], &capabilities).is_ok()); + + let (unknown, length) = + encoded_test_record( + FORMAT_VERSION, + SCHEMA_ID, + |key| { + if key == 1 { 25 } else { key } + }, + ); + assert!(matches!( + decode_candidate(&unknown[..length], &capabilities), + Err(CborError::Malformed) + )); + + let (duplicate, length) = encoded_test_record(FORMAT_VERSION, SCHEMA_ID, |key| { + if key == 1 { FORMAT_KEY } else { key } + }); + assert!(matches!( + decode_candidate(&duplicate[..length], &capabilities), + Err(CborError::Malformed) + )); + + let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); + let mut encoder = Encoder::new(&mut writer); + encoder + .map(CandidateProfile::FIELD_COUNT as u64 + 1) + .unwrap(); + encoder + .u16(FORMAT_KEY) + .unwrap() + .u16(FORMAT_VERSION) + .unwrap(); + encoder.u16(SCHEMA_KEY).unwrap().bytes(&SCHEMA_ID).unwrap(); + let candidate = CandidateProfile::default(); + for descriptor in CandidateProfile::descriptors().iter().skip(1) { + encoder.u16(descriptor.id.get()).unwrap(); + encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap(); + } + let length = writer.position(); + let missing = writer.into_inner(); + assert!(matches!( + decode_candidate(&missing[..length], &capabilities), + Err(CborError::Malformed) + )); + } + + fn encoded_test_record( + format_version: u16, + schema_id: [u8; 16], + mut remap_key: F, + ) -> ([u8; MAXIMUM_CANDIDATE_BYTES], usize) + where + F: FnMut(u16) -> u16, + { + let mut writer = minicbor::encode::write::Cursor::new([0_u8; MAXIMUM_CANDIDATE_BYTES]); + let mut encoder = Encoder::new(&mut writer); + encoder + .map(CandidateProfile::FIELD_COUNT as u64 + 2) + .unwrap(); + encoder + .u16(FORMAT_KEY) + .unwrap() + .u16(format_version) + .unwrap(); + encoder.u16(SCHEMA_KEY).unwrap().bytes(&schema_id).unwrap(); + let candidate = CandidateProfile::default(); + for descriptor in CandidateProfile::descriptors() { + encoder.u16(remap_key(descriptor.id.get())).unwrap(); + encode_value(&mut encoder, candidate.field_value(descriptor.id).unwrap()).unwrap(); + } + let length = writer.position(); + (writer.into_inner(), length) + } +} diff --git a/libs/kaptan-flight-config/src/lib.rs b/libs/kaptan-flight-config/src/lib.rs new file mode 100644 index 0000000..f08c334 --- /dev/null +++ b/libs/kaptan-flight-config/src/lib.rs @@ -0,0 +1,18 @@ +//! Vehicle configuration schema and CBOR snapshot codec. + +#![no_std] + +pub mod cbor; +pub mod profile; + +pub use cbor::{CborError, MAXIMUM_CANDIDATE_BYTES, decode_candidate, encode_candidate}; +pub use profile::{CandidateProfile, FORMAT_VERSION, VehicleCapabilities, VehicleProfile}; + +/// Exact identifier of the one unreleased Quad-X profile schema. +/// +/// This is an identifier, not a cryptographic fingerprint. A GCS includes it +/// with an import so a profile prepared for another schema cannot be applied. +pub const SCHEMA_ID: [u8; 16] = *b"KAPTANPROFILE-01"; + +#[cfg(test)] +extern crate std; diff --git a/libs/kaptan-flight-config/src/profile/active.rs b/libs/kaptan-flight-config/src/profile/active.rs new file mode 100644 index 0000000..f3a1f3e --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/active.rs @@ -0,0 +1,237 @@ +//! Immutable configuration projections consumed by runtime owners. + +/// Immutable compiled contract for the physical vehicle/board image. +/// +/// A runtime candidate can tune only values inside this contract. It cannot +/// create pins, buses, sensor drivers, or actuator channels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VehicleCapabilities { + /// Number of board-declared logical actuator channels available to the + /// selected vehicle model. + actuator_count: u8, + /// Reviewed physical allocation for this board/airframe image. + /// + /// This is capability data rather than a persisted setting: profile edits + /// must never alter motor order, thrust signs, or control authority. + mixer: [[f32; 4]; 4], +} + +impl VehicleCapabilities { + /// Returns the reviewed four-actuator Quad-X allocation supported by P1. + /// + /// New allocation layouts require a dedicated board-bound airframe review + /// and constructor, rather than accepting an arbitrary coefficient array + /// through a configuration or capability initializer. + pub const fn quad_x() -> Self { + Self { + actuator_count: 4, + mixer: [ + [-1.0, 1.0, 1.0, 1.0], + [-1.0, -1.0, -1.0, 1.0], + [1.0, -1.0, 1.0, 1.0], + [1.0, 1.0, -1.0, 1.0], + ], + } + } + + pub(crate) const fn actuator_count(self) -> u8 { + self.actuator_count + } + + pub(crate) const fn mixer(self) -> [[f32; 4]; 4] { + self.mixer + } +} + +/// Active prearm timing policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SafetySettings { + pub(crate) prearm_dwell_microseconds: u64, +} + +impl SafetySettings { + pub const fn prearm_dwell_microseconds(self) -> u64 { + self.prearm_dwell_microseconds + } +} + +/// Active stream timing policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimingSettings { + pub(crate) maximum_imu_interval_microseconds: u64, + pub(crate) imu_freshness_microseconds: u64, + pub(crate) local_state_freshness_microseconds: u64, + pub(crate) command_link_freshness_microseconds: u64, +} + +impl TimingSettings { + pub const fn maximum_imu_interval_microseconds(self) -> u64 { + self.maximum_imu_interval_microseconds + } + pub const fn imu_freshness_microseconds(self) -> u64 { + self.imu_freshness_microseconds + } + pub const fn local_state_freshness_microseconds(self) -> u64 { + self.local_state_freshness_microseconds + } + pub const fn command_link_freshness_microseconds(self) -> u64 { + self.command_link_freshness_microseconds + } +} + +/// Active local-aiding uncertainty policy. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AidingSettings { + pub(crate) position_variance_m2: f32, + pub(crate) velocity_variance_m2_per_second_squared: f32, +} + +impl AidingSettings { + pub const fn position_variance_m2(self) -> f32 { + self.position_variance_m2 + } + pub const fn velocity_variance_m2_per_second_squared(self) -> f32 { + self.velocity_variance_m2_per_second_squared + } +} + +/// Active outer-loop controller policy. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ControlSettings { + pub(crate) position_gain_per_second_squared: f32, + pub(crate) velocity_gain_per_second: f32, + pub(crate) maximum_horizontal_acceleration_mps2: f32, + pub(crate) maximum_upward_acceleration_mps2: f32, + pub(crate) maximum_downward_acceleration_mps2: f32, +} + +impl ControlSettings { + pub const fn position_gain_per_second_squared(self) -> f32 { + self.position_gain_per_second_squared + } + pub const fn velocity_gain_per_second(self) -> f32 { + self.velocity_gain_per_second + } + pub const fn maximum_horizontal_acceleration_mps2(self) -> f32 { + self.maximum_horizontal_acceleration_mps2 + } + pub const fn maximum_upward_acceleration_mps2(self) -> f32 { + self.maximum_upward_acceleration_mps2 + } + pub const fn maximum_downward_acceleration_mps2(self) -> f32 { + self.maximum_downward_acceleration_mps2 + } +} + +/// Active physical thrust envelope. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThrustSettings { + pub(crate) mass_kg: f32, + pub(crate) maximum_collective_thrust_newtons: f32, + pub(crate) maximum_tilt_tangent: f32, +} + +impl ThrustSettings { + pub const fn mass_kg(self) -> f32 { + self.mass_kg + } + pub const fn maximum_collective_thrust_newtons(self) -> f32 { + self.maximum_collective_thrust_newtons + } + pub const fn maximum_tilt_tangent(self) -> f32 { + self.maximum_tilt_tangent + } +} + +/// Active scalar PID policy. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PidSettings { + pub(crate) proportional: f32, + pub(crate) integral: f32, + pub(crate) derivative: f32, + pub(crate) output_limit: f32, + pub(crate) integral_limit: f32, +} + +impl PidSettings { + pub const fn proportional(self) -> f32 { + self.proportional + } + pub const fn integral(self) -> f32 { + self.integral + } + pub const fn derivative(self) -> f32 { + self.derivative + } + pub const fn output_limit(self) -> f32 { + self.output_limit + } + pub const fn integral_limit(self) -> f32 { + self.integral_limit + } +} + +/// Active actuator command bounds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ActuatorLimits { + pub(crate) minimum: f32, + pub(crate) maximum: f32, + pub(crate) slew_per_second: f32, +} + +impl ActuatorLimits { + pub const fn minimum(self) -> f32 { + self.minimum + } + pub const fn maximum(self) -> f32 { + self.maximum + } + pub const fn slew_per_second(self) -> f32 { + self.slew_per_second + } +} + +/// Immutable complete configuration selected at boot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VehicleProfile { + pub(crate) safety: SafetySettings, + pub(crate) timing: TimingSettings, + pub(crate) aiding: AidingSettings, + pub(crate) control: ControlSettings, + pub(crate) thrust: ThrustSettings, + pub(crate) rate_pid: PidSettings, + pub(crate) actuator_limits: ActuatorLimits, + pub(crate) observability: super::ObservabilitySettings, + pub(crate) mixer: [[f32; 4]; 4], +} + +impl VehicleProfile { + pub const fn safety(self) -> SafetySettings { + self.safety + } + pub const fn timing(self) -> TimingSettings { + self.timing + } + pub const fn aiding(self) -> AidingSettings { + self.aiding + } + pub const fn control(self) -> ControlSettings { + self.control + } + pub const fn thrust(self) -> ThrustSettings { + self.thrust + } + pub const fn rate_pid(self) -> PidSettings { + self.rate_pid + } + pub const fn actuator_limits(self) -> ActuatorLimits { + self.actuator_limits + } + /// Immutable boot-active best-effort diagnostic policy. + pub const fn observability(self) -> super::ObservabilitySettings { + self.observability + } + pub const fn mixer(self) -> [[f32; 4]; 4] { + self.mixer + } +} diff --git a/libs/kaptan-flight-config/src/profile/candidate.rs b/libs/kaptan-flight-config/src/profile/candidate.rs new file mode 100644 index 0000000..91df666 --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/candidate.rs @@ -0,0 +1,339 @@ +//! Private vehicle candidate and whole-profile validation boundary. + +use kaptan_config::{ + ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, + ValidationRule, +}; + +use super::{ + ActuatorLimits, AidingSettings, ControlSettings, ObservabilitySettings, PidSettings, + SafetySettings, ThrustSettings, TimingSettings, VehicleCapabilities, VehicleProfile, + control::{ + ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, + }, + observability::ObservabilityCandidate, + registry, + safety::SafetyCandidate, + timing::TimingCandidate, +}; + +/// Current persisted CBOR schema for the vehicle candidate. +pub const FORMAT_VERSION: u16 = 1; + +/// Complete editable vehicle candidate. +/// +/// Fields are private outside this crate. Operators, CBOR import, and MAVLink +/// transport all use the same descriptor/mutation and full-validation path. +#[derive(Clone, Copy)] +pub struct CandidateProfile { + pub(super) safety: SafetyCandidate, + pub(super) timing: TimingCandidate, + pub(super) aiding: AidingCandidate, + pub(super) control: ControlCandidate, + pub(super) thrust: ThrustCandidate, + pub(super) rate_pid: PidCandidate, + pub(super) actuator_limits: ActuatorLimitsCandidate, + pub(super) observability: ObservabilityCandidate, +} + +impl Default for CandidateProfile { + fn default() -> Self { + registry::default_candidate() + } +} + +impl CandidateProfile { + /// Number of operator-editable fields in the derived descriptor registry. + pub const FIELD_COUNT: usize = registry::FIELD_COUNT; + + /// Returns the complete transport-neutral descriptor registry. + pub const fn descriptors() -> &'static [FieldDescriptor; Self::FIELD_COUNT] { + registry::descriptors() + } + + /// Validates this candidate against immutable board capabilities. + pub fn validate_against( + &self, + capabilities: &VehicleCapabilities, + ) -> Result { + self.validate_complete(capabilities) + } + + fn from_active(active: &VehicleProfile) -> Self { + let safety = active.safety(); + let timing = active.timing(); + let aiding = active.aiding(); + let control = active.control(); + let thrust = active.thrust(); + let rate_pid = active.rate_pid(); + let actuator_limits = active.actuator_limits(); + let observability = active.observability(); + Self { + safety: SafetyCandidate { + prearm_dwell_microseconds: safety.prearm_dwell_microseconds(), + }, + timing: TimingCandidate { + maximum_imu_interval_microseconds: timing.maximum_imu_interval_microseconds(), + imu_freshness_microseconds: timing.imu_freshness_microseconds(), + local_state_freshness_microseconds: timing.local_state_freshness_microseconds(), + command_link_freshness_microseconds: timing.command_link_freshness_microseconds(), + }, + aiding: AidingCandidate { + position_variance_m2: aiding.position_variance_m2(), + velocity_variance_m2_per_second_squared: aiding + .velocity_variance_m2_per_second_squared(), + }, + control: ControlCandidate { + position_gain_per_second_squared: control.position_gain_per_second_squared(), + velocity_gain_per_second: control.velocity_gain_per_second(), + maximum_horizontal_acceleration_mps2: control + .maximum_horizontal_acceleration_mps2(), + maximum_upward_acceleration_mps2: control.maximum_upward_acceleration_mps2(), + maximum_downward_acceleration_mps2: control.maximum_downward_acceleration_mps2(), + }, + thrust: ThrustCandidate { + mass_kg: thrust.mass_kg(), + maximum_collective_thrust_newtons: thrust.maximum_collective_thrust_newtons(), + maximum_tilt_tangent: thrust.maximum_tilt_tangent(), + }, + rate_pid: PidCandidate { + proportional: rate_pid.proportional(), + integral: rate_pid.integral(), + derivative: rate_pid.derivative(), + output_limit: rate_pid.output_limit(), + integral_limit: rate_pid.integral_limit(), + }, + actuator_limits: ActuatorLimitsCandidate { + minimum: actuator_limits.minimum(), + maximum: actuator_limits.maximum(), + slew_per_second: actuator_limits.slew_per_second(), + }, + observability: ObservabilityCandidate { + maximum_log_level: observability.maximum_log_level(), + }, + } + } + + fn validate_complete( + &self, + capabilities: &VehicleCapabilities, + ) -> Result { + if capabilities.actuator_count() != 4 { + return Err(ConfigViolation::global( + ValidationRule::UnsupportedCapability, + )); + } + self.safety.validate()?; + self.timing.validate()?; + self.aiding.validate()?; + self.control.validate()?; + self.thrust.validate()?; + self.rate_pid.validate()?; + self.actuator_limits.validate()?; + self.observability.validate()?; + Ok(VehicleProfile { + safety: SafetySettings { + prearm_dwell_microseconds: self.safety.prearm_dwell_microseconds, + }, + timing: TimingSettings { + maximum_imu_interval_microseconds: self.timing.maximum_imu_interval_microseconds, + imu_freshness_microseconds: self.timing.imu_freshness_microseconds, + local_state_freshness_microseconds: self.timing.local_state_freshness_microseconds, + command_link_freshness_microseconds: self + .timing + .command_link_freshness_microseconds, + }, + aiding: AidingSettings { + position_variance_m2: self.aiding.position_variance_m2, + velocity_variance_m2_per_second_squared: self + .aiding + .velocity_variance_m2_per_second_squared, + }, + control: ControlSettings { + position_gain_per_second_squared: self.control.position_gain_per_second_squared, + velocity_gain_per_second: self.control.velocity_gain_per_second, + maximum_horizontal_acceleration_mps2: self + .control + .maximum_horizontal_acceleration_mps2, + maximum_upward_acceleration_mps2: self.control.maximum_upward_acceleration_mps2, + maximum_downward_acceleration_mps2: self.control.maximum_downward_acceleration_mps2, + }, + thrust: ThrustSettings { + mass_kg: self.thrust.mass_kg, + maximum_collective_thrust_newtons: self.thrust.maximum_collective_thrust_newtons, + maximum_tilt_tangent: self.thrust.maximum_tilt_tangent, + }, + rate_pid: PidSettings { + proportional: self.rate_pid.proportional, + integral: self.rate_pid.integral, + derivative: self.rate_pid.derivative, + output_limit: self.rate_pid.output_limit, + integral_limit: self.rate_pid.integral_limit, + }, + actuator_limits: ActuatorLimits { + minimum: self.actuator_limits.minimum, + maximum: self.actuator_limits.maximum, + slew_per_second: self.actuator_limits.slew_per_second, + }, + observability: ObservabilitySettings::new(self.observability.maximum_log_level), + mixer: capabilities.mixer(), + }) + } +} + +impl ConfigModel for CandidateProfile { + type Active = VehicleProfile; + type Context = VehicleCapabilities; + const SCHEMA_ID: [u8; 16] = crate::SCHEMA_ID; + + fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { + registry::descriptor(index) + } + + fn field_count() -> u16 { + u16::try_from(registry::FIELD_COUNT).expect("field registry exceeds the protocol limit") + } + + fn field_value(&self, id: FieldId) -> Option { + registry::field_value(self, id) + } + + fn active_field_value(active: &Self::Active, id: FieldId) -> Option { + registry::field_value(&Self::from_active(active), id) + } + + fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> { + registry::set_field(self, id, value) + } + + fn validate(&self, context: &Self::Context) -> Result { + self.validate_complete(context) + } +} + +#[cfg(test)] +mod tests { + use kaptan_config::{ConfigModel, ConfigValue, FieldWriteError, ValidationRule}; + + use super::{CandidateProfile, VehicleCapabilities}; + use crate::profile::ids; + + const CAPABILITIES: VehicleCapabilities = VehicleCapabilities::quad_x(); + + #[test] + fn every_descriptor_has_a_readable_default_value() { + let candidate = CandidateProfile::default(); + for descriptor in CandidateProfile::descriptors() { + assert_eq!( + candidate.field_value(descriptor.id), + Some(descriptor.default) + ); + } + } + + #[test] + fn aggregated_descriptor_ids_are_unique() { + for (index, descriptor) in CandidateProfile::descriptors().iter().enumerate() { + assert!( + CandidateProfile::descriptors()[index + 1..] + .iter() + .all(|other| other.id != descriptor.id) + ); + } + } + + #[test] + fn cross_field_invalid_edit_is_retained_only_in_the_candidate() { + let mut candidate = CandidateProfile::default(); + candidate + .set_field( + ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS, + ConfigValue::U64(51_000), + ) + .unwrap(); + assert!(matches!( + candidate.validate(&CAPABILITIES), + Err(issue) if issue.rule() == ValidationRule::Inconsistent + )); + } + + #[test] + fn local_write_error_retains_exact_field_and_rule() { + let mut candidate = CandidateProfile::default(); + assert_eq!( + candidate.set_field(ids::MASS_KG, ConfigValue::F32(f32::NAN)), + Err(FieldWriteError::non_finite(ids::MASS_KG)) + ); + } + + #[test] + fn descriptor_bounds_do_not_conflict_with_derived_field_validation() { + for descriptor in CandidateProfile::descriptors() { + for bound in [descriptor.minimum, descriptor.maximum] + .into_iter() + .flatten() + { + let mut candidate = CandidateProfile::default(); + candidate + .set_field(descriptor.id, bound) + .expect("descriptor bound must pass the descriptor-backed setter"); + if let Err(issue) = candidate.validate(&CAPABILITIES) { + assert!( + issue.primary() != Some(descriptor.id) + || issue.rule() == ValidationRule::Inconsistent, + "descriptor bound for {} conflicts with generated field validation: {issue:?}", + descriptor.display_key, + ); + } + } + } + } + + #[test] + fn observability_filter_is_persisted_restart_only_and_cannot_hide_errors() { + let mut candidate = CandidateProfile::default(); + candidate + .set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(3)) + .unwrap(); + let active = candidate.validate(&CAPABILITIES).unwrap(); + assert_eq!(active.observability().maximum_log_level(), 3); + assert_eq!(active.observability().maximum_log_level(), 3); + candidate + .set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(5)) + .unwrap(); + assert_eq!( + candidate + .validate(&CAPABILITIES) + .unwrap() + .observability() + .maximum_log_level(), + 5 + ); + assert!(matches!( + candidate.set_field( + ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, + ConfigValue::U32(2), + ), + Err(FieldWriteError::Violation(issue)) + if issue.rule() == ValidationRule::BelowMinimum + )); + } + + #[test] + fn every_persisted_log_severity_is_retained_exactly() { + for raw in [3, 4, 5, 6, 7] { + let mut candidate = CandidateProfile::default(); + candidate + .set_field(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL, ConfigValue::U32(raw)) + .expect("descriptor range accepts the stable severity"); + assert_eq!( + candidate + .validate(&CAPABILITIES) + .expect("valid complete profile") + .observability() + .maximum_log_level(), + raw + ); + } + } +} diff --git a/libs/kaptan-flight-config/src/profile/control.rs b/libs/kaptan-flight-config/src/profile/control.rs new file mode 100644 index 0000000..c08b19f --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/control.rs @@ -0,0 +1,235 @@ +use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule}; + +use super::ids; + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct AidingCandidate { + #[config( + id = 6, + key = "aiding.position_variance_m2", + unit = "m2", + default = 0.04, + finite, + range(min = 0.000_001, max = 10_000.0) + )] + pub(super) position_variance_m2: f32, + #[config( + id = 7, + key = "aiding.velocity_variance_m2ps2", + unit = "m2/s2", + default = 0.04, + finite, + range(min = 0.000_001, max = 10_000.0) + )] + pub(super) velocity_variance_m2_per_second_squared: f32, +} + +impl AidingCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields() + } +} + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct ControlCandidate { + #[config( + id = 8, + key = "control.position_gain_per_s2", + unit = "1/s2", + default = 0.8, + finite, + range(min = 0.0, max = 100.0) + )] + pub(super) position_gain_per_second_squared: f32, + #[config( + id = 9, + key = "control.velocity_gain_per_s", + unit = "1/s", + default = 1.2, + finite, + range(min = 0.0, max = 100.0) + )] + pub(super) velocity_gain_per_second: f32, + #[config( + id = 10, + key = "control.maximum_horizontal_acceleration_mps2", + unit = "m/s2", + default = 3.0, + finite, + range(min = 0.001, max = 100.0) + )] + pub(super) maximum_horizontal_acceleration_mps2: f32, + #[config( + id = 11, + key = "control.maximum_upward_acceleration_mps2", + unit = "m/s2", + default = 2.0, + finite, + range(min = 0.001, max = 100.0) + )] + pub(super) maximum_upward_acceleration_mps2: f32, + #[config( + id = 12, + key = "control.maximum_downward_acceleration_mps2", + unit = "m/s2", + default = 2.0, + finite, + range(min = 0.001, max = 100.0) + )] + pub(super) maximum_downward_acceleration_mps2: f32, +} +impl ControlCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields() + } +} + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct ThrustCandidate { + #[config( + id = 13, + key = "thrust.mass_kg", + unit = "kg", + default = 1.2, + finite, + range(min = 0.01, max = 100.0) + )] + pub(super) mass_kg: f32, + #[config( + id = 14, + key = "thrust.maximum_collective_thrust_n", + unit = "N", + default = 18.0, + finite, + range(min = 0.1, max = 10_000.0) + )] + pub(super) maximum_collective_thrust_newtons: f32, + #[config( + id = 15, + key = "thrust.maximum_tilt_tangent", + unit = "", + default = 0.700_207_53, + finite, + range(min = 0.001, max = 10.0) + )] + pub(super) maximum_tilt_tangent: f32, +} +impl ThrustCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields()?; + if self.maximum_collective_thrust_newtons <= self.mass_kg * 9.806_65 { + return Err(ConfigViolation::related( + ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS, + ids::MASS_KG, + ValidationRule::Inconsistent, + )); + } + Ok(()) + } +} + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct PidCandidate { + #[config( + id = 16, + key = "control.rate_pid.proportional", + unit = "", + default = 0.035, + finite, + range(min = 0.0, max = 10.0) + )] + pub(super) proportional: f32, + #[config( + id = 17, + key = "control.rate_pid.integral", + unit = "", + default = 0.006, + finite, + range(min = 0.0, max = 10.0) + )] + pub(super) integral: f32, + #[config( + id = 18, + key = "control.rate_pid.derivative", + unit = "", + default = 0.0, + finite, + range(min = 0.0, max = 10.0) + )] + pub(super) derivative: f32, + #[config( + id = 19, + key = "control.rate_pid.output_limit", + unit = "", + default = 0.10, + finite, + range(min = 0.001, max = 1.0) + )] + pub(super) output_limit: f32, + #[config( + id = 20, + key = "control.rate_pid.integral_limit", + unit = "", + default = 0.025, + finite, + range(min = 0.0, max = 1.0) + )] + pub(super) integral_limit: f32, +} +impl PidCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields()?; + if self.integral_limit > self.output_limit { + return Err(ConfigViolation::related( + ids::RATE_PID_INTEGRAL_LIMIT, + ids::RATE_PID_OUTPUT_LIMIT, + ValidationRule::Inconsistent, + )); + } + Ok(()) + } +} + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct ActuatorLimitsCandidate { + #[config( + id = 21, + key = "actuator.minimum", + unit = "", + default = 0.0, + finite, + range(min = 0.0, max = 1.0) + )] + pub(super) minimum: f32, + #[config( + id = 22, + key = "actuator.maximum", + unit = "", + default = 1.0, + finite, + range(min = 0.0, max = 1.0) + )] + pub(super) maximum: f32, + #[config( + id = 23, + key = "actuator.slew_per_second", + unit = "1/s", + default = 8.0, + finite, + range(min = 0.001, max = 100.0) + )] + pub(super) slew_per_second: f32, +} +impl ActuatorLimitsCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields()?; + if self.minimum >= self.maximum { + return Err(ConfigViolation::related( + ids::ACTUATOR_MINIMUM, + ids::ACTUATOR_MAXIMUM, + ValidationRule::Inconsistent, + )); + } + Ok(()) + } +} diff --git a/libs/kaptan-flight-config/src/profile/ids.rs b/libs/kaptan-flight-config/src/profile/ids.rs new file mode 100644 index 0000000..dc75c73 --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/ids.rs @@ -0,0 +1,41 @@ +//! Stable field identities for the current vehicle configuration schema. + +use super::{ + control::{ + ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, + }, + observability::ObservabilityCandidate, + safety::SafetyCandidate, + timing::TimingCandidate, +}; +use kaptan_config::FieldId; + +// These symbolic names are projections of derive-generated metadata. No field +// identifier is handwritten outside the field declaration itself. +pub(super) const PREARM_DWELL_MICROSECONDS: FieldId = SafetyCandidate::DESCRIPTORS[0].id; +pub(super) const MAXIMUM_IMU_INTERVAL_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[0].id; +pub(super) const IMU_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[1].id; +pub(super) const LOCAL_STATE_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[2].id; +pub(super) const COMMAND_LINK_FRESHNESS_MICROSECONDS: FieldId = TimingCandidate::DESCRIPTORS[3].id; +pub(super) const POSITION_VARIANCE_M2: FieldId = AidingCandidate::DESCRIPTORS[0].id; +pub(super) const VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED: FieldId = + AidingCandidate::DESCRIPTORS[1].id; +pub(super) const POSITION_GAIN_PER_SECOND_SQUARED: FieldId = ControlCandidate::DESCRIPTORS[0].id; +pub(super) const VELOCITY_GAIN_PER_SECOND: FieldId = ControlCandidate::DESCRIPTORS[1].id; +pub(super) const MAXIMUM_HORIZONTAL_ACCELERATION_MPS2: FieldId = + ControlCandidate::DESCRIPTORS[2].id; +pub(super) const MAXIMUM_UPWARD_ACCELERATION_MPS2: FieldId = ControlCandidate::DESCRIPTORS[3].id; +pub(super) const MAXIMUM_DOWNWARD_ACCELERATION_MPS2: FieldId = ControlCandidate::DESCRIPTORS[4].id; +pub(super) const MASS_KG: FieldId = ThrustCandidate::DESCRIPTORS[0].id; +pub(super) const MAXIMUM_COLLECTIVE_THRUST_NEWTONS: FieldId = ThrustCandidate::DESCRIPTORS[1].id; +pub(super) const MAXIMUM_TILT_TANGENT: FieldId = ThrustCandidate::DESCRIPTORS[2].id; +pub(super) const RATE_PID_PROPORTIONAL: FieldId = PidCandidate::DESCRIPTORS[0].id; +pub(super) const RATE_PID_INTEGRAL: FieldId = PidCandidate::DESCRIPTORS[1].id; +pub(super) const RATE_PID_DERIVATIVE: FieldId = PidCandidate::DESCRIPTORS[2].id; +pub(super) const RATE_PID_OUTPUT_LIMIT: FieldId = PidCandidate::DESCRIPTORS[3].id; +pub(super) const RATE_PID_INTEGRAL_LIMIT: FieldId = PidCandidate::DESCRIPTORS[4].id; +pub(super) const ACTUATOR_MINIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[0].id; +pub(super) const ACTUATOR_MAXIMUM: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[1].id; +pub(super) const ACTUATOR_SLEW_PER_SECOND: FieldId = ActuatorLimitsCandidate::DESCRIPTORS[2].id; +pub(super) const OBSERVABILITY_MAXIMUM_LOG_LEVEL: FieldId = + ObservabilityCandidate::DESCRIPTORS[0].id; diff --git a/libs/kaptan-flight-config/src/profile/mod.rs b/libs/kaptan-flight-config/src/profile/mod.rs new file mode 100644 index 0000000..7b29ffb --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/mod.rs @@ -0,0 +1,17 @@ +//! Vehicle-specific candidate and immutable active configuration models. + +mod active; +mod candidate; +mod control; +mod ids; +mod observability; +mod registry; +mod safety; +mod timing; + +pub use active::{ + ActuatorLimits, AidingSettings, ControlSettings, PidSettings, SafetySettings, ThrustSettings, + TimingSettings, VehicleCapabilities, VehicleProfile, +}; +pub use candidate::{CandidateProfile, FORMAT_VERSION}; +pub use observability::ObservabilitySettings; diff --git a/libs/kaptan-flight-config/src/profile/observability.rs b/libs/kaptan-flight-config/src/profile/observability.rs new file mode 100644 index 0000000..d57f385 --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/observability.rs @@ -0,0 +1,41 @@ +//! Persisted, restart-only runtime-observability policy. + +use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields}; + +/// Candidate-only observability policy. It deliberately controls only optional +/// detail; error-level records are never suppressible configuration fields. +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct ObservabilityCandidate { + #[config( + id = 24, + key = "observability.maximum_log_level", + unit = "mav_severity", + default = 6, + range(min = 3, max = 7) + )] + pub(super) maximum_log_level: u32, +} + +impl ObservabilityCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields() + } +} + +/// Immutable boot-active best-effort observability policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ObservabilitySettings { + maximum_log_level: u32, +} + +impl ObservabilitySettings { + pub(crate) const fn new(maximum_log_level: u32) -> Self { + Self { maximum_log_level } + } + + /// Maximum best-effort MAVLink severity: Error (3) through Debug (7). + /// This never filters error-level records. + pub const fn maximum_log_level(self) -> u32 { + self.maximum_log_level + } +} diff --git a/libs/kaptan-flight-config/src/profile/registry.rs b/libs/kaptan-flight-config/src/profile/registry.rs new file mode 100644 index 0000000..551c099 --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/registry.rs @@ -0,0 +1,408 @@ +//! Current field registry and local candidate mutation mapping. + +use kaptan_config::{ + ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError, ValidationRule, + ValueKind, +}; + +use super::{ + candidate::CandidateProfile, + control::{ + ActuatorLimitsCandidate, AidingCandidate, ControlCandidate, PidCandidate, ThrustCandidate, + }, + ids, + observability::ObservabilityCandidate, + safety::SafetyCandidate, + timing::TimingCandidate, +}; + +/// Number of operator-editable fields in the current SITL Quad-X profile. +/// +/// Allocation topology is immutable board capability data, not an operator +/// parameter. It requires a separate physical-airframe safety workflow. +pub(super) const FIELD_COUNT: usize = SafetyCandidate::DESCRIPTORS.len() + + TimingCandidate::DESCRIPTORS.len() + + AidingCandidate::DESCRIPTORS.len() + + ControlCandidate::DESCRIPTORS.len() + + ThrustCandidate::DESCRIPTORS.len() + + PidCandidate::DESCRIPTORS.len() + + ActuatorLimitsCandidate::DESCRIPTORS.len() + + ObservabilityCandidate::DESCRIPTORS.len(); +/// The registry is an ordered view of metadata generated from field attributes. +/// It owns no IDs, bounds, defaults, or display text. +const FIELDS: [FieldDescriptor; FIELD_COUNT] = [ + SafetyCandidate::DESCRIPTORS[0], + TimingCandidate::DESCRIPTORS[0], + TimingCandidate::DESCRIPTORS[1], + TimingCandidate::DESCRIPTORS[2], + TimingCandidate::DESCRIPTORS[3], + AidingCandidate::DESCRIPTORS[0], + AidingCandidate::DESCRIPTORS[1], + ControlCandidate::DESCRIPTORS[0], + ControlCandidate::DESCRIPTORS[1], + ControlCandidate::DESCRIPTORS[2], + ControlCandidate::DESCRIPTORS[3], + ControlCandidate::DESCRIPTORS[4], + ThrustCandidate::DESCRIPTORS[0], + ThrustCandidate::DESCRIPTORS[1], + ThrustCandidate::DESCRIPTORS[2], + PidCandidate::DESCRIPTORS[0], + PidCandidate::DESCRIPTORS[1], + PidCandidate::DESCRIPTORS[2], + PidCandidate::DESCRIPTORS[3], + PidCandidate::DESCRIPTORS[4], + ActuatorLimitsCandidate::DESCRIPTORS[0], + ActuatorLimitsCandidate::DESCRIPTORS[1], + ActuatorLimitsCandidate::DESCRIPTORS[2], + ObservabilityCandidate::DESCRIPTORS[0], +]; + +const _: () = assert_unique_ids(&FIELDS); + +const fn assert_unique_ids(fields: &[FieldDescriptor]) { + let mut index = 0; + while index < fields.len() { + let mut other = index + 1; + while other < fields.len() { + if fields[index].id.get() == fields[other].id.get() { + panic!("duplicate field id in the aggregated vehicle registry"); + } + other += 1; + } + index += 1; + } +} + +pub(super) const fn descriptors() -> &'static [FieldDescriptor; FIELD_COUNT] { + &FIELDS +} + +/// Constructs the vehicle candidate entirely from derive-generated defaults. +pub(super) fn default_candidate() -> CandidateProfile { + CandidateProfile { + safety: SafetyCandidate { + prearm_dwell_microseconds: default_u64(ids::PREARM_DWELL_MICROSECONDS), + }, + timing: TimingCandidate { + maximum_imu_interval_microseconds: default_u64(ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS), + imu_freshness_microseconds: default_u64(ids::IMU_FRESHNESS_MICROSECONDS), + local_state_freshness_microseconds: default_u64( + ids::LOCAL_STATE_FRESHNESS_MICROSECONDS, + ), + command_link_freshness_microseconds: default_u64( + ids::COMMAND_LINK_FRESHNESS_MICROSECONDS, + ), + }, + aiding: AidingCandidate { + position_variance_m2: default_f32(ids::POSITION_VARIANCE_M2), + velocity_variance_m2_per_second_squared: default_f32( + ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED, + ), + }, + control: ControlCandidate { + position_gain_per_second_squared: default_f32(ids::POSITION_GAIN_PER_SECOND_SQUARED), + velocity_gain_per_second: default_f32(ids::VELOCITY_GAIN_PER_SECOND), + maximum_horizontal_acceleration_mps2: default_f32( + ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2, + ), + maximum_upward_acceleration_mps2: default_f32(ids::MAXIMUM_UPWARD_ACCELERATION_MPS2), + maximum_downward_acceleration_mps2: default_f32( + ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2, + ), + }, + thrust: ThrustCandidate { + mass_kg: default_f32(ids::MASS_KG), + maximum_collective_thrust_newtons: default_f32(ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS), + maximum_tilt_tangent: default_f32(ids::MAXIMUM_TILT_TANGENT), + }, + rate_pid: PidCandidate { + proportional: default_f32(ids::RATE_PID_PROPORTIONAL), + integral: default_f32(ids::RATE_PID_INTEGRAL), + derivative: default_f32(ids::RATE_PID_DERIVATIVE), + output_limit: default_f32(ids::RATE_PID_OUTPUT_LIMIT), + integral_limit: default_f32(ids::RATE_PID_INTEGRAL_LIMIT), + }, + actuator_limits: ActuatorLimitsCandidate { + minimum: default_f32(ids::ACTUATOR_MINIMUM), + maximum: default_f32(ids::ACTUATOR_MAXIMUM), + slew_per_second: default_f32(ids::ACTUATOR_SLEW_PER_SECOND), + }, + observability: ObservabilityCandidate { + maximum_log_level: default_u32(ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL), + }, + } +} + +pub(super) fn descriptor(index: u16) -> Option<&'static FieldDescriptor> { + FIELDS.get(usize::from(index)) +} + +pub(super) fn field_value(candidate: &CandidateProfile, id: FieldId) -> Option { + Some(match id { + ids::PREARM_DWELL_MICROSECONDS => { + ConfigValue::U64(candidate.safety.prearm_dwell_microseconds) + } + ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS => { + ConfigValue::U64(candidate.timing.maximum_imu_interval_microseconds) + } + ids::IMU_FRESHNESS_MICROSECONDS => { + ConfigValue::U64(candidate.timing.imu_freshness_microseconds) + } + ids::LOCAL_STATE_FRESHNESS_MICROSECONDS => { + ConfigValue::U64(candidate.timing.local_state_freshness_microseconds) + } + ids::COMMAND_LINK_FRESHNESS_MICROSECONDS => { + ConfigValue::U64(candidate.timing.command_link_freshness_microseconds) + } + ids::POSITION_VARIANCE_M2 => ConfigValue::F32(candidate.aiding.position_variance_m2), + ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED => { + ConfigValue::F32(candidate.aiding.velocity_variance_m2_per_second_squared) + } + ids::POSITION_GAIN_PER_SECOND_SQUARED => { + ConfigValue::F32(candidate.control.position_gain_per_second_squared) + } + ids::VELOCITY_GAIN_PER_SECOND => { + ConfigValue::F32(candidate.control.velocity_gain_per_second) + } + ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2 => { + ConfigValue::F32(candidate.control.maximum_horizontal_acceleration_mps2) + } + ids::MAXIMUM_UPWARD_ACCELERATION_MPS2 => { + ConfigValue::F32(candidate.control.maximum_upward_acceleration_mps2) + } + ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2 => { + ConfigValue::F32(candidate.control.maximum_downward_acceleration_mps2) + } + ids::MASS_KG => ConfigValue::F32(candidate.thrust.mass_kg), + ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS => { + ConfigValue::F32(candidate.thrust.maximum_collective_thrust_newtons) + } + ids::MAXIMUM_TILT_TANGENT => ConfigValue::F32(candidate.thrust.maximum_tilt_tangent), + ids::RATE_PID_PROPORTIONAL => ConfigValue::F32(candidate.rate_pid.proportional), + ids::RATE_PID_INTEGRAL => ConfigValue::F32(candidate.rate_pid.integral), + ids::RATE_PID_DERIVATIVE => ConfigValue::F32(candidate.rate_pid.derivative), + ids::RATE_PID_OUTPUT_LIMIT => ConfigValue::F32(candidate.rate_pid.output_limit), + ids::RATE_PID_INTEGRAL_LIMIT => ConfigValue::F32(candidate.rate_pid.integral_limit), + ids::ACTUATOR_MINIMUM => ConfigValue::F32(candidate.actuator_limits.minimum), + ids::ACTUATOR_MAXIMUM => ConfigValue::F32(candidate.actuator_limits.maximum), + ids::ACTUATOR_SLEW_PER_SECOND => { + ConfigValue::F32(candidate.actuator_limits.slew_per_second) + } + ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL => { + ConfigValue::U32(candidate.observability.maximum_log_level) + } + _ => return None, + }) +} + +pub(super) fn set_field( + candidate: &mut CandidateProfile, + id: FieldId, + value: ConfigValue, +) -> Result<(), FieldWriteError> { + let value = match descriptor_for(id) { + Some(descriptor) => match descriptor.kind { + ValueKind::U64 => ConfigValue::U64(validate_u64(id, value, descriptor)?), + ValueKind::U32 => ConfigValue::U32(validate_u32(id, value, descriptor)?), + ValueKind::F32 => ConfigValue::F32(validate_f32(id, value, descriptor)?), + kind => { + return Err(FieldWriteError::WrongType { + field: id, + expected: kind, + actual: value.kind(), + }); + } + }, + None => return Err(FieldWriteError::UnknownField(id)), + }; + match value { + ConfigValue::U64(value) => match id { + ids::PREARM_DWELL_MICROSECONDS => candidate.safety.prearm_dwell_microseconds = value, + ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS => { + candidate.timing.maximum_imu_interval_microseconds = value + } + ids::IMU_FRESHNESS_MICROSECONDS => candidate.timing.imu_freshness_microseconds = value, + ids::LOCAL_STATE_FRESHNESS_MICROSECONDS => { + candidate.timing.local_state_freshness_microseconds = value + } + ids::COMMAND_LINK_FRESHNESS_MICROSECONDS => { + candidate.timing.command_link_freshness_microseconds = value + } + _ => return Err(FieldWriteError::UnknownField(id)), + }, + ConfigValue::U32(value) => match id { + ids::OBSERVABILITY_MAXIMUM_LOG_LEVEL => { + candidate.observability.maximum_log_level = value + } + _ => return Err(FieldWriteError::UnknownField(id)), + }, + ConfigValue::F32(value) => match id { + ids::POSITION_VARIANCE_M2 => candidate.aiding.position_variance_m2 = value, + ids::VELOCITY_VARIANCE_M2_PER_SECOND_SQUARED => { + candidate.aiding.velocity_variance_m2_per_second_squared = value + } + ids::POSITION_GAIN_PER_SECOND_SQUARED => { + candidate.control.position_gain_per_second_squared = value + } + ids::VELOCITY_GAIN_PER_SECOND => candidate.control.velocity_gain_per_second = value, + ids::MAXIMUM_HORIZONTAL_ACCELERATION_MPS2 => { + candidate.control.maximum_horizontal_acceleration_mps2 = value + } + ids::MAXIMUM_UPWARD_ACCELERATION_MPS2 => { + candidate.control.maximum_upward_acceleration_mps2 = value + } + ids::MAXIMUM_DOWNWARD_ACCELERATION_MPS2 => { + candidate.control.maximum_downward_acceleration_mps2 = value + } + ids::MASS_KG => candidate.thrust.mass_kg = value, + ids::MAXIMUM_COLLECTIVE_THRUST_NEWTONS => { + candidate.thrust.maximum_collective_thrust_newtons = value + } + ids::MAXIMUM_TILT_TANGENT => candidate.thrust.maximum_tilt_tangent = value, + ids::RATE_PID_PROPORTIONAL => candidate.rate_pid.proportional = value, + ids::RATE_PID_INTEGRAL => candidate.rate_pid.integral = value, + ids::RATE_PID_DERIVATIVE => candidate.rate_pid.derivative = value, + ids::RATE_PID_OUTPUT_LIMIT => candidate.rate_pid.output_limit = value, + ids::RATE_PID_INTEGRAL_LIMIT => candidate.rate_pid.integral_limit = value, + ids::ACTUATOR_MINIMUM => candidate.actuator_limits.minimum = value, + ids::ACTUATOR_MAXIMUM => candidate.actuator_limits.maximum = value, + ids::ACTUATOR_SLEW_PER_SECOND => candidate.actuator_limits.slew_per_second = value, + _ => return Err(FieldWriteError::UnknownField(id)), + }, + ConfigValue::Bool(_) | ConfigValue::I32(_) | ConfigValue::F64(_) => { + return Err(FieldWriteError::WrongType { + field: id, + expected: descriptor_for(id).expect("checked descriptor").kind, + actual: value.kind(), + }); + } + } + Ok(()) +} + +fn validate_u32( + id: FieldId, + value: ConfigValue, + descriptor: &FieldDescriptor, +) -> Result { + let ConfigValue::U32(value) = value else { + return Err(FieldWriteError::WrongType { + field: id, + expected: ValueKind::U32, + actual: value.kind(), + }); + }; + let Some(ConfigValue::U32(minimum)) = descriptor.minimum else { + unreachable!() + }; + let Some(ConfigValue::U32(maximum)) = descriptor.maximum else { + unreachable!() + }; + if value < minimum { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::BelowMinimum, + ))); + } + if value > maximum { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::AboveMaximum, + ))); + } + Ok(value) +} + +fn descriptor_for(id: FieldId) -> Option<&'static FieldDescriptor> { + FIELDS.iter().find(|descriptor| descriptor.id == id) +} + +fn default_u32(id: FieldId) -> u32 { + match descriptor_for(id).expect("generated descriptor").default { + ConfigValue::U32(value) => value, + _ => unreachable!("generated descriptor type"), + } +} + +fn default_u64(id: FieldId) -> u64 { + match descriptor_for(id).expect("generated descriptor").default { + ConfigValue::U64(value) => value, + _ => unreachable!("generated descriptor type"), + } +} + +fn default_f32(id: FieldId) -> f32 { + match descriptor_for(id).expect("generated descriptor").default { + ConfigValue::F32(value) => value, + _ => unreachable!("generated descriptor type"), + } +} + +fn validate_u64( + id: FieldId, + value: ConfigValue, + descriptor: &FieldDescriptor, +) -> Result { + let ConfigValue::U64(value) = value else { + return Err(FieldWriteError::WrongType { + field: id, + expected: ValueKind::U64, + actual: value.kind(), + }); + }; + let Some(ConfigValue::U64(minimum)) = descriptor.minimum else { + unreachable!() + }; + let Some(ConfigValue::U64(maximum)) = descriptor.maximum else { + unreachable!() + }; + if value < minimum { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::BelowMinimum, + ))); + } + if value > maximum { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::AboveMaximum, + ))); + } + Ok(value) +} + +fn validate_f32( + id: FieldId, + value: ConfigValue, + descriptor: &FieldDescriptor, +) -> Result { + let ConfigValue::F32(value) = value else { + return Err(FieldWriteError::WrongType { + field: id, + expected: ValueKind::F32, + actual: value.kind(), + }); + }; + if !value.is_finite() { + return Err(FieldWriteError::non_finite(id)); + } + let Some(ConfigValue::F32(minimum)) = descriptor.minimum else { + unreachable!() + }; + let Some(ConfigValue::F32(maximum)) = descriptor.maximum else { + unreachable!() + }; + if value < minimum { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::BelowMinimum, + ))); + } + if value > maximum { + return Err(FieldWriteError::Violation(ConfigViolation::field( + id, + ValidationRule::AboveMaximum, + ))); + } + Ok(value) +} diff --git a/libs/kaptan-flight-config/src/profile/safety.rs b/libs/kaptan-flight-config/src/profile/safety.rs new file mode 100644 index 0000000..ccfdbde --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/safety.rs @@ -0,0 +1,20 @@ +use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields}; + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct SafetyCandidate { + #[config( + id = 1, + key = "safety.prearm_dwell_us", + unit = "us", + default = 100_000, + nonzero, + range(min = 1, max = 10_000_000) + )] + pub(super) prearm_dwell_microseconds: u64, +} + +impl SafetyCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields() + } +} diff --git a/libs/kaptan-flight-config/src/profile/timing.rs b/libs/kaptan-flight-config/src/profile/timing.rs new file mode 100644 index 0000000..271bb14 --- /dev/null +++ b/libs/kaptan-flight-config/src/profile/timing.rs @@ -0,0 +1,57 @@ +use kaptan_config::{ConfigValidate, ConfigViolation, ValidateFields, ValidationRule}; + +use super::ids; + +#[derive(Clone, Copy, ConfigValidate)] +pub(super) struct TimingCandidate { + #[config( + id = 2, + key = "timing.imu_max_interval_us", + unit = "us", + default = 50_000, + nonzero, + range(min = 1, max = 1_000_000) + )] + pub(super) maximum_imu_interval_microseconds: u64, + #[config( + id = 3, + key = "timing.imu_freshness_us", + unit = "us", + default = 50_000, + nonzero, + range(min = 1, max = 1_000_000) + )] + pub(super) imu_freshness_microseconds: u64, + #[config( + id = 4, + key = "timing.local_state_freshness_us", + unit = "us", + default = 150_000, + nonzero, + range(min = 1, max = 10_000_000) + )] + pub(super) local_state_freshness_microseconds: u64, + #[config( + id = 5, + key = "timing.link_freshness_us", + unit = "us", + default = 500_000, + nonzero, + range(min = 1, max = 10_000_000) + )] + pub(super) command_link_freshness_microseconds: u64, +} + +impl TimingCandidate { + pub(super) fn validate(self) -> Result<(), ConfigViolation> { + self.validate_fields()?; + if self.maximum_imu_interval_microseconds > self.imu_freshness_microseconds { + return Err(ConfigViolation::related( + ids::MAXIMUM_IMU_INTERVAL_MICROSECONDS, + ids::IMU_FRESHNESS_MICROSECONDS, + ValidationRule::Inconsistent, + )); + } + Ok(()) + } +} diff --git a/libs/kaptan-flight-runtime/Cargo.toml b/libs/kaptan-flight-runtime/Cargo.toml new file mode 100644 index 0000000..3fdaa8c --- /dev/null +++ b/libs/kaptan-flight-runtime/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "kaptan-flight-runtime" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +kaptan-config = { path = "../kaptan-config" } +kaptan-flight-config = { path = "../kaptan-flight-config" } +kaptan-log = { path = "../kaptan-log" } diff --git a/libs/kaptan-flight-runtime/src/lib.rs b/libs/kaptan-flight-runtime/src/lib.rs new file mode 100644 index 0000000..b63fb60 --- /dev/null +++ b/libs/kaptan-flight-runtime/src/lib.rs @@ -0,0 +1,129 @@ +//! Central, transport-neutral flight-runtime composition. +//! +//! This crate owns the one configuration manager and one bounded log hub for +//! an image. MAVLink adapters receive mutable borrows for bounded dispatch or +//! draining, but neither can own configuration lifecycle or link state. + +#![no_std] + +use kaptan_config::{ConfigManager, ProfileStore, StartupError}; +use kaptan_flight_config::{CandidateProfile, VehicleCapabilities}; +use kaptan_log::{EventHub, LogFlags, LogRecord, LogText, PublishOutcome, Severity}; + +/// Default bounded allocation for central runtime text diagnostics. Error +/// records are partitioned from optional detail. +pub type RuntimeLogHub = EventHub<8, 16>; + +/// One boot-active vehicle runtime. It deliberately has no MAVLink dependency. +pub struct FlightRuntime { + configuration: ConfigManager, + logs: RuntimeLogHub, +} + +impl FlightRuntime +where + S: ProfileStore, +{ + /// Recovers the central manager, validates its boot-active snapshot, and + /// copies the restart-only observability policy into the bounded hub. + pub fn recover( + capabilities: VehicleCapabilities, + store: S, + ) -> Result> { + let configuration = + ConfigManager::recover(CandidateProfile::default(), capabilities, store)?; + let maximum_log_level = configuration.active().observability().maximum_log_level(); + let logs = RuntimeLogHub::new(kaptan_log::LogFilter::new( + Severity::try_from_wire(maximum_log_level as u8) + .expect("validated flight profile has a MAVLink log severity"), + )); + Ok(Self { + configuration, + logs, + }) + } + + /// The sole central configuration manager. + pub const fn configuration(&self) -> &ConfigManager { + &self.configuration + } + + /// Borrows the manager and hub together for a state-only protocol adapter. + /// This makes adapter ownership inversion unrepresentable in composition. + pub fn configuration_and_logs_mut( + &mut self, + ) -> (&mut ConfigManager, &mut RuntimeLogHub) { + (&mut self.configuration, &mut self.logs) + } + + /// Publishes one preformatted bounded task record through the sole hub. + pub fn log(&mut self, severity: Severity, flags: LogFlags, message: LogText) -> PublishOutcome { + self.logs.publish_text(severity, flags, message) + } + + /// Peeks the highest-priority pending text log for the sole telemetry link. + pub fn pending_report(&self) -> Option { + self.logs.peek() + } + + /// Submits the next record and removes it only after transport admission. + pub fn submit_next( + &mut self, + submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R), + ) -> Option { + self.logs.submit_next(submit) + } + + /// Immutable boot-active log policy copied from the validated profile. + pub const fn log_filter(&self) -> kaptan_log::LogFilter { + self.logs.filter() + } +} + +#[cfg(test)] +mod tests { + use kaptan_config::{Generation, ProfileStore}; + use kaptan_log::{LogFlags, LogText, Severity}; + + use super::FlightRuntime; + + struct Store; + impl ProfileStore for Store { + type Error = (); + + fn load( + &mut self, + ) -> Result, Self::Error> + { + Ok(None) + } + + fn persist( + &mut self, + _: &kaptan_flight_config::CandidateProfile, + ) -> Result { + Ok(Generation::new(1)) + } + } + + #[test] + fn central_runtime_uses_the_active_profile_filter_and_owns_one_hub() { + let mut runtime = + FlightRuntime::recover(kaptan_flight_config::VehicleCapabilities::quad_x(), Store) + .expect("factory profile validates"); + assert_eq!( + runtime + .configuration() + .active() + .observability() + .maximum_log_level(), + runtime.log_filter().maximum_severity() as u32 + ); + let _ = runtime.log( + Severity::Info, + LogFlags::NONE, + LogText::from_message("runtime ready"), + ); + assert!(runtime.pending_report().is_some()); + } +}