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

@@ -0,0 +1,239 @@
extern crate std;
use std::vec::Vec;
use kaptan_config::{Config, ConfigModel, ConfigValue, FieldId};
use kaptan_ports::{PortCatalog, PortDescriptor, PortSettings, Ports, Role};
use platform_stm32f7::{
f722::{F722Route, Icm42688Imu, ImuService, Mpu6000Imu},
*,
};
use crate::f722::{config as target_config, geprc, kakute, ports};
fn board_route<B, R, Imu>()
where
B: F7Board,
B::ImmutableConfig: F7ImmutableConfig<Route = R>,
R: F722Route<Imu = Imu>,
Imu: ImuService,
{
}
#[test]
fn board_defaults_disable_configurable_ports() {
board_route::<kakute::KakuteF7MiniV3, Mpu6000Spi1, Mpu6000Imu>();
board_route::<geprc::GeprcF722V2, Icm42688Spi3, Icm42688Imu>();
fn check<B: F7Board>() {
let candidate =
target_config::UserConfigModel::<_, B::ImmutableConfig, 5>::new(B::FACTORY_USER_CONFIG);
let active = candidate.validate(&B::IMMUTABLE_CONFIG).unwrap();
let inventory = B::IMMUTABLE_CONFIG.f722().hardware.serial();
assert_eq!(inventory.len(), 5);
assert_eq!(
active
.ports
.iter()
.map(|(id, _)| id.get())
.collect::<Vec<_>>(),
[1, 2, 3, 4, 6]
);
assert!(
active
.ports
.iter()
.all(|(_, port)| port.role == Role::Disabled)
);
let fields: Vec<_> = (0..B::UserConfig::FIELD_COUNT)
.map(|index| B::UserConfig::descriptor(index).unwrap())
.collect();
assert_eq!(fields.len(), 7 + 2 * inventory.len());
assert_eq!(
fields[..7]
.iter()
.map(|field| field.id.get())
.collect::<Vec<_>>(),
[101, 102, 105, 103, 104, 106, 107]
);
for (port, fields) in inventory
.descriptors()
.iter()
.zip(fields[7..].chunks_exact(2))
{
assert_eq!(fields, port.fields().iter().collect::<Vec<_>>());
assert_eq!(
candidate.field_value(port.baud_field),
Some(ConfigValue::U32(115_200))
);
}
assert_eq!(inventory.index(kaptan_ports::PortId::new(0)), None);
}
check::<kakute::KakuteF7MiniV3>();
check::<geprc::GeprcF722V2>();
}
struct Both;
impl PortCatalog<3> for Both {
const DESCRIPTORS: [PortDescriptor; 3] = [UART1, UART2, UART4];
}
#[test]
fn independent_imu_serial_and_led_facts_allow_cross_route_compositions() {
let mpu = Mpu6000Spi1 {
mount: ImuMount::BodyAligned,
config: Mpu6000Config {
accelerometer: AccelerometerRange::PlusMinus4G,
gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond,
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
sample_rate_divider: 9,
},
};
let icm = Icm42688Spi3 {
mount: ImuMount::Yaw180,
};
fn check<R: F722Route>(route: R) {
for led in [StatusLed::ActiveLowPd2, StatusLed::ActiveHighPa13] {
let inventory = SerialInventory::new(&Both::DESCRIPTORS);
let hardware =
F722Hardware::new(route, inventory, led, VerticalSensor::None, MotorPins::Four);
for sensor in [0, 1, 2] {
let mut settings = [PortSettings {
role: Role::Mavlink,
baud: 115_200,
}; 3];
settings[sensor].role = Role::Mtf01;
let root = Ports::<Both, 3>::new(settings);
let plan = ports::project_ports::<_, 3>(&root, hardware.serial(), 3400).unwrap();
assert_eq!(
plan.iter().filter(|(_, p)| p.role == Role::Mavlink).count(),
2
);
assert_eq!(
plan.iter().filter(|(_, p)| p.role == Role::Mtf01).count(),
1
);
}
}
}
check(mpu);
check(icm);
}
#[test]
fn double_sensor_unsupported_baud_and_missing_inventory_fields_are_rejected() {
let inventory = SerialInventory::new(&Both::DESCRIPTORS);
let mut root = Ports::<Both, 3>::new(
[PortSettings {
role: Role::Mtf01,
baud: 115_200,
}; 3],
);
let error = ports::project_ports::<_, 3>(&root, inventory, 0).unwrap_err();
assert_eq!(error.primary(), Some(UART2.role_field));
assert_eq!(error.related_field(), Some(UART1.role_field));
root.set_field(FieldId::new(203), ConfigValue::U8(0))
.unwrap();
root.set_field(FieldId::new(207), ConfigValue::U8(0))
.unwrap();
assert!(ports::project_ports::<_, 3>(&root, inventory, 0).is_ok());
assert!(
root.set_field(FieldId::new(202), ConfigValue::U32(12345))
.is_err()
);
root.set_field(FieldId::new(202), ConfigValue::U32(230400))
.unwrap();
assert!(ports::project_ports::<_, 3>(&root, inventory, 0).is_err());
root.set_field(FieldId::new(201), ConfigValue::U8(1))
.unwrap();
assert!(ports::project_ports::<_, 3>(&root, inventory, 23041).is_err());
assert!(
ports::project_ports::<_, 3>(&geprc::GeprcF722V2::FACTORY_USER_CONFIG, inventory, 0)
.is_err()
);
}
#[test]
fn schema_inventory_and_storage_count_must_agree_even_when_disabled() {
let root = Ports::<Both, 3>::new(
[PortSettings {
role: Role::Disabled,
baud: 115_200,
}; 3],
);
// No silent unused fields, including fields whose current role is Disabled.
assert!(ports::project_ports::<_, 1>(&root, SerialInventory::new(&[UART1]), 0).is_err());
assert!(ports::project_ports::<_, 0>(&root, SerialInventory::new(&[]), 0).is_err());
assert!(
ports::project_ports::<_, 2>(&root, SerialInventory::new(&Both::DESCRIPTORS), 0).is_err()
);
assert!(
ports::project_ports::<_, 3>(&root, SerialInventory::new(&[UART1, UART1, UART2]), 0)
.is_err()
);
}
#[test]
fn rejects_invalid_port_descriptors() {
let root = Ports::<Both, 3>::new(
[PortSettings {
role: Role::Disabled,
baud: 115_200,
}; 3],
);
const DISABLED_ROLES: [(u8, &str); 1] = [Role::CONFIG_ENUM_OPTIONS[0]];
const INVALID_INVENTORIES: [[PortDescriptor; 3]; 6] = {
let mut rows = [[UART1, UART2, UART4]; 6];
rows[0][0].id = kaptan_ports::PortId::new(3);
rows[1][0].resources = &[8, 9, 101];
rows[2][0].role_field = FieldId::new(209);
rows[3][0].role_key = "ports.other.role";
rows[4][0].allowed_bauds = &[9600];
rows[5][0].supported_roles = &DISABLED_ROLES;
rows
};
for descriptors in &INVALID_INVENTORIES {
let inventory = SerialInventory::new(descriptors);
assert!(ports::project_ports::<_, 3>(&root, inventory, 0).is_err());
}
struct WrongMetadata;
impl PortCatalog<1> for WrongMetadata {
const DESCRIPTORS: [PortDescriptor; 1] = [PortDescriptor {
allowed_bauds: &[9600],
..UART1
}];
}
let root = Ports::<WrongMetadata, 1>::new([PortSettings {
role: Role::Disabled,
baud: 9600,
}]);
assert!(ports::project_ports::<_, 1>(&root, SerialInventory::new(&[UART1]), 0).is_err());
}
#[test]
fn stable_ids_follow_catalog_order_and_disabled_ports_keep_their_slots() {
struct Reordered;
impl PortCatalog<2> for Reordered {
const DESCRIPTORS: [PortDescriptor; 2] = [UART4, UART1];
}
let inventory = SerialInventory::new(&Reordered::DESCRIPTORS);
let mut root = Ports::<Reordered, 2>::new(
[PortSettings {
role: Role::Disabled,
baud: 115_200,
}; 2],
);
for role in [Role::Disabled, Role::Mavlink, Role::Mtf01] {
root.set_field(UART4.role_field, ConfigValue::U8(role.wire_value()))
.unwrap();
let plan = ports::project_ports::<_, 2>(&root, inventory, 0).unwrap();
assert_eq!(
plan.iter().map(|(id, _)| id.get()).collect::<Vec<_>>(),
[4, 1]
);
assert_eq!(plan.get(UART4.id).unwrap().role, role);
assert_eq!(inventory.index(UART4.id), Some(0));
assert_eq!(inventory.index(UART1.id), Some(1));
for id in [0, 2, 3, 5, 255] {
assert_eq!(inventory.index(kaptan_ports::PortId::new(id)), None);
}
}
}