feat(config): add validated profile core
This commit is contained in:
1 parent
0a75860841
commit
6c5e061359
36 files changed
+3343
No files matched your search
@@ -5,9 +5,13 @@ members = [
|
|||||||
"libs/kaptan-actuator",
|
"libs/kaptan-actuator",
|
||||||
"libs/kaptan-board",
|
"libs/kaptan-board",
|
||||||
"libs/kaptan-control",
|
"libs/kaptan-control",
|
||||||
|
"libs/kaptan-config",
|
||||||
|
"libs/kaptan-config-derive",
|
||||||
"libs/kaptan-data-channel",
|
"libs/kaptan-data-channel",
|
||||||
"libs/kaptan-dma",
|
"libs/kaptan-dma",
|
||||||
"libs/kaptan-drivers",
|
"libs/kaptan-drivers",
|
||||||
|
"libs/kaptan-flight-config",
|
||||||
|
"libs/kaptan-flight-runtime",
|
||||||
"libs/kaptan-i2c",
|
"libs/kaptan-i2c",
|
||||||
"libs/kaptan-math",
|
"libs/kaptan-math",
|
||||||
"libs/kaptan-mavlink",
|
"libs/kaptan-mavlink",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
| ^^^^^
|
||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user