feat(firmware): validate configuration and migrate sensor-driven flight APIs

Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
This commit is contained in:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
[package]
name = "kaptan-ports"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
kaptan-config = { path = "../kaptan-config" }
+227
View File
@@ -0,0 +1,227 @@
use kaptan_config::{ConfigViolation, FieldId, ValidationRule};
use crate::{PortId, Role};
/// One platform-owned UART's static capabilities and schema identities.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PortDescriptor {
pub id: PortId,
pub role_field: FieldId,
pub baud_field: FieldId,
pub role_key: &'static str,
pub baud_key: &'static str,
/// Subset of `Role::CONFIG_ENUM_OPTIONS`; must include Disabled.
pub supported_roles: &'static [(u8, &'static str)],
/// Nonempty, unique nonzero baud whitelist, enforced even while disabled.
pub allowed_bauds: &'static [u32],
/// Exclusive resource tokens claimed by any enabled role on this UART.
pub resources: &'static [u16],
}
impl PortDescriptor {
/// The same role and baud metadata exposed by [`crate::Ports`]. Platforms
/// can check that a composed root advertises the inventory they execute.
pub const fn fields(&self) -> [kaptan_config::FieldDescriptor; 2] {
crate::schema::fields(&[*self])[0]
}
}
/// Immutable platform inventory. Resource tokens share one platform namespace.
///
/// Use explicit stable IDs/keys, never array-derived wire identities. Include
/// every resource needed by the advertised roles (a conservative union is
/// acceptable). Cross-port overlaps are allowed in the inventory and rejected
/// only when both ports are enabled. Fixed claims must be disjoint from every
/// UART, even disabled ones. Do not put USB in either UART or reserved claims.
pub trait PortCatalog<const N: usize> {
const DESCRIPTORS: [PortDescriptor; N];
const RESERVED_RESOURCES: &'static [u16] = &[];
}
/// Checks an inventory at runtime or in a platform constant.
///
/// Rejects duplicate port/field IDs, keys, choices, bauds, per-port resource
/// claims and reserved claims, and overlaps with reserved resources. Labels
/// must be the canonical `Role` labels. All failures use `Inconsistent`;
/// field-local or related identities are included when available.
/// [`crate::Ports::new`] and generated metadata also enforce this in a const.
///
/// ```compile_fail
/// use kaptan_config::FieldId;
/// use kaptan_ports::*;
/// struct Bad;
/// impl PortCatalog<1> for Bad {
/// const DESCRIPTORS: [PortDescriptor; 1] = [PortDescriptor {
/// id: PortId::new(1),
/// role_field: FieldId::new(10), baud_field: FieldId::new(10),
/// role_key: "uart.role", baud_key: "uart.baud",
/// supported_roles: Role::CONFIG_ENUM_OPTIONS,
/// allowed_bauds: &[115_200], resources: &[],
/// }];
/// }
/// const INVALID: Ports<Bad, 1> = Ports::new([
/// PortSettings { role: Role::Disabled, baud: 115_200 },
/// ]);
/// ```
pub const fn validate_catalog<const N: usize>(
descriptors: &[PortDescriptor; N],
reserved_resources: &[u16],
) -> Result<(), ConfigViolation> {
let rule = ValidationRule::Inconsistent;
if N > 256 || duplicates(reserved_resources) {
return Err(ConfigViolation::global(rule));
}
let mut i = 0;
while i < N {
let port = &descriptors[i];
if port.role_field.get() == port.baud_field.get() || same_text(port.role_key, port.baud_key)
{
return Err(ConfigViolation::related(
port.role_field,
port.baud_field,
rule,
));
}
if !valid_text(port.role_key) || !valid_text(port.baud_key) {
return Err(ConfigViolation::field(port.role_field, rule));
}
let fields = [port.role_field, port.baud_field];
let keys = [port.role_key, port.baud_key];
let mut j = 0;
while j < i {
let other = &descriptors[j];
if port.id.get() == other.id.get() {
return Err(ConfigViolation::related(
port.role_field,
other.role_field,
rule,
));
}
let other_fields = [other.role_field, other.baud_field];
let other_keys = [other.role_key, other.baud_key];
let mut a = 0;
while a < 2 {
let mut b = 0;
while b < 2 {
if fields[a].get() == other_fields[b].get() || same_text(keys[a], other_keys[b])
{
return Err(ConfigViolation::related(fields[a], other_fields[b], rule));
}
b += 1;
}
a += 1;
}
j += 1;
}
let mut disabled = false;
j = 0;
while j < port.supported_roles.len() {
let (value, label) = port.supported_roles[j];
let mut canonical = false;
let mut k = 0;
while k < Role::CONFIG_ENUM_OPTIONS.len() {
let option = Role::CONFIG_ENUM_OPTIONS[k];
if value == option.0 && same_text(label, option.1) {
canonical = true;
}
k += 1;
}
if !canonical {
return Err(ConfigViolation::field(port.role_field, rule));
}
k = 0;
while k < j {
if port.supported_roles[k].0 == value {
return Err(ConfigViolation::field(port.role_field, rule));
}
k += 1;
}
if value == Role::Disabled as u8 {
disabled = true;
}
j += 1;
}
if !disabled {
return Err(ConfigViolation::field(port.role_field, rule));
}
if port.allowed_bauds.is_empty() {
return Err(ConfigViolation::field(port.baud_field, rule));
}
j = 0;
while j < port.allowed_bauds.len() {
if port.allowed_bauds[j] == 0 {
return Err(ConfigViolation::field(port.baud_field, rule));
}
let mut k = 0;
while k < j {
if port.allowed_bauds[j] == port.allowed_bauds[k] {
return Err(ConfigViolation::field(port.baud_field, rule));
}
k += 1;
}
j += 1;
}
if duplicates(port.resources) {
return Err(ConfigViolation::field(port.role_field, rule));
}
j = 0;
while j < port.resources.len() {
let mut k = 0;
while k < reserved_resources.len() {
if port.resources[j] == reserved_resources[k] {
return Err(ConfigViolation::field(port.role_field, rule));
}
k += 1;
}
j += 1;
}
i += 1;
}
Ok(())
}
const fn duplicates(values: &[u16]) -> bool {
let mut i = 0;
while i < values.len() {
let mut j = 0;
while j < i {
if values[i] == values[j] {
return true;
}
j += 1;
}
i += 1;
}
false
}
const fn same_text(left: &str, right: &str) -> bool {
let left = left.as_bytes();
let right = right.as_bytes();
if left.len() != right.len() {
return false;
}
let mut i = 0;
while i < left.len() {
if left[i] != right[i] {
return false;
}
i += 1;
}
true
}
const fn valid_text(text: &str) -> bool {
if text.is_empty() || text.len() > u8::MAX as usize {
return false;
}
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0 {
return false;
}
i += 1;
}
true
}
+334
View File
@@ -0,0 +1,334 @@
//! Portable, restart-only UART configuration and boot planning.
//!
//! A platform supplies a [`PortCatalog`] with stable UART/field identities and
//! opaque resource tokens for pins, peripherals and DMA streams. USB is
//! initialized separately and must not appear in this inventory.
//!
//! Embed [`Ports`] as an ordinary child of a `#[derive(kaptan_config::Config)]`
//! root. At full-candidate validation, the root's `ConfigModel::validate` should
//! call [`Ports::validate_plan`] with the candidate's publication budget. Keep
//! the returned plan in the boot-active view managed by `ConfigManager`; execute
//! it only at boot. Committing another candidate must not reconfigure hardware.
//!
//! ```
//! use kaptan_config::FieldId;
//! use kaptan_ports::{PortCatalog, PortDescriptor, PortId, PortSettings, Ports, Role};
//!
//! struct BoardPorts;
//! impl PortCatalog<1> for BoardPorts {
//! const DESCRIPTORS: [PortDescriptor; 1] = [PortDescriptor {
//! id: PortId::new(4),
//! role_field: FieldId::new(208), baud_field: FieldId::new(209),
//! role_key: "ports.uart4.role", baud_key: "ports.uart4.baud",
//! supported_roles: Role::CONFIG_ENUM_OPTIONS,
//! allowed_bauds: &[115_200], resources: &[40, 41, 42],
//! }];
//! const RESERVED_RESOURCES: &'static [u16] = &[90];
//! }
//! const FACTORY: Ports<BoardPorts, 1> = Ports::new([
//! PortSettings { role: Role::Mtf01, baud: 115_200 },
//! ]);
//! let plan = FACTORY.validate_plan(1_000, 200).unwrap();
//! assert_eq!(plan.get(PortId::new(4)).unwrap().role, Role::Mtf01);
//! ```
#![no_std]
#![forbid(unsafe_code)]
mod catalog;
mod schema;
use core::marker::PhantomData;
use kaptan_config::{
Config, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
ValidationRule, ValueKind,
};
pub use catalog::{PortCatalog, PortDescriptor, validate_catalog};
/// Stable platform-assigned identity, independent of catalog array position.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Hash)]
#[repr(transparent)]
pub struct PortId(u8);
impl PortId {
/// All `u8` values are available to the platform's stable UART namespace.
pub const fn new(value: u8) -> Self {
Self(value)
}
/// Returns the stable numeric identity.
pub const fn get(self) -> u8 {
self.0
}
}
/// UART service selected for the next boot. Wire values must never be reused.
#[derive(Clone, Copy, Debug, Eq, PartialEq, kaptan_config::ConfigEnum)]
#[repr(u8)]
pub enum Role {
/// No UART initialization or resource claims.
Disabled = 0,
/// Kaptan MAVLink.
#[config_enum(label = "KaptanMavlink")]
Mavlink = 1,
/// MTF-01 range/optical-flow service.
Mtf01 = 2,
}
/// Candidate scalar values for one UART, in native KCFG widths.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PortSettings {
pub role: Role,
pub baud: u32,
}
/// Editable schema child backed by exactly `N` UART settings.
///
/// Construction checks the catalog at compile time, but leaves candidate values
/// for local writes and complete validation. Settings passed to `new` follow
/// catalog declaration order; field and port lookups always use explicit IDs.
pub struct Ports<C: PortCatalog<N>, const N: usize> {
settings: [PortSettings; N],
catalog: PhantomData<fn() -> C>,
}
impl<C: PortCatalog<N>, const N: usize> Copy for Ports<C, N> {}
impl<C: PortCatalog<N>, const N: usize> Clone for Ports<C, N> {
fn clone(&self) -> Self {
*self
}
}
impl<C: PortCatalog<N>, const N: usize> Ports<C, N> {
const CHECK_CATALOG: () = assert!(
validate_catalog(&C::DESCRIPTORS, C::RESERVED_RESOURCES).is_ok(),
"invalid UART port catalog"
);
// Nested arrays avoid unstable generic const expressions such as [T; 2 * N].
const FIELDS: [[FieldDescriptor; 2]; N] = {
let () = Self::CHECK_CATALOG;
schema::fields(&C::DESCRIPTORS)
};
/// Creates an editable candidate, including in a board's factory constant.
pub const fn new(settings: [PortSettings; N]) -> Self {
let () = Self::CHECK_CATALOG;
Self {
settings,
catalog: PhantomData,
}
}
/// Reads candidate settings by stable UART identity.
pub fn get(&self, id: PortId) -> Option<&PortSettings> {
C::DESCRIPTORS
.iter()
.position(|descriptor| descriptor.id == id)
.map(|index| &self.settings[index])
}
/// Validates inventory, local values and resource exclusivity.
///
/// This has no publication budget. Platforms with scheduled MAVLink traffic
/// must use [`Self::validate_plan`] when validating the complete candidate.
pub fn plan(&self) -> Result<PortPlan<N>, ConfigViolation> {
self.validate_plan(0, 0)
}
/// Fully validates and snapshots a boot plan with an 8N1 transmit budget.
///
/// Both arguments are **wire bytes per second**. `reserve` is an additive
/// allowance for unscheduled traffic, not a percentage. Each enabled MAVLink
/// UART must fit `(scheduled_bytes_per_second + reserve) * 10 <= baud`.
/// Supply framing-inclusive publication costs computed from the complete
/// candidate. Arithmetic is checked in `u32`; overflow is `AboveMaximum`,
/// insufficient baud is `UnsupportedCapability`, both on the baud field.
/// Disabled/MTF-01 UARTs and fixed USB do not consume this budget.
pub fn validate_plan(
&self,
scheduled_bytes_per_second: u32,
reserve: u32,
) -> Result<PortPlan<N>, ConfigViolation> {
PortPlan::validate(
&C::DESCRIPTORS,
self.settings,
C::RESERVED_RESOURCES,
scheduled_bytes_per_second,
reserve,
)
}
}
impl<C: PortCatalog<N>, const N: usize> Config for Ports<C, N> {
const FIELD_COUNT: usize = {
let () = Self::CHECK_CATALOG;
N * 2
};
const SNAPSHOT_VALUE_BYTES: usize = N * 5;
const SCHEMA_ID: [u8; 16] =
schema::fingerprint(&Self::FIELDS, &C::DESCRIPTORS, C::RESERVED_RESOURCES);
fn descriptor(index: usize) -> Option<&'static FieldDescriptor> {
Self::FIELDS.get(index / 2).map(|fields| &fields[index % 2])
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
for (descriptor, settings) in C::DESCRIPTORS.iter().zip(&self.settings) {
if id == descriptor.role_field {
return Some(ConfigValue::U8(settings.role.wire_value()));
}
if id == descriptor.baud_field {
return Some(ConfigValue::U32(settings.baud));
}
}
None
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
for (descriptor, settings) in C::DESCRIPTORS.iter().zip(&mut self.settings) {
if id == descriptor.role_field {
let ConfigValue::U8(raw) = value else {
return Err(wrong_type(id, ValueKind::U8, value));
};
let role = Role::try_from_wire(raw).ok_or(FieldWriteError::Violation(
ConfigViolation::field(id, ValidationRule::InvalidEnumValue),
))?;
validate_role(descriptor, role).map_err(FieldWriteError::Violation)?;
// Commit only the accepted scalar: failed writes leave no changes.
settings.role = role;
return Ok(());
}
if id == descriptor.baud_field {
let ConfigValue::U32(baud) = value else {
return Err(wrong_type(id, ValueKind::U32, value));
};
validate_baud(descriptor, baud).map_err(FieldWriteError::Violation)?;
settings.baud = baud;
return Ok(());
}
}
Err(FieldWriteError::UnknownField(id))
}
fn validate(&self) -> Result<(), ConfigViolation> {
self.plan().map(|_| ())
}
}
fn wrong_type(field: FieldId, expected: ValueKind, value: ConfigValue) -> FieldWriteError {
FieldWriteError::WrongType {
field,
expected,
actual: value.kind(),
}
}
fn validate_role(descriptor: &PortDescriptor, role: Role) -> Result<(), ConfigViolation> {
if descriptor
.supported_roles
.iter()
.any(|option| option.0 == role.wire_value())
{
Ok(())
} else {
Err(ConfigViolation::field(
descriptor.role_field,
ValidationRule::UnsupportedCapability,
))
}
}
fn validate_baud(descriptor: &PortDescriptor, baud: u32) -> Result<(), ConfigViolation> {
if descriptor.allowed_bauds.contains(&baud) {
Ok(())
} else {
Err(ConfigViolation::field(
descriptor.baud_field,
ValidationRule::UnsupportedCapability,
))
}
}
/// Immutable, validated boot decisions. Contains no physical resource handles.
///
/// Construction always validates the catalog and candidate decisions.
/// Iteration includes disabled ports, in catalog declaration order. Executors
/// match the stable ID and role, and skip initialization for `Disabled`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PortPlan<const N: usize> {
ids: [PortId; N],
settings: [PortSettings; N],
}
impl<const N: usize> PortPlan<N> {
/// Validates projected settings against a borrowed catalog, including its
/// resources and the same 8N1 budget as [`Ports::validate_plan`]. This lets
/// platforms consume a board catalog without introducing another catalog type.
pub fn validate(
descriptors: &[PortDescriptor; N],
settings: [PortSettings; N],
reserved_resources: &[u16],
scheduled_bytes_per_second: u32,
reserve: u32,
) -> Result<Self, ConfigViolation> {
validate_catalog(descriptors, reserved_resources)?;
for (index, (descriptor, setting)) in descriptors.iter().zip(&settings).enumerate() {
validate_role(descriptor, setting.role)?;
validate_baud(descriptor, setting.baud)?;
if setting.role == Role::Disabled {
continue;
}
for (previous, other) in descriptors[..index].iter().zip(&settings) {
if other.role != Role::Disabled
&& descriptor
.resources
.iter()
.any(|resource| previous.resources.contains(resource))
{
return Err(ConfigViolation::related(
descriptor.role_field,
previous.role_field,
ValidationRule::Inconsistent,
));
}
}
}
for (descriptor, setting) in descriptors.iter().zip(&settings) {
if setting.role == Role::Mavlink {
let required = scheduled_bytes_per_second
.checked_add(reserve)
.and_then(|bytes| bytes.checked_mul(10))
.ok_or(ConfigViolation::field(
descriptor.baud_field,
ValidationRule::AboveMaximum,
))?;
if required > setting.baud {
return Err(ConfigViolation::field(
descriptor.baud_field,
ValidationRule::UnsupportedCapability,
));
}
}
}
Ok(Self {
ids: descriptors.map(|descriptor| descriptor.id),
settings,
})
}
/// Returns the boot decision for a stable UART identity.
pub fn get(&self, id: PortId) -> Option<&PortSettings> {
self.ids
.iter()
.position(|&port| port == id)
.map(|index| &self.settings[index])
}
/// Reads all boot decisions without exposing mutable settings.
pub fn iter(&self) -> impl ExactSizeIterator<Item = (PortId, &PortSettings)> + '_ {
self.ids.iter().copied().zip(self.settings.iter())
}
}
+105
View File
@@ -0,0 +1,105 @@
use kaptan_config::{ConfigValue, FieldDescriptor, FieldId, ValueKind, validation};
use crate::PortDescriptor;
pub(crate) const fn fields<const N: usize>(
ports: &[PortDescriptor; N],
) -> [[FieldDescriptor; 2]; N] {
let empty = FieldDescriptor {
id: FieldId::new(1),
display_key: "",
kind: ValueKind::U8,
minimum: None,
maximum: None,
enum_options: &[],
unit: "",
finite: false,
nonzero: false,
};
let mut fields = [[empty; 2]; N];
let mut i = 0;
while i < N {
let port = &ports[i];
let mut minimum = u32::MAX;
let mut maximum = 0;
let mut j = 0;
while j < port.allowed_bauds.len() {
let baud = port.allowed_bauds[j];
if baud < minimum {
minimum = baud;
}
if baud > maximum {
maximum = baud;
}
j += 1;
}
fields[i] = [
FieldDescriptor {
id: port.role_field,
display_key: port.role_key,
enum_options: port.supported_roles,
..empty
},
FieldDescriptor {
id: port.baud_field,
display_key: port.baud_key,
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(minimum)),
maximum: Some(ConfigValue::U32(maximum)),
unit: "baud",
..empty
},
];
i += 1;
}
fields
}
// Reuse Config's schema combiner, including constraints not expressible by a
// scalar descriptor (whitelist holes, port IDs and resources).
// Ordering is schema-significant, like derived Config children; IDs themselves
// remain stable when descriptors are reordered.
pub(crate) const fn fingerprint<const N: usize>(
fields: &[[FieldDescriptor; 2]; N],
ports: &[PortDescriptor; N],
reserved: &[u16],
) -> [u8; 16] {
let mut state = *b"KAPTAN-PORTS-v01";
state = number(state, N as u64);
let mut i = 0;
while i < N {
state = validation::__combine_schema_ids(&[state, validation::__schema_id(&fields[i])]);
state = number(state, ports[i].id.get() as u64);
state = number(state, ports[i].allowed_bauds.len() as u64);
let mut j = 0;
while j < ports[i].allowed_bauds.len() {
state = number(state, ports[i].allowed_bauds[j] as u64);
j += 1;
}
state = number(state, ports[i].resources.len() as u64);
j = 0;
while j < ports[i].resources.len() {
state = number(state, ports[i].resources[j] as u64);
j += 1;
}
i += 1;
}
state = number(state, reserved.len() as u64);
i = 0;
while i < reserved.len() {
state = number(state, reserved[i] as u64);
i += 1;
}
state
}
const fn number(state: [u8; 16], value: u64) -> [u8; 16] {
let bytes = value.to_le_bytes();
let mut encoded = [0; 16];
let mut i = 0;
while i < bytes.len() {
encoded[i] = bytes[i];
i += 1;
}
validation::__combine_schema_ids(&[state, encoded])
}
+642
View File
@@ -0,0 +1,642 @@
use kaptan_config::{
Config, ConfigManager, ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor,
FieldId, FieldWriteError, KcfgExporter, KcfgImporter, ReplaceError, ValidationRule, ValueKind,
decode_kcfg, encode_edited_kcfg,
};
use kaptan_ports::{
PortCatalog, PortDescriptor, PortId, PortPlan, PortSettings, Ports, Role, validate_catalog,
};
const UART1: PortDescriptor = PortDescriptor {
id: PortId::new(1),
role_field: FieldId::new(201),
baud_field: FieldId::new(202),
role_key: "ports.uart1.role",
baud_key: "ports.uart1.baud",
supported_roles: Role::CONFIG_ENUM_OPTIONS,
allowed_bauds: &[9_600, 57_600, 115_200],
resources: &[10, 11],
};
const UART4: PortDescriptor = PortDescriptor {
id: PortId::new(4),
role_field: FieldId::new(207),
baud_field: FieldId::new(208),
role_key: "ports.uart4.role",
baud_key: "ports.uart4.baud",
resources: &[40, 41],
..UART1
};
const OFF: PortSettings = PortSettings {
role: Role::Disabled,
baud: 115_200,
};
const MAVLINK: PortSettings = PortSettings {
role: Role::Mavlink,
..OFF
};
const MTF01: PortSettings = PortSettings {
role: Role::Mtf01,
..OFF
};
// Intentionally not numeric order; neither type needs Clone/Copy bounds.
struct Two;
impl PortCatalog<2> for Two {
const DESCRIPTORS: [PortDescriptor; 2] = [UART4, UART1];
const RESERVED_RESOURCES: &'static [u16] = &[90, 91];
}
const FACTORY: Ports<Two, 2> = Ports::new([MTF01, MAVLINK]);
struct Conflict;
impl PortCatalog<2> for Conflict {
const DESCRIPTORS: [PortDescriptor; 2] = [
PortDescriptor {
resources: &[40, 10],
..UART4
},
UART1,
];
}
struct Limited;
impl PortCatalog<2> for Limited {
const DESCRIPTORS: [PortDescriptor; 2] = [
PortDescriptor {
supported_roles: &[Role::CONFIG_ENUM_OPTIONS[0], Role::CONFIG_ENUM_OPTIONS[2]],
..UART4
},
UART1,
];
}
struct Eight;
impl PortCatalog<8> for Eight {
const DESCRIPTORS: [PortDescriptor; 8] = [
UART4,
UART1,
PortDescriptor {
id: PortId::new(2),
role_field: FieldId::new(203),
baud_field: FieldId::new(204),
role_key: "ports.uart2.role",
baud_key: "ports.uart2.baud",
resources: &[20],
..UART1
},
PortDescriptor {
id: PortId::new(3),
role_field: FieldId::new(205),
baud_field: FieldId::new(206),
role_key: "ports.uart3.role",
baud_key: "ports.uart3.baud",
resources: &[30],
..UART1
},
PortDescriptor {
id: PortId::new(5),
role_field: FieldId::new(209),
baud_field: FieldId::new(210),
role_key: "ports.uart5.role",
baud_key: "ports.uart5.baud",
resources: &[50],
..UART1
},
PortDescriptor {
id: PortId::new(6),
role_field: FieldId::new(211),
baud_field: FieldId::new(212),
role_key: "ports.uart6.role",
baud_key: "ports.uart6.baud",
resources: &[60],
..UART1
},
PortDescriptor {
id: PortId::new(7),
role_field: FieldId::new(213),
baud_field: FieldId::new(214),
role_key: "ports.uart7.role",
baud_key: "ports.uart7.baud",
resources: &[70],
..UART1
},
PortDescriptor {
id: PortId::new(8),
role_field: FieldId::new(215),
baud_field: FieldId::new(216),
role_key: "ports.uart8.role",
baud_key: "ports.uart8.baud",
resources: &[80],
..UART1
},
];
}
#[derive(Debug, PartialEq)]
enum Started {
UsbMavlink,
Uart(PortId, Role, u32),
}
// A host executor consumes the actual immutable plan, not a parallel policy model.
fn execute<const N: usize>(plan: &PortPlan<N>) -> Vec<Started> {
let mut started = vec![Started::UsbMavlink];
for (id, settings) in plan.iter() {
match settings.role {
Role::Disabled => {}
Role::Mavlink | Role::Mtf01 => {
started.push(Started::Uart(id, settings.role, settings.baud))
}
}
}
started
}
#[test]
fn crossed_roles_follow_stable_ids_and_published_plan_is_a_snapshot() {
let mut candidate = FACTORY;
let boot = candidate.plan().unwrap();
candidate
.set_field(UART1.role_field, ConfigValue::U8(2))
.unwrap();
candidate
.set_field(UART4.role_field, ConfigValue::U8(1))
.unwrap();
candidate
.set_field(UART4.baud_field, ConfigValue::U32(57_600))
.unwrap();
let crossed = candidate.validate_plan(1_000, 200).unwrap();
assert_eq!(
execute(&crossed),
vec![
Started::UsbMavlink,
Started::Uart(PortId::new(4), Role::Mavlink, 57_600),
Started::Uart(PortId::new(1), Role::Mtf01, 115_200),
]
);
assert_eq!(boot.get(PortId::new(4)), Some(&MTF01));
assert_eq!(boot.get(PortId::new(1)), Some(&MAVLINK));
assert!(boot.get(PortId::new(0)).is_none());
assert_eq!(crossed.iter().len(), 2);
struct Reordered;
impl PortCatalog<2> for Reordered {
const DESCRIPTORS: [PortDescriptor; 2] = [UART1, UART4];
}
let reordered = Ports::<Reordered, 2>::new([MAVLINK, MTF01]).plan().unwrap();
for (id, settings) in boot.iter() {
assert_eq!(reordered.get(id), Some(settings));
}
}
#[test]
fn resource_conflicts_block_execution_but_disabled_ports_claim_nothing() {
let mut candidate = Ports::<Conflict, 2>::new([MTF01, MAVLINK]);
assert_eq!(
candidate.plan().unwrap_err(),
ConfigViolation::related(
UART1.role_field,
UART4.role_field,
ValidationRule::Inconsistent,
)
);
candidate
.set_field(UART1.role_field, ConfigValue::U8(0))
.unwrap();
assert_eq!(
execute(&candidate.plan().unwrap()),
vec![
Started::UsbMavlink,
Started::Uart(PortId::new(4), Role::Mtf01, 115_200),
]
);
candidate
.set_field(UART4.role_field, ConfigValue::U8(0))
.unwrap();
assert_eq!(
execute(&candidate.plan().unwrap()),
vec![Started::UsbMavlink]
);
}
#[test]
fn eight_ports_allow_independent_sensor_services() {
let mut settings = [OFF; 8];
settings[2] = MAVLINK; // UART2
settings[7] = MTF01; // UART8
let mut candidate = Ports::<Eight, 8>::new(settings);
let plan = candidate.validate_plan(5_000, 760).unwrap();
assert_eq!(plan.iter().len(), 8);
assert_eq!(
execute(&plan),
vec![
Started::UsbMavlink,
Started::Uart(PortId::new(2), Role::Mavlink, 115_200),
Started::Uart(PortId::new(8), Role::Mtf01, 115_200),
]
);
candidate
.set_field(UART1.role_field, ConfigValue::U8(2))
.unwrap();
let plan = candidate.plan().unwrap();
assert_eq!(plan.get(UART1.id), Some(&MTF01));
assert_eq!(plan.get(PortId::new(8)), Some(&MTF01));
}
#[test]
fn local_setters_keep_native_widths_enforce_choices_and_roll_back() {
let mut ports = Ports::<Limited, 2>::new([MTF01, OFF]);
for (field, value, expected) in [
(
UART4.role_field,
ConfigValue::U8(1),
ValidationRule::UnsupportedCapability,
),
(
UART4.role_field,
ConfigValue::U8(255),
ValidationRule::InvalidEnumValue,
),
(
UART4.baud_field,
ConfigValue::U32(0),
ValidationRule::UnsupportedCapability,
),
(
UART4.baud_field,
ConfigValue::U32(38_400),
ValidationRule::UnsupportedCapability,
),
] {
assert_eq!(
ports.set_field(field, value),
Err(FieldWriteError::Violation(ConfigViolation::field(
field, expected
)))
);
assert_eq!(ports.get(PortId::new(4)), Some(&MTF01));
}
for (field, value, expected) in [
(UART4.role_field, ConfigValue::U32(2), ValueKind::U8),
(UART4.baud_field, ConfigValue::U16(9_600), ValueKind::U32),
] {
assert_eq!(
ports.set_field(field, value),
Err(FieldWriteError::WrongType {
field,
expected,
actual: value.kind(),
})
);
}
let missing = FieldId::new(999);
assert_eq!(ports.field_value(missing), None);
assert_eq!(
ports.set_field(missing, ConfigValue::U8(0)),
Err(FieldWriteError::UnknownField(missing))
);
// A disabled port still retains a locally valid baud for the next role.
assert!(
Ports::<Two, 2>::new([PortSettings { baud: 0, ..OFF }, OFF])
.plan()
.is_err()
);
assert!(Ports::<Limited, 2>::new([MAVLINK, OFF]).plan().is_err());
}
#[test]
fn budgets_include_reserve_and_check_both_overflows_on_each_mavlink_uart() {
let low_baud = PortSettings {
baud: 9_600,
..MAVLINK
};
let ports = Ports::<Two, 2>::new([low_baud, MAVLINK]);
assert!(ports.validate_plan(900, 60).is_ok());
assert_eq!(
ports.validate_plan(900, 61).unwrap_err(),
ConfigViolation::field(UART4.baud_field, ValidationRule::UnsupportedCapability)
);
for (scheduled, reserve) in [(u32::MAX, 1), (u32::MAX / 10 + 1, 0), (0, u32::MAX)] {
assert_eq!(
ports.validate_plan(scheduled, reserve).unwrap_err(),
ConfigViolation::field(UART4.baud_field, ValidationRule::AboveMaximum)
);
}
let second = Ports::<Two, 2>::new([MAVLINK, low_baud]);
assert_eq!(
second.validate_plan(961, 0).unwrap_err().primary(),
Some(UART1.baud_field)
);
// No UART MAVLink transmitter: the USB budget is platform-owned.
assert!(
Ports::<Two, 2>::new([MTF01, OFF])
.validate_plan(u32::MAX, u32::MAX)
.is_ok()
);
}
#[test]
fn inventory_rejects_ambiguous_metadata_and_fixed_resource_claims() {
assert!(validate_catalog(&Two::DESCRIPTORS, &[90, 91]).is_ok());
assert!(validate_catalog(&Conflict::DESCRIPTORS, &[]).is_ok());
let invalid_ports = [
PortDescriptor {
id: UART1.id,
..UART4
},
PortDescriptor {
role_field: UART1.baud_field,
..UART4
},
PortDescriptor {
baud_field: UART4.role_field,
..UART4
},
PortDescriptor {
baud_key: UART1.role_key,
..UART4
},
PortDescriptor {
role_key: UART4.baud_key,
..UART4
},
PortDescriptor {
role_key: "",
..UART4
},
PortDescriptor {
baud_key: "bad\0key",
..UART4
},
PortDescriptor {
supported_roles: &[(0, "Disabled"), (0, "Disabled")],
..UART4
},
PortDescriptor {
supported_roles: &[(0, "Disabled"), (3, "Unknown")],
..UART4
},
PortDescriptor {
supported_roles: &[(1, "KaptanMavlink")],
..UART4
},
PortDescriptor {
supported_roles: &[(0, "wrong label")],
..UART4
},
PortDescriptor {
allowed_bauds: &[],
..UART4
},
PortDescriptor {
allowed_bauds: &[0],
..UART4
},
PortDescriptor {
allowed_bauds: &[115_200, 115_200],
..UART4
},
PortDescriptor {
resources: &[40, 40],
..UART4
},
];
for port in invalid_ports {
assert_eq!(
validate_catalog(&[port, UART1], &[]).unwrap_err().rule(),
ValidationRule::Inconsistent
);
}
assert!(validate_catalog(&Two::DESCRIPTORS, &[40]).is_err());
assert!(validate_catalog(&Two::DESCRIPTORS, &[90, 90]).is_err());
const NO_MTF: PortDescriptor = PortDescriptor {
supported_roles: &[Role::CONFIG_ENUM_OPTIONS[0], Role::CONFIG_ENUM_OPTIONS[1]],
..UART1
};
assert!(validate_catalog(&[NO_MTF], &[]).is_ok());
}
#[test]
fn descriptor_tables_and_fingerprint_include_catalog_constraints() {
assert_eq!(<Ports<Two, 2> as Config>::FIELD_COUNT, 4);
assert_eq!(<Ports<Eight, 8> as Config>::FIELD_COUNT, 16);
assert_eq!(<Ports<Eight, 8> as Config>::SNAPSHOT_VALUE_BYTES, 40);
assert_eq!(
Ports::<Limited, 2>::descriptor(0).unwrap().enum_options,
&[(0, "Disabled"), (2, "Mtf01")]
);
let baud = Ports::<Limited, 2>::descriptor(1).unwrap();
assert_eq!(
(baud.kind, baud.minimum, baud.maximum),
(
ValueKind::U32,
Some(ConfigValue::U32(9_600)),
Some(ConfigValue::U32(115_200)),
)
);
assert!(Ports::<Two, 2>::descriptor(4).is_none());
assert!(Ports::<Two, 2>::descriptor(usize::MAX).is_none());
assert_ne!(
<Ports<Two, 2> as Config>::SCHEMA_ID,
<Ports<Limited, 2> as Config>::SCHEMA_ID
);
struct WhitelistHole;
impl PortCatalog<2> for WhitelistHole {
const DESCRIPTORS: [PortDescriptor; 2] = [
PortDescriptor {
allowed_bauds: &[9_600, 115_200],
..UART4
},
UART1,
];
const RESERVED_RESOURCES: &'static [u16] = Two::RESERVED_RESOURCES;
}
// Same min/max, different exact whitelist must still change schema identity.
assert_eq!(
Ports::<Two, 2>::descriptor(1),
Ports::<WhitelistHole, 2>::descriptor(1)
);
assert_ne!(
<Ports<Two, 2> as Config>::SCHEMA_ID,
<Ports<WhitelistHole, 2> as Config>::SCHEMA_ID
);
}
// Real derived root plus ConfigModel, using the same Ports child and actual
// KCFG import/export and restart-only lifecycle as a platform integration.
#[derive(Clone, kaptan_config::Config)]
struct Publications {
#[config(id = 50, key = "telemetry.wire_bytes_per_second", unit = "B/s")]
bytes_per_second: u32,
}
#[derive(Clone, kaptan_config::Config)]
struct Root {
ports: Ports<Two, 2>,
publications: Publications,
}
struct Active {
ports: PortPlan<2>,
bytes_per_second: u32,
}
impl ConfigModel for Root {
type Active = Active;
type Context = u32; // reserve wire bytes/sec
const SCHEMA_ID: [u8; 16] = <Self as Config>::SCHEMA_ID;
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
<Self as Config>::descriptor(usize::from(index))
}
fn field_count() -> u16 {
<Self as Config>::FIELD_COUNT as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
Config::field_value(self, id)
}
fn active_field_value(active: &Active, id: FieldId) -> Option<ConfigValue> {
if id == FieldId::new(50) {
return Some(ConfigValue::U32(active.bytes_per_second));
}
for descriptor in Two::DESCRIPTORS {
let settings = active.ports.get(descriptor.id)?;
if id == descriptor.role_field {
return Some(ConfigValue::U8(settings.role.wire_value()));
}
if id == descriptor.baud_field {
return Some(ConfigValue::U32(settings.baud));
}
}
None
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
Config::set_field(self, id, value)
}
fn validate(&self, reserve: &u32) -> Result<Active, ConfigViolation> {
Config::validate(self)?;
Ok(Active {
ports: self
.ports
.validate_plan(self.publications.bytes_per_second, *reserve)?,
bytes_per_second: self.publications.bytes_per_second,
})
}
}
#[derive(Default)]
struct MemoryStore(Option<Root>);
impl ConfigStore<Root> for MemoryStore {
type Error = core::convert::Infallible;
fn load(&mut self, _: &Root) -> Result<Option<Root>, Self::Error> {
Ok(self.0.clone())
}
fn persist(&mut self, candidate: &Root) -> Result<(), Self::Error> {
self.0 = Some(candidate.clone());
Ok(())
}
}
fn factory() -> Root {
Root {
ports: FACTORY,
publications: Publications {
bytes_per_second: 900,
},
}
}
fn import_edit(factory: &Root, mut edit: impl FnMut(FieldId, ConfigValue) -> ConfigValue) -> Root {
let exporter = KcfgExporter::new(factory, factory, false).unwrap();
let mut bytes = vec![0; exporter.length()];
assert_eq!(exporter.read(0, &mut bytes).unwrap(), bytes.len());
let artifact =
decode_kcfg(&bytes, &mut [0; kaptan_config::KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
let mut edited = vec![0; bytes.len()];
let edited = encode_edited_kcfg(&artifact, true, &mut edited, |field| {
edit(field.id(), field.value())
})
.unwrap();
let mut importer = KcfgImporter::<Root>::new(factory).unwrap();
for chunk in edited.chunks(7) {
importer.push(factory, chunk).unwrap();
}
importer.finish().unwrap().candidate
}
#[test]
fn real_kcfg_composition_exports_native_fields_and_commits_only_for_restart() {
let factory = factory();
let exporter = KcfgExporter::new(&factory, &factory, false).unwrap();
let mut bytes = vec![0; exporter.length()];
exporter.read(0, &mut bytes).unwrap();
let artifact =
decode_kcfg(&bytes, &mut [0; kaptan_config::KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
assert_eq!(artifact.schema_id(), <Root as Config>::SCHEMA_ID);
assert_eq!(artifact.field_count(), 5);
let fields: Vec<_> = artifact.fields().collect();
assert_eq!(
(fields[0].id(), fields[0].kind(), fields[0].value()),
(UART4.role_field, ValueKind::U8, ConfigValue::U8(2))
);
assert_eq!(
(fields[1].id(), fields[1].kind(), fields[1].value()),
(UART4.baud_field, ValueKind::U32, ConfigValue::U32(115_200))
);
assert_eq!(
fields[0]
.enum_options()
.map(|option| (option.value(), option.label()))
.collect::<Vec<_>>(),
Role::CONFIG_ENUM_OPTIONS
);
let crossed = import_edit(&factory, |id, value| {
if id == UART4.role_field {
ConfigValue::U8(1)
} else if id == UART1.role_field {
ConfigValue::U8(2)
} else {
value
}
});
let mut manager = ConfigManager::recover(factory.clone(), 60, MemoryStore::default()).unwrap();
let before = manager.active().ports;
assert!(manager.replace(crossed).unwrap().restart_required);
assert_eq!(manager.active().ports, before);
let committed = manager.committed().unwrap();
assert_eq!(
committed.ports.get(PortId::new(4)).unwrap().role,
Role::Mavlink
);
let restarted =
ConfigManager::recover(factory, 60, MemoryStore(Some(committed.clone()))).unwrap();
assert_eq!(
restarted.active().ports.get(PortId::new(4)).unwrap().role,
Role::Mavlink
);
assert!(!restarted.restart_required());
}
#[test]
fn imported_publication_budget_must_fit_uart_capacity() {
let factory = factory();
let mut manager = ConfigManager::recover(factory.clone(), 60, MemoryStore::default()).unwrap();
// Both edits are locally valid, but together exceed the UART's capacity.
let overloaded = import_edit(&factory, |id, value| {
if id == UART1.baud_field {
ConfigValue::U32(9_600)
} else if id == FieldId::new(50) {
ConfigValue::U32(901)
} else {
value
}
});
assert!(
matches!(manager.replace(overloaded), Err(ReplaceError::InvalidCandidate(violation))
if violation.primary() == Some(UART1.baud_field))
);
assert!(!manager.restart_required());
assert_eq!(
manager.committed().unwrap().ports.get(PortId::new(1)),
Some(&MAVLINK)
);
}