feat(config): add validated profile core

This commit is contained in:
Onur Özkan committed 2026-08-09 22:06:10 +03:00
1 parent 0a75860841
commit 6c5e061359
36 files changed
+3343

No files matched your search

+4
View File
@@ -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",
+22
View File
@@ -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"
+187
View File
@@ -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<TokenStream> {
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<TokenStream>,
checks: Vec<TokenStream>,
}
fn expand_field(crate_path: &syn::Path, field: &crate::parse::FieldModel) -> Result<FieldMetadata> {
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,
));
}
},
}
}
+17
View File
@@ -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(),
}
}
+372
View File
@@ -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<Path>,
pub(crate) fields: Vec<FieldModel>,
}
pub(crate) struct FieldModel {
pub(crate) ident: Ident,
pub(crate) ty: Type,
pub(crate) id: Option<u16>,
pub(crate) display_key: Option<syn::LitStr>,
pub(crate) unit: Option<syn::LitStr>,
pub(crate) default: Option<Expr>,
pub(crate) checks: Vec<Check>,
}
#[derive(Clone)]
pub(crate) enum Check {
Finite,
Nonzero,
Minimum(Expr),
Maximum(Expr),
}
impl Model {
pub(crate) fn parse(input: DeriveInput) -> Result<Self> {
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<Path>)> {
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<FieldModel> {
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::<u16>()?;
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<Expr>, Option<Expr>)> {
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,
})
}
@@ -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);
}
+6
View File
@@ -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");
}
@@ -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() {}
@@ -0,0 +1,5 @@
error: duplicate configuration field id
--> tests/ui/duplicate_id.rs:8:5
|
8 | second: u32,
| ^^^^^^
@@ -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() {}
@@ -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))]
| ^
@@ -0,0 +1,9 @@
use kaptan_config_derive::ConfigValidate;
#[derive(ConfigValidate)]
struct MissingId {
#[config(nonzero)]
value: u32,
}
fn main() {}
@@ -0,0 +1,5 @@
error: validated configuration fields require config(id = N)
--> tests/ui/missing_id.rs:6:5
|
6 | value: u32,
| ^^^^^
@@ -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() {}
@@ -0,0 +1,5 @@
error: configuration fields require config(key = "...")
--> tests/ui/missing_metadata.rs:6:5
|
6 | value: u32,
| ^^^^^
+14
View File
@@ -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" }
+85
View File
@@ -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<FieldId>,
related: Option<FieldId>,
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<FieldId> {
self.primary
}
/// Related field for cross-field validation.
pub const fn related_field(self) -> Option<FieldId> {
self.related
}
/// Violated invariant.
pub const fn rule(self) -> ValidationRule {
self.rule
}
}
+200
View File
@@ -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<Self> {
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<ConfigValue>,
/// Inclusive upper bound when the value is ordered.
pub maximum: Option<ConfigValue>,
/// 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<ConfigViolation> {
match self {
Self::Violation(violation) => Some(violation),
Self::UnknownField(_) | Self::WrongType { .. } => None,
}
}
/// Returns a field-local non-finite error.
pub const fn non_finite(field: FieldId) -> Self {
Self::Violation(ConfigViolation::field(field, ValidationRule::NonFinite))
}
}
/// 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<ConfigValue>;
/// Returns one value from the immutable boot-active view.
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue>;
/// 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<Self::Active, ConfigViolation>;
}
+25
View File
@@ -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;
+420
View File
@@ -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<E> {
/// 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<E> {
/// 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<C: ConfigModel, S> {
active: C::Active,
committed: C,
transaction: Option<C>,
context: C::Context,
active_generation: Option<Generation>,
committed_generation: Option<Generation>,
store: S,
restart_required: bool,
}
impl<C, S> ConfigManager<C, S>
where
C: ConfigModel,
S: ProfileStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
/// is sufficient to arm; no recovery is implicit.
pub fn new(factory: C, context: C::Context, store: S) -> Result<Self, ConfigViolation> {
let active = factory.validate(&context)?;
Ok(Self {
active,
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<Self, StartupError<S::Error>> {
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<ConfigValue> {
self.committed.field_value(id)
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
}
/// Returns a transaction value when an edit is open.
pub fn transaction_value(&self, id: FieldId) -> Option<ConfigValue> {
self.transaction.as_ref()?.field_value(id)
}
/// Returns descriptor metadata by stable schema index.
pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> {
C::descriptor(index)
}
/// Number of fields exposed by the compiled registry.
pub fn field_count(&self) -> u16 {
C::field_count()
}
/// Returns the current lifecycle state.
pub const fn state(&self) -> ConfigState {
if self.transaction.is_some() {
ConfigState::Editing
} else if self.restart_required {
ConfigState::RestartRequired
} else {
ConfigState::Active
}
}
/// Returns the boot-active snapshot generation.
pub const fn active_generation(&self) -> Option<Generation> {
self.active_generation
}
/// Returns the most recently recovered or committed generation.
pub const fn committed_generation(&self) -> Option<Generation> {
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<Generation> {
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<ConfigValue, SetError> {
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<S::Error>> {
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<S::Error>> {
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<Generation, CommitError<S::Error>> {
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<ConfigValue> {
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<ConfigValue> {
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<Self::Active, ConfigViolation> {
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<Candidate> for MemoryStore {
type Error = ();
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
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);
}
}
+48
View File
@@ -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<Self> {
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<C> {
/// Backend-specific I/O or integrity failure.
type Error;
/// Loads the newest valid committed candidate, if one exists.
fn load(&mut self) -> Result<Option<(Generation, C)>, Self::Error>;
/// Persists one complete already-validated candidate atomically.
fn persist(&mut self, candidate: &C) -> Result<Generation, Self::Error>;
}
+13
View File
@@ -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>;
}
+15
View File
@@ -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"] }
+289
View File
@@ -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<CandidateProfile, CborError> {
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<W>(
candidate: CandidateProfile,
writer: W,
) -> Result<(), minicbor::encode::Error<W::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<CandidateProfile, CborError> {
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<W>(
encoder: &mut Encoder<W>,
value: ConfigValue,
) -> Result<(), minicbor::encode::Error<W::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<ConfigValue, CborError> {
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<F>(
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)
}
}
+18
View File
@@ -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;
@@ -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
}
}
@@ -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<VehicleProfile, ConfigViolation> {
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<VehicleProfile, ConfigViolation> {
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<ConfigValue> {
registry::field_value(self, id)
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
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::Active, ConfigViolation> {
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
);
}
}
}
@@ -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(())
}
}
@@ -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;
@@ -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;
@@ -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
}
}
@@ -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<ConfigValue> {
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<u32, FieldWriteError> {
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<u64, FieldWriteError> {
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<f32, FieldWriteError> {
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)
}
@@ -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()
}
}
@@ -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(())
}
}
+15
View File
@@ -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" }
+129
View File
@@ -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<S> {
configuration: ConfigManager<CandidateProfile, S>,
logs: RuntimeLogHub,
}
impl<S> FlightRuntime<S>
where
S: ProfileStore<CandidateProfile>,
{
/// 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<Self, StartupError<S::Error>> {
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<CandidateProfile, S> {
&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<CandidateProfile, S>, &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<LogRecord> {
self.logs.peek()
}
/// Submits the next record and removes it only after transport admission.
pub fn submit_next<R>(
&mut self,
submit: impl FnOnce(&LogRecord) -> (kaptan_log::TransportAdmission, R),
) -> Option<R> {
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<kaptan_flight_config::CandidateProfile> for Store {
type Error = ();
fn load(
&mut self,
) -> Result<Option<(Generation, kaptan_flight_config::CandidateProfile)>, Self::Error>
{
Ok(None)
}
fn persist(
&mut self,
_: &kaptan_flight_config::CandidateProfile,
) -> Result<Generation, Self::Error> {
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());
}
}