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:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user