Files
Onur Özkan f9a61a086f 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.
2026-11-07 18:12:11 +03:00

643 lines
20 KiB
Rust

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)
);
}