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,220 @@
use kaptan_config::{Config, ConfigManager, ConfigModel, ConfigStore, ConfigValue, FieldId};
use platform_stm32f7::{
F722Hardware, ImuMount, LevelFilter, MotorPins, Mpu6000Config, Mpu6000Spi1, SerialInventory,
StatusLed, VerticalSensor,
};
type F722ImmutableConfig = platform_stm32f7::F722ImmutableConfig<Mpu6000Spi1>;
use crate::f722::config as target_config;
use target_config::UserConfigModel;
#[derive(Clone, kaptan_config::Config)]
struct MavlinkConfig {
#[config(id = 101, key = "mavlink.system_id", unit = "id", range(min = 1))]
system_id: u8,
#[config(id = 102, key = "mavlink.component_id", unit = "id", range(min = 1))]
component_id: u8,
#[config(
enum,
id = 105,
key = "mavlink.logging.level_filter",
unit = "log_level"
)]
log_level_filter: LevelFilter,
}
#[derive(Clone, kaptan_config::Config)]
struct TelemetryConfig {
#[config(
id = 103,
key = "telemetry.imu_hz",
unit = "Hz",
range(min = 1, max = 100)
)]
imu_hz: u8,
#[config(
id = 104,
key = "telemetry.heartbeat_hz",
unit = "Hz",
range(min = 1, max = 10)
)]
heartbeat_hz: u8,
}
#[derive(Clone, kaptan_config::Config)]
struct DriverConfig {
#[config(
id = 1000,
key = "test_driver.gain",
unit = "count",
range(min = 1, max = 100)
)]
driver_gain: u8,
}
#[derive(Clone, kaptan_config::Config)]
struct TestUserConfig {
mavlink: MavlinkConfig,
telemetry: TelemetryConfig,
driver: DriverConfig,
}
const IMMUTABLE: F722ImmutableConfig = F722ImmutableConfig {
name: "active-candidate-test",
service_hz: 100,
hardware: F722Hardware::new(
Mpu6000Spi1 {
mount: ImuMount::BodyAligned,
config: Mpu6000Config {
accelerometer: platform_stm32f7::AccelerometerRange::PlusMinus4G,
gyroscope: platform_stm32f7::GyroscopeRange::PlusMinus500DegreesPerSecond,
digital_low_pass_filter: platform_stm32f7::DigitalLowPassFilter::Bandwidth42Hz,
sample_rate_divider: 9,
},
},
SerialInventory::new(&[]),
StatusLed::ActiveLowPd2,
VerticalSensor::None,
MotorPins::Four,
),
};
struct OneRecord<C>(Option<C>);
impl<C: Clone> ConfigStore<C> for OneRecord<C> {
type Error = ();
fn load(&mut self, _: &C) -> Result<Option<C>, Self::Error> {
Ok(self.0.take())
}
fn persist(&mut self, candidate: &C) -> Result<(), Self::Error> {
self.0 = Some(candidate.clone());
Ok(())
}
}
fn factory() -> TestUserConfig {
TestUserConfig {
mavlink: MavlinkConfig {
system_id: 1,
component_id: 1,
log_level_filter: LevelFilter::Info,
},
telemetry: TelemetryConfig {
imu_hz: 20,
heartbeat_hz: 2,
},
driver: DriverConfig { driver_gain: 7 },
}
}
struct MalformedProjection {
inner: TestUserConfig,
system_id: Option<ConfigValue>,
}
impl Config for MalformedProjection {
const FIELD_COUNT: usize = TestUserConfig::FIELD_COUNT;
const SNAPSHOT_VALUE_BYTES: usize = TestUserConfig::SNAPSHOT_VALUE_BYTES;
const SCHEMA_ID: [u8; 16] = TestUserConfig::SCHEMA_ID;
fn descriptor(index: usize) -> Option<&'static kaptan_config::FieldDescriptor> {
TestUserConfig::descriptor(index)
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
if id == FieldId::new(101) {
self.system_id
} else {
self.inner.field_value(id)
}
}
fn set_field(
&mut self,
id: FieldId,
value: ConfigValue,
) -> Result<(), kaptan_config::FieldWriteError> {
self.inner.set_field(id, value)
}
fn validate(&self) -> Result<(), kaptan_config::ConfigViolation> {
self.inner.validate()
}
}
#[test]
fn mavlink_logging_policy_is_projected_and_validated_independently() {
assert_eq!(
core::mem::size_of::<target_config::F722UserRuntime>(),
9,
"runtime projection must retain narrow board-native scalar widths"
);
let mut candidate = UserConfigModel::<TestUserConfig, F722ImmutableConfig, 0>::new(factory());
candidate
.set_field(FieldId::new(105), ConfigValue::U8(0))
.unwrap();
let active = candidate.validate(&IMMUTABLE).unwrap();
let runtime = target_config::project_runtime(&active.candidate, &IMMUTABLE).unwrap();
assert_eq!(runtime.mavlink_log_level_filter, LevelFilter::Off);
}
#[test]
fn runtime_projection_rejects_missing_and_wrong_kind_values() {
for (system_id, rule) in [
(None, kaptan_config::ValidationRule::Inconsistent),
(
Some(ConfigValue::Bool(true)),
kaptan_config::ValidationRule::Inconsistent,
),
] {
let malformed = MalformedProjection {
inner: factory(),
system_id,
};
let issue = target_config::project_runtime(&malformed, &IMMUTABLE).unwrap_err();
assert_eq!(issue.primary(), Some(FieldId::new(101)));
assert_eq!(issue.rule(), rule);
}
}
#[test]
fn active_projection_retains_the_persisted_board_owned_user_root() {
let factory = UserConfigModel::<TestUserConfig, F722ImmutableConfig, 0>::new(factory());
let mut persisted = factory.clone();
persisted
.set_field(FieldId::new(1000), ConfigValue::U8(42))
.expect("driver field must accept a valid persisted value");
let manager = ConfigManager::recover(factory, IMMUTABLE, OneRecord(Some(persisted)))
.expect("synthetic persisted user configuration must recover");
assert_eq!(
manager.active().candidate.field_value(FieldId::new(1000)),
Some(ConfigValue::U8(42)),
"the active projection must retain the validated persisted field"
);
}
#[test]
fn committing_a_user_edit_does_not_mutate_the_boot_active_view() {
let factory = UserConfigModel::<TestUserConfig, F722ImmutableConfig, 0>::new(factory());
let mut manager = ConfigManager::recover(factory, IMMUTABLE, OneRecord(None)).unwrap();
let mut replacement = manager.committed().unwrap().clone();
replacement
.set_field(FieldId::new(1000), ConfigValue::U8(42))
.unwrap();
manager.replace(replacement).unwrap();
assert_eq!(
manager.active().candidate.field_value(FieldId::new(1000)),
Some(ConfigValue::U8(7))
);
assert_eq!(
manager.committed().unwrap().field_value(FieldId::new(1000)),
Some(ConfigValue::U8(42))
);
}
@@ -0,0 +1,775 @@
use kaptan_config::{
ConfigManager, ConfigModel, ConfigStore, ConfigValue, DualSlotJournal, DualSlotNor, FieldId,
KcfgExporter, KcfgImporter, KcfgResultDetail, ReplaceError, decode_kcfg, encode_edited_kcfg,
};
use kaptan_file_mavlink::{
Content, ControlRequest, ControlResponse, DATA_BYTES, DataRequest, DispatchResult,
FileOperation, FileProvider, FileRequest, FileResponse, FileService, FileStatus, IngressId,
Principal, Target, TransferPeer, crc32c, wire,
};
use kaptan_ports::{PortId, Role};
use platform_stm32f7::{F7Board, F7ImmutableConfig};
extern crate std;
use crate::f722::{config as target_config, geprc, kakute};
use std::{cell::RefCell, rc::Rc, vec, vec::Vec};
use target_config::{
BootDiagnostic, F722ConfigFile, F722FlashError, UserConfigModel, recover_configuration,
};
type Board = kakute::KakuteF7MiniV3;
type C = <Board as F7Board>::UserConfig;
type I = <Board as F7Board>::ImmutableConfig;
const PORT_COUNT: usize = Board::IMMUTABLE_CONFIG.hardware.serial().len();
type Model = UserConfigModel<C, I, PORT_COUNT>;
const N: usize = platform_stm32f7::config_snapshot_length::<C>();
type Store = DualSlotJournal<RamNor, N>;
type Manager = ConfigManager<Model, Store>;
type Provider = F722ConfigFile<C, I, PORT_COUNT, N, Store>;
struct FlashState {
bytes: [[u8; 2048]; 2],
operations: usize,
bytes_read: usize,
fail_after: Option<usize>,
fail_after_commit: bool,
fail_reads: bool,
}
impl Default for FlashState {
fn default() -> Self {
Self {
bytes: [[0xff; 2048]; 2],
operations: 0,
bytes_read: 0,
fail_after: None,
fail_after_commit: false,
fail_reads: false,
}
}
}
#[derive(Clone, Default)]
struct RamNor(Rc<RefCell<FlashState>>);
impl RamNor {
fn operation(state: &mut FlashState) -> Result<(), F722FlashError> {
if state.fail_after == Some(state.operations) {
return Err(F722FlashError::Controller);
}
state.operations += 1;
Ok(())
}
}
impl DualSlotNor for RamNor {
type Error = F722FlashError;
fn slot_size(&self) -> usize {
2048
}
fn read(&mut self, slot: usize, offset: usize, output: &mut [u8]) -> Result<(), Self::Error> {
let mut state = self.0.borrow_mut();
if state.fail_reads {
return Err(F722FlashError::Controller);
}
state.bytes_read += output.len();
output.copy_from_slice(&state.bytes[slot][offset..offset + output.len()]);
Ok(())
}
fn erase_slot(&mut self, slot: usize) -> Result<(), Self::Error> {
let mut state = self.0.borrow_mut();
Self::operation(&mut state)?;
state.bytes[slot].fill(0xff);
Ok(())
}
fn program_word(&mut self, slot: usize, offset: usize, word: u32) -> Result<(), Self::Error> {
let mut state = self.0.borrow_mut();
Self::operation(&mut state)?;
for (byte, value) in state.bytes[slot][offset..offset + 4]
.iter_mut()
.zip(word.to_le_bytes())
{
assert_eq!(*byte & value, value);
*byte = value;
}
// Fault after the durable marker, before journal verification.
if state.fail_after_commit && word == u32::from_le_bytes(*b"DONE") {
return Err(F722FlashError::Verification);
}
Ok(())
}
}
fn factory() -> Model {
Model::new(Board::FACTORY_USER_CONFIG)
}
fn recover(flash: &RamNor) -> Manager {
recover_configuration(
factory(),
Board::IMMUTABLE_CONFIG,
Store::new(flash.clone()),
)
.unwrap()
}
fn artifact(candidate: &Model) -> Vec<u8> {
let factory = factory();
let export = KcfgExporter::new(candidate, &factory, false).unwrap();
let mut bytes = vec![0; export.length()];
assert_eq!(export.read(0, &mut bytes).unwrap(), bytes.len());
bytes
}
fn decode(bytes: &[u8]) -> Model {
let factory = factory();
let mut importer = KcfgImporter::new(&factory).unwrap();
for chunk in bytes.chunks(37) {
importer.push(&factory, chunk).unwrap();
}
importer.finish().unwrap().candidate
}
const TARGET: Target = Target::new(1, 1);
const PEER: TransferPeer = TransferPeer {
principal: Principal::new(42, 7),
ingress: IngressId(0),
};
fn control(operation: FileOperation, position: u32, path: &[u8], bytes: &[u8]) -> FileRequest {
FileRequest::Control(ControlRequest {
operation,
target: TARGET,
position,
file_size: bytes.len() as u32,
checksum_crc32c: if bytes.is_empty() { 0 } else { crc32c(bytes) },
transfer_id: 9,
content: Content::try_new(path).unwrap(),
})
}
fn exchange(
service: &mut FileService,
provider: &mut impl FileProvider,
peer: TransferPeer,
request: FileRequest,
) -> DispatchResult {
let request = wire::decode_request(&wire::encode_request(request)).unwrap();
let result = service.dispatch(provider, peer, request).unwrap();
assert_eq!(
wire::decode_response(&wire::encode_response(result.response)).unwrap(),
result.response
);
result
}
fn accepted(result: DispatchResult) -> Result<ControlResponse, FileStatus> {
let FileResponse::Control(response) = result.response else {
panic!("control response expected");
};
if response.status != FileStatus::Ok {
return Err(response.status);
}
assert!(result.refresh_lease);
Ok(response)
}
fn read_result(provider: &mut impl FileProvider, path: &str) -> Result<Vec<u8>, FileStatus> {
let mut service = FileService::new(TARGET);
let metadata = accepted(exchange(
&mut service,
provider,
PEER,
control(FileOperation::OpenRead, 0, path.as_bytes(), &[]),
))?;
let mut bytes = vec![0; metadata.file_size as usize];
for (i, chunk) in bytes.chunks_mut(DATA_BYTES).enumerate() {
let result = exchange(
&mut service,
provider,
PEER,
control(FileOperation::Read, (i * DATA_BYTES) as u32, &[], &[]),
);
assert!(result.refresh_lease);
let FileResponse::Data(response) = result.response else {
panic!("data response expected");
};
chunk.copy_from_slice(&response.data[..chunk.len()]);
}
assert_eq!(crc32c(&bytes), metadata.checksum_crc32c);
accepted(exchange(
&mut service,
provider,
PEER,
control(FileOperation::Close, bytes.len() as u32, &[], &[]),
))?;
Ok(bytes)
}
fn read(provider: &mut impl FileProvider, path: &str) -> Vec<u8> {
read_result(provider, path).unwrap()
}
fn write_to(provider: &mut Provider, path: &[u8], candidate: &Model) -> Result<(), FileStatus> {
let base = read_result(provider, "/config/user.kcfg")
.or_else(|_| read_result(provider, "/config/active.kcfg"))?;
upload(provider, path, &edit(&base, candidate)).map(|_| ())
}
fn edit(base: &[u8], candidate: &Model) -> Vec<u8> {
let source = decode_kcfg(base, &mut [0; kaptan_config::KCFG_FIELD_ID_SCRATCH_BYTES]).unwrap();
let mut edited = vec![0; base.len()];
encode_edited_kcfg(&source, false, &mut edited, |field| {
candidate.field_value(field.id()).unwrap()
})
.unwrap();
edited
}
fn upload(provider: &mut Provider, path: &[u8], bytes: &[u8]) -> Result<Content, FileStatus> {
let mut service = FileService::new(TARGET);
accepted(exchange(
&mut service,
provider,
PEER,
control(FileOperation::OpenWrite, 0, path, bytes),
))?;
for (i, chunk) in bytes.chunks(DATA_BYTES).enumerate() {
let mut data = [0; DATA_BYTES];
data[..chunk.len()].copy_from_slice(chunk);
let request = FileRequest::Data(DataRequest {
target: TARGET,
transfer_id: 9,
position: (i * DATA_BYTES) as u32,
data,
});
for id in [1, 2, 4] {
let foreign = exchange(
&mut service,
provider,
TransferPeer {
ingress: IngressId(id),
..PEER
},
request,
);
assert!(!foreign.refresh_lease);
assert_eq!(accepted(foreign), Err(FileStatus::InvalidTransfer));
}
let result = exchange(&mut service, provider, PEER, request);
accepted(result)?;
assert_eq!(
exchange(&mut service, provider, PEER, request),
result,
"retry must not append twice"
);
}
let result = exchange(
&mut service,
provider,
PEER,
control(FileOperation::Close, bytes.len() as u32, &[], &[]),
);
if let FileResponse::Control(response) = result.response
&& response.status == FileStatus::RequestConflict
{
let (_, text) = KcfgResultDetail::decode(response.content.as_bytes()).unwrap();
assert!(
text.contains("reload"),
"conflict must carry a readable KCFG prefix"
);
}
accepted(result).map(|response| response.content)
}
fn write(provider: &mut Provider, candidate: &Model) -> Result<(), FileStatus> {
write_to(provider, b"/config/user.kcfg", candidate)
}
#[test]
fn both_actual_providers_export_only_their_declared_ports() {
fn check<B: F7Board, const PORTS: usize, const BYTES: usize>() {
let factory = UserConfigModel::<B::UserConfig, B::ImmutableConfig, PORTS>::new(
B::FACTORY_USER_CONFIG,
);
let manager = recover_configuration(
factory.clone(),
B::IMMUTABLE_CONFIG,
DualSlotJournal::<_, BYTES>::new(RamNor::default()),
)
.unwrap();
assert_eq!(manager.active().ports.iter().len(), PORTS);
let mut provider = F722ConfigFile::<_, _, PORTS, BYTES, _>::new(manager);
for path in ["/config/user.kcfg", "/config/active.kcfg"] {
let bytes = read(&mut provider, path);
let mut importer = KcfgImporter::new(&factory).unwrap();
importer.push(&factory, &bytes).unwrap();
let exported = importer.finish().unwrap().candidate;
for port in [
platform_stm32f7::UART1,
platform_stm32f7::UART2,
platform_stm32f7::UART3,
platform_stm32f7::UART4,
platform_stm32f7::UART6,
] {
let available = B::IMMUTABLE_CONFIG
.f722()
.hardware
.serial()
.index(port.id)
.is_some();
assert_eq!(exported.field_value(port.role_field).is_some(), available);
assert_eq!(exported.field_value(port.baud_field).is_some(), available);
}
}
}
check::<kakute::KakuteF7MiniV3, PORT_COUNT, N>();
check::<
geprc::GeprcF722V2,
{ geprc::GeprcF722V2::IMMUTABLE_CONFIG.hardware.serial().len() },
{
platform_stm32f7::config_snapshot_length::<<geprc::GeprcF722V2 as F7Board>::UserConfig>(
)
},
>();
}
#[test]
fn actual_board_kcfg_provider_round_trips_and_active_is_read_only_restart_only() {
let flash = RamNor::default();
let mut provider = Provider::new(recover(&flash));
let _initial = read(&mut provider, "/config/active.kcfg");
let mut next = decode(&read(&mut provider, "/config/user.kcfg"));
next.set_field(FieldId::new(201), ConfigValue::U8(2))
.unwrap();
next.set_field(FieldId::new(207), ConfigValue::U8(1))
.unwrap();
write(&mut provider, &next).unwrap();
let active = decode(&read(&mut provider, "/config/active.kcfg"));
let committed = decode(&read(&mut provider, "/config/user.kcfg"));
assert_eq!(
active.field_value(FieldId::new(201)),
Some(ConfigValue::U8(0))
);
assert_eq!(
active.field_value(FieldId::new(207)),
Some(ConfigValue::U8(0))
);
assert_eq!(
committed.field_value(FieldId::new(201)),
Some(ConfigValue::U8(2))
);
assert_eq!(
committed.field_value(FieldId::new(207)),
Some(ConfigValue::U8(1))
);
assert_eq!(
write_to(&mut provider, b"/config/active.kcfg", &next),
Err(FileStatus::NotFound)
);
let reboot = recover(&flash);
assert_eq!(
reboot.active().ports.get(PortId::new(4)).unwrap().role,
Role::Mavlink
);
assert_eq!(reboot.diagnostic(), BootDiagnostic::Healthy);
}
#[test]
fn latest_readable_invalid_candidate_is_retained_for_usb_repair_without_enabling_uarts() {
let flash = RamNor::default();
let mut store = Store::new(flash.clone());
store.persist(&factory()).unwrap();
let mut invalid = factory();
invalid
.set_field(FieldId::new(201), ConfigValue::U8(2))
.unwrap();
invalid
.set_field(FieldId::new(207), ConfigValue::U8(2))
.unwrap();
let error = invalid.validate(&Board::IMMUTABLE_CONFIG).err().unwrap();
assert_eq!(error.primary(), Some(FieldId::new(207)));
assert_eq!(error.related_field(), Some(FieldId::new(201)));
store.persist(&invalid).unwrap();
let manager = recover(&flash);
assert!(matches!(
manager.diagnostic(),
BootDiagnostic::InvalidPersisted(_)
));
assert!(
manager
.active()
.ports
.iter()
.all(|(_, p)| p.role == Role::Disabled)
);
assert_eq!(
manager.committed().unwrap().field_value(FieldId::new(201)),
Some(ConfigValue::U8(2))
);
let before = flash.0.borrow().operations;
let mut provider = Provider::new(manager);
assert_eq!(
decode(&read(&mut provider, "/config/user.kcfg")).field_value(FieldId::new(201)),
Some(ConfigValue::U8(2))
);
assert_eq!(
flash.0.borrow().operations,
before,
"boot and inspection never rewrite the candidate"
);
assert_eq!(
write(&mut provider, &invalid),
Err(FileStatus::ContentRejected)
);
assert_eq!(flash.0.borrow().operations, before);
let mut repaired = factory();
repaired
.set_field(FieldId::new(201), ConfigValue::U8(1))
.unwrap();
write(&mut provider, &repaired).unwrap();
let active = decode(&read(&mut provider, "/config/active.kcfg"));
for id in [201, 207] {
assert_eq!(
active.field_value(FieldId::new(id)),
Some(ConfigValue::U8(0))
);
}
assert_eq!(
recover(&flash)
.active()
.ports
.get(PortId::new(1))
.unwrap()
.role,
Role::Mavlink
);
}
#[test]
fn failed_persist_keeps_active_and_invalidates_committed() {
let probe = RamNor::default();
let mut manager = recover(&probe);
let mut next = factory();
next.set_field(FieldId::new(201), ConfigValue::U8(1))
.unwrap();
manager.replace(next.clone()).unwrap();
let operations = probe.0.borrow().operations;
for fail_after in 0..operations {
let flash = RamNor::default();
let mut store = Store::new(flash.clone());
store.persist(&factory()).unwrap();
let mut manager = recover(&flash);
{
let mut state = flash.0.borrow_mut();
state.operations = 0;
state.fail_after = Some(fail_after);
}
assert!(matches!(
manager.replace(next.clone()),
Err(ReplaceError::Store(_))
));
assert!(manager.committed().is_none());
assert_eq!(
manager.active().ports.get(PortId::new(1)).unwrap().role,
Role::Disabled
);
assert!(matches!(
manager.replace(factory()),
Err(ReplaceError::DurableStateUnknown)
));
let mut provider = Provider::new(manager);
assert_eq!(
read_result(&mut provider, "/config/user.kcfg"),
Err(FileStatus::StorageFailure)
);
assert_eq!(
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
Some(ConfigValue::U8(0))
);
flash.0.borrow_mut().fail_after = None;
assert_eq!(
recover(&flash)
.active()
.ports
.get(PortId::new(1))
.unwrap()
.role,
Role::Disabled
);
}
let flash = RamNor::default();
let mut manager = recover(&flash);
flash.0.borrow_mut().fail_after_commit = true;
assert!(matches!(manager.replace(next), Err(ReplaceError::Store(_))));
assert!(manager.committed().is_none());
assert_eq!(
manager.active().ports.get(PortId::new(1)).unwrap().role,
Role::Disabled
);
flash.0.borrow_mut().fail_after_commit = false;
assert_eq!(
recover(&flash)
.active()
.ports
.get(PortId::new(1))
.unwrap()
.role,
Role::Mavlink
);
}
#[test]
fn failed_provider_close_blocks_saves_but_keeps_active_export_available() {
let flash = RamNor::default();
let mut provider = Provider::new(recover(&flash));
let mut next = factory();
next.set_field(FieldId::new(201), ConfigValue::U8(1))
.unwrap();
flash.0.borrow_mut().fail_after = Some(2);
assert_eq!(write(&mut provider, &next), Err(FileStatus::StorageFailure));
flash.0.borrow_mut().fail_after = None;
assert_eq!(
write(&mut provider, &factory()),
Err(FileStatus::StorageFailure)
);
assert_eq!(
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
Some(ConfigValue::U8(0))
);
write_to(&mut provider, b"/config/reset.kcfg", &factory()).unwrap();
write(&mut provider, &next).unwrap();
assert_eq!(
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
Some(ConfigValue::U8(0))
);
}
#[test]
fn storage_read_failure_is_explicit_and_cannot_activate_factory_uart_defaults() {
let flash = RamNor::default();
flash.0.borrow_mut().fail_reads = true;
let mut manager = recover(&flash);
assert_eq!(manager.diagnostic(), BootDiagnostic::StorageUnknown);
assert!(
manager
.active()
.ports
.iter()
.all(|(_, p)| p.role == Role::Disabled)
);
assert!(manager.committed().is_none());
assert!(matches!(
manager.replace(factory()),
Err(ReplaceError::DurableStateUnknown)
));
}
#[test]
fn occupied_but_undecodable_storage_is_not_a_healthy_factory_boot() {
let flash = RamNor::default();
flash.0.borrow_mut().bytes[1][128] = 0;
let manager = recover(&flash);
assert_eq!(manager.diagnostic(), BootDiagnostic::StorageUnknown);
assert!(manager.committed().is_none());
assert!(
manager
.active()
.ports
.iter()
.all(|(_, p)| p.role == Role::Disabled)
);
assert_eq!(flash.0.borrow().operations, 0);
assert_eq!(flash.0.borrow().bytes[1][128], 0);
}
#[test]
fn two_downloaded_drafts_conflict_before_noop_and_fresh_noop_skips_flash() {
let flash = RamNor::default();
let mut provider = Provider::new(recover(&flash));
let a = read(&mut provider, "/config/user.kcfg");
let b = read(&mut provider, "/config/user.kcfg");
let mut next = decode(&b);
next.set_field(FieldId::new(201), ConfigValue::U8(1))
.unwrap();
upload(&mut provider, b"/config/user.kcfg", &edit(&b, &next)).unwrap();
let operations = flash.0.borrow().operations;
for stale in [a.clone(), edit(&a, &next)] {
assert_eq!(
upload(&mut provider, b"/config/user.kcfg", &stale),
Err(FileStatus::RequestConflict)
);
assert_eq!(flash.0.borrow().operations, operations);
}
let fresh = read(&mut provider, "/config/user.kcfg");
let result = upload(&mut provider, b"/config/user.kcfg", &fresh).unwrap();
assert!(
KcfgResultDetail::decode(result.as_bytes())
.unwrap()
.0
.restart_required()
);
assert_eq!(flash.0.borrow().operations, operations);
assert_eq!(
decode(&read(&mut provider, "/config/active.kcfg")).field_value(FieldId::new(201)),
Some(ConfigValue::U8(0))
);
assert_eq!(
recover(&flash)
.active()
.ports
.get(PortId::new(1))
.unwrap()
.role,
Role::Mavlink
);
}
// Use the codec to produce an incompatible snapshot without duplicating its layout.
#[derive(Clone)]
struct OldSchema(Model);
impl ConfigModel for OldSchema {
type Active = <Model as ConfigModel>::Active;
type Context = I;
const SCHEMA_ID: [u8; 16] = *b"OLD-BOARD-SCHEMA";
fn descriptor(index: u16) -> Option<&'static kaptan_config::FieldDescriptor> {
Model::descriptor(index)
}
fn field_count() -> u16 {
Model::field_count()
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
self.0.field_value(id)
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
Model::active_field_value(active, id)
}
fn set_field(
&mut self,
id: FieldId,
value: ConfigValue,
) -> Result<(), kaptan_config::FieldWriteError> {
self.0.set_field(id, value)
}
fn validate(&self, context: &I) -> Result<Self::Active, kaptan_config::ConfigViolation> {
self.0.validate(context)
}
}
#[test]
fn old_schema_can_only_be_reset_explicitly_to_exact_factory_from_active_artifact() {
let flash = RamNor::default();
Store::new(flash.clone())
.persist(&OldSchema(factory()))
.unwrap();
let before = flash.0.borrow().operations;
assert!(matches!(
ConfigManager::recover(
factory(),
Board::IMMUTABLE_CONFIG,
Store::new(flash.clone())
),
Err(kaptan_config::StartupError::UnreadablePersisted)
));
let manager = recover(&flash);
assert_eq!(manager.diagnostic(), BootDiagnostic::StorageUnknown);
let mut provider = Provider::new(manager);
assert_eq!(flash.0.borrow().operations, before);
assert_eq!(
read_result(&mut provider, "/config/user.kcfg"),
Err(FileStatus::StorageFailure)
);
let active = read(&mut provider, "/config/active.kcfg");
let mut nonfactory = factory();
nonfactory
.set_field(FieldId::new(201), ConfigValue::U8(1))
.unwrap();
assert_eq!(
upload(
&mut provider,
b"/config/reset.kcfg",
&edit(&active, &nonfactory)
),
Err(FileStatus::ContentRejected)
);
assert_eq!(flash.0.borrow().operations, before);
let reset = edit(&active, &factory());
assert_eq!(
upload(&mut provider, b"/config/user.kcfg", &reset),
Err(FileStatus::StorageFailure)
);
let result = upload(&mut provider, b"/config/reset.kcfg", &reset).unwrap();
assert!(
!KcfgResultDetail::decode(result.as_bytes())
.unwrap()
.0
.restart_required()
);
assert!(flash.0.borrow().operations > before);
assert_eq!(
artifact(&decode(&read(&mut provider, "/config/active.kcfg"))),
artifact(&decode(&active))
);
assert_eq!(
decode(&read(&mut provider, "/config/user.kcfg")).field_value(FieldId::new(201)),
Some(ConfigValue::U8(0))
);
let reboot = recover(&flash);
assert_eq!(reboot.diagnostic(), BootDiagnostic::Healthy);
assert_eq!(
reboot.active().ports.get(PortId::new(1)).unwrap().role,
Role::Disabled
);
assert!(!reboot.restart_required());
}
#[test]
fn reset_failures_remain_unknown_and_explicit_retry_can_recover_after_durable_marker() {
for after_commit in [false, true] {
let flash = RamNor::default();
Store::new(flash.clone())
.persist(&OldSchema(factory()))
.unwrap();
let mut provider = Provider::new(recover(&flash));
let active = read(&mut provider, "/config/active.kcfg");
let reset = edit(&active, &factory());
{
let mut state = flash.0.borrow_mut();
state.fail_after_commit = after_commit;
if !after_commit {
state.fail_after = Some(state.operations + 2);
}
}
assert_eq!(
upload(&mut provider, b"/config/reset.kcfg", &reset),
Err(FileStatus::StorageFailure)
);
assert_eq!(
read_result(&mut provider, "/config/user.kcfg"),
Err(FileStatus::StorageFailure)
);
assert_eq!(read(&mut provider, "/config/active.kcfg"), active);
{
let mut state = flash.0.borrow_mut();
state.fail_after_commit = false;
state.fail_after = None;
}
assert_eq!(
upload(&mut provider, b"/config/user.kcfg", &reset),
Err(FileStatus::StorageFailure)
);
let result = upload(&mut provider, b"/config/reset.kcfg", &reset).unwrap();
assert!(
!KcfgResultDetail::decode(result.as_bytes())
.unwrap()
.0
.restart_required()
);
assert_eq!(
artifact(&decode(&read(&mut provider, "/config/active.kcfg"))),
artifact(&decode(&active))
);
assert_eq!(
recover(&flash)
.active()
.ports
.get(PortId::new(1))
.unwrap()
.role,
Role::Disabled
);
}
}
#[test]
fn readable_journal_recovery_does_not_scan_entire_slots() {
let flash = RamNor::default();
Store::new(flash.clone()).persist(&factory()).unwrap();
flash.0.borrow_mut().bytes_read = 0;
assert_eq!(recover(&flash).diagnostic(), BootDiagnostic::Healthy);
assert!(
flash.0.borrow().bytes_read < 2048,
"readable records should reuse journal recovery"
);
}
@@ -0,0 +1,671 @@
//! Exercises the actual protocol worker and physical UART owners. Only elapsed
//! time, PAC registers and provider latency are simulated; no protocol is copied.
extern crate std;
use super::*;
use crate::f722::serial::{ReceiveEvent, SerialDevice, SerialIo, UartIo};
use crate::f722::{data_channel::exchange_usb, usb};
use kaptan_file_mavlink::{
Content, ControlRequest, FileFailure, FileMetadata, FileOperation, FilePath, FileRequest,
FileStatus,
};
use kaptan_mavlink::{encoder::MavlinkEncoder, receiver::IntegrityMavlinkFramer};
use std::{
boxed::Box,
cell::{Cell, RefCell},
collections::VecDeque,
rc::Rc,
vec::Vec,
};
use usb::test_bus as usb_bus;
const INVENTORY: SerialInventory =
SerialInventory::new(&[crate::UART1, crate::UART2, crate::UART4]);
#[derive(Default)]
struct Registers {
rx: VecDeque<u8>,
tx: Vec<u8>,
tx_ready_irq: bool,
tx_complete_irq: bool,
}
struct MockUart(Rc<RefCell<Registers>>);
impl UartIo for MockUart {
fn tx_ready(&mut self) -> bool {
true
}
fn tx_complete(&mut self) -> bool {
true
}
fn write_byte(&mut self, byte: u8) {
self.0.borrow_mut().tx.push(byte);
}
fn interrupts(&mut self, ready: bool, complete: bool) {
let mut registers = self.0.borrow_mut();
registers.tx_ready_irq = ready;
registers.tx_complete_irq = complete;
}
}
impl SerialDevice for MockUart {
fn receive(&mut self) -> ReceiveEvent {
self.0
.borrow_mut()
.rx
.pop_front()
.map_or(ReceiveEvent::Idle, ReceiveEvent::Byte)
}
}
struct Io {
uart: [SerialIo<MockUart>; 3],
registers: [Rc<RefCell<Registers>>; 3],
sent: Vec<(u8, kaptan::MavMessage)>,
admitted: [usize; 3],
}
impl Io {
fn new() -> Self {
let registers = core::array::from_fn(|_| Rc::new(RefCell::new(Registers::default())));
Self {
uart: core::array::from_fn(|i| {
SerialIo::new(Some(MockUart(registers[i].clone())), i != 2)
}),
registers,
sent: Vec::new(),
admitted: [0; 3],
}
}
fn irq(&mut self, now: Timestamp) {
for i in 0..3 {
if !self.registers[i].borrow().rx.is_empty() {
self.admitted[i] += 1;
}
self.uart[i].on_interrupt(now);
}
}
fn finish_setup_transmit(&mut self, now: Timestamp) {
// The owned TX queue has four pending slots plus reserved/active
// storage. 2048 ready/complete IRQs cover even six maximum-sized frames.
for _ in 0..2048 {
let mut pending = false;
for i in 0..2 {
let enabled = {
let registers = self.registers[i].borrow();
registers.tx_ready_irq || registers.tx_complete_irq
};
if enabled {
pending = true;
self.uart[i].on_interrupt(now);
}
}
if !pending {
return;
}
}
panic!("setup UART transmission did not reach quiescence");
}
fn exchange(&mut self, id: u8, frame: Option<PendingFrame<MavlinkFrame>>) -> IoBatch {
if let Some(frame) = &frame {
self.sent.push((id, checked(frame.frame).decode().unwrap()));
}
if id == 0 {
return match frame {
Some(_) => IoBatch {
accepted: true,
..IoBatch::default()
},
None => IoBatch::default(),
};
}
self.uart[INVENTORY.index(PortId::new(id)).unwrap()].exchange(frame)
}
}
struct SlowProvider<'a> {
work: Option<std::boxed::Box<dyn FnOnce() + 'a>>,
opens: usize,
}
impl FileProvider for SlowProvider<'_> {
fn open_read(&mut self, _: &FilePath) -> Result<FileMetadata, FileFailure> {
self.opens += 1;
if let Some(work) = self.work.take() {
work();
}
Ok(FileMetadata {
size: 0,
checksum_crc32c: 0,
})
}
fn open_write(&mut self, _: &FilePath, _: FileMetadata) -> Result<(), FileFailure> {
unreachable!()
}
fn read(&mut self, _: u32, _: &mut [u8]) -> Result<(), FileFailure> {
unreachable!()
}
fn write(&mut self, _: u32, _: &[u8]) -> Result<(), FileFailure> {
unreachable!()
}
fn close(&mut self) -> Result<Content, FileFailure> {
Ok(Content::EMPTY)
}
fn abort(&mut self) {}
}
fn checked(frame: MavlinkFrame) -> IntegrityCheckedFrame {
let mut parser = IntegrityMavlinkFramer::new();
frame
.raw_bytes()
.iter()
.find_map(|&byte| parser.receive_byte(byte))
.unwrap()
}
fn checked_stream(bytes: &[u8]) -> Vec<IntegrityCheckedFrame> {
let mut parser = IntegrityMavlinkFramer::new();
let frames: Vec<_> = bytes
.iter()
.filter_map(|&byte| parser.receive_byte(byte))
.collect();
assert_eq!(
frames
.iter()
.map(|frame| frame.raw().raw_bytes().len())
.sum::<usize>(),
bytes.len(),
"all transmitted bytes must belong to complete CRC-valid frames"
);
frames
}
fn open(system: u8) -> MavlinkFrame {
let request = FileRequest::Control(ControlRequest {
operation: FileOperation::OpenRead,
target: Target::new(1, 1),
position: 0,
file_size: 0,
checksum_crc32c: 0,
transfer_id: 9,
content: Content::try_new(b"/test").unwrap(),
});
let kaptan::MavMessage::FILE_CONTROL(data) = wire::encode_request(request) else {
unreachable!()
};
MavlinkEncoder::new(system, 7).encode(&data)
}
fn mtf() -> [u8; 27] {
let mut frame = [0; 27];
frame[..6].copy_from_slice(&[0xef, 1, 1, 0x51, 0, 20]);
frame[10..14].copy_from_slice(&1234u32.to_le_bytes());
frame[16] = 1; // range valid
frame[23] = 1; // flow valid
frame[26] = frame[..26]
.iter()
.fold(0u8, |sum, byte| sum.wrapping_add(*byte));
frame
}
fn worker(provider: SlowProvider<'_>) -> ProtocolWorker<SlowProvider<'_>, 3> {
let mailboxes = Box::leak(Box::new((
LatestImuMailbox::new(),
LatestRangeMailbox::new(),
LatestOpticalFlowMailbox::new(),
)));
let (_, imu) = mailboxes.0.split();
let (range_publisher, range) = mailboxes.1.split();
let (flow_publisher, optical_flow) = mailboxes.2.split();
let rates = DataChannelRates {
imu_hz: 1,
heartbeat_hz: 1,
range_hz: Some(98),
optical_flow_hz: None,
};
ProtocolWorker {
inventory: INVENTORY,
usb: Link::new(1, 1, rates).unwrap(),
serial: [
Some(SerialEndpoint::Mavlink(Link::new(1, 1, rates).unwrap())),
Some(SerialEndpoint::Mavlink(Link::new(1, 1, rates).unwrap())),
Some(SerialEndpoint::Mtf01 {
service: Mtf01::new(),
range: range_publisher,
optical_flow: flow_publisher,
}),
],
collector: DataCollector::new(DataSources {
imu,
range,
optical_flow,
}),
config_file: provider,
file_service: FileService::new(Target::new(1, 1)),
file_lease: FileLease::default(),
mavlink_errors: ErrorSink::new(),
mavlink_logs: LogSinkQueue::new(crate::LevelFilter::Off),
}
}
#[test]
fn compact_worker_routes_by_stable_id_and_never_exchanges_missing_ports() {
let ProtocolWorker {
usb,
serial: [_, uart2, mtf01],
collector,
config_file,
file_service,
file_lease,
mavlink_errors,
mavlink_logs,
..
} = worker(SlowProvider {
work: None,
opens: 0,
});
// Non-numeric order makes any residual platform-wide slot mapping fail.
let mut worker = ProtocolWorker {
usb,
serial: [mtf01, uart2],
inventory: SerialInventory::new(&[crate::UART4, crate::UART2]),
collector,
config_file,
file_service,
file_lease,
mavlink_errors,
mavlink_logs,
};
assert!(worker.link(1).is_none());
assert!(worker.link(4).is_none());
assert!(worker.link(2).is_some());
let mut exchanges = Vec::new();
worker.process_worker_events(
2,
|id, frame| {
assert!(frame.is_none());
exchanges.push(id);
IoBatch::default()
},
Timestamp::default,
);
assert_eq!(exchanges, [4, 2, 0]);
worker.dispatch_frame(
2,
checked(open(42)),
&mut |id, frame| {
assert_eq!(id, 2);
assert!(matches!(
checked(frame.unwrap().frame).decode(),
Some(kaptan::MavMessage::FILE_CONTROL(_))
));
IoBatch::default()
},
&mut Timestamp::default,
);
assert_eq!(worker.config_file.opens, 1);
}
#[test]
fn expensive_dispatch_keeps_uarts_and_usb_tx_serviceable_during_rx_backpressure() {
usb_bus::with_link(|host, usb| {
let usb = RefCell::new(usb);
let io = Rc::new(RefCell::new(Io::new()));
let time = Rc::new(Cell::new(0u64));
let mut worker = worker(SlowProvider {
work: None,
opens: 0,
});
let mut exchange = |id, frame| {
let batch = io.borrow_mut().exchange(id, frame);
if id == 0 {
exchange_usb(&mut usb.borrow_mut(), frame, |enabled| {
assert!(
usb.try_borrow_mut().is_err(),
"gate must finish inside the I/O borrow"
);
host.set_rx_interrupt_enabled(enabled)
})
} else {
batch
}
};
// Finish an initial sensor report and advance the real schedule to RANGE.
io.borrow().registers[2].borrow_mut().rx.extend(mtf());
for t in 1..=27 {
io.borrow_mut().irq(Timestamp::from_microseconds(t * 100));
}
time.set(3000);
for _ in 0..2 {
worker.process_worker_events(1, &mut exchange, || {
Timestamp::from_microseconds(time.get())
});
}
assert_eq!(
worker
.collector
.snapshot()
.range
.unwrap()
.timestamp
.as_microseconds(),
2700
);
// Warm-up ticks may route process-global errors raised by another test.
// Explicitly queue one diagnostic locally so the setup/backlog case is
// deterministic without changing or draining the global error ingress.
let mut diagnostic = kaptan::ERROR_DATA::DEFAULT;
diagnostic.error_id = 65001;
let raised_at = b"io_latency_setup:1";
diagnostic.raised_at[..raised_at.len()].copy_from_slice(raised_at);
for id in [0, 1, 2] {
let frame = worker
.link(id)
.unwrap()
.channel
.encode_message(FrameClass::Critical, &diagnostic);
assert!(exchange(id, Some(frame)).accepted);
}
// Settle setup traffic on every transport before queuing workload
// markers. Vital traffic can overtake a pending critical diagnostic;
// without this UART drain, that diagnostic could follow the marker.
io.borrow_mut()
.finish_setup_transmit(Timestamp::from_microseconds(time.get()));
host.collect_packets(&mut usb.borrow_mut());
let uart_tx_start: [usize; 2] = core::array::from_fn(|i| {
let registers = io.borrow().registers[i].clone();
let registers = registers.borrow();
let frames = checked_stream(&registers.tx);
assert!(frames.iter().any(|frame| matches!(
frame.decode(), Some(kaptan::MavMessage::ERROR(data)) if data.error_id == 65001
)));
registers.tx.len()
});
io.borrow_mut().sent.clear();
let heartbeat = MavlinkEncoder::new(1, 1).encode(&kaptan::HEARTBEAT_DATA::default());
for id in [1, 2] {
assert!(
io.borrow_mut()
.exchange(
id,
Some(PendingFrame {
class: FrameClass::Vital,
frame: heartbeat,
}),
)
.accepted
);
}
let mut data = kaptan::FILE_DATA_DATA::DEFAULT;
data.data[..107].fill(0x5a);
let usb_frame = MavlinkEncoder::new(1, 1).encode(&data);
assert_eq!(usb_frame.raw_bytes().len(), 128);
exchange_usb(
&mut usb.borrow_mut(),
Some(PendingFrame {
class: FrameClass::Vital,
frame: usb_frame,
}),
|enabled| host.set_rx_interrupt_enabled(enabled),
);
// Real MAVLink bytes on both UARTs and a second MTF report arrive during
// expensive provider work. Borrowing I/O here would panic if dispatch still
// ran under the caller's I/O lock.
let during = io.clone();
let clock = time.clone();
let physical_usb = &usb;
worker.config_file.work = Some(std::boxed::Box::new(move || {
for i in 0..2 {
during.borrow().registers[i]
.borrow_mut()
.rx
.extend(open(42).raw_bytes());
}
during.borrow().registers[2].borrow_mut().rx.extend(mtf());
let mut transmitted_usb = Vec::new();
for t in 1..=3000 {
clock.set(3000 + t * 100);
during
.borrow_mut()
.irq(Timestamp::from_microseconds(clock.get()));
if t == 1 {
host.write_packet(open(42).raw_bytes());
}
if host.enter_irq() {
physical_usb.borrow_mut().with_rx_interrupt_gate(
|usb| usb.poll(),
|enabled| host.set_rx_interrupt_enabled(enabled),
);
}
if let Some(packet) = host.take_packet() {
transmitted_usb.push(packet);
}
}
for (i, start) in uart_tx_start.into_iter().enumerate() {
let registers = during.borrow().registers[i].clone();
assert_eq!(
&registers.borrow().tx[start..],
heartbeat.raw_bytes(),
"UART {i} must transmit the complete workload marker during provider work"
);
}
assert_eq!(
transmitted_usb.concat(),
usb_frame.raw_bytes(),
"IN completion interrupts submit all remaining bytes during provider work"
);
assert_eq!(
transmitted_usb.iter().map(Vec::len).collect::<Vec<_>>(),
[64, 64, 0]
);
assert!(
!host.rx_interrupt_enabled(),
"only the RX FIFO interrupt waits for the consumer"
);
assert!(host.nvic_enabled());
assert!(
host.events_empty(),
"TX completion was serviced without the worker"
);
}));
host.write_packet(open(42).raw_bytes());
worker.process_worker_events(1, &mut exchange, || {
Timestamp::from_microseconds(time.get())
});
assert_eq!(time.get(), 303000);
// The report that was fresh at worker entry became stale during dispatch.
assert!(
!io.borrow()
.sent
.iter()
.any(|(_, msg)| matches!(msg, kaptan::MavMessage::RANGE(_)))
);
for _ in 0..32 {
worker.process_worker_events(2, &mut exchange, || {
Timestamp::from_microseconds(time.get())
});
}
assert_eq!(
worker
.collector
.snapshot()
.range
.unwrap()
.timestamp
.as_microseconds(),
5700,
"queued MTF bytes retain receipt time, not drain time"
);
assert_eq!(io.borrow().admitted[2], 54);
assert!(
host.rx_interrupt_enabled(),
"draining RX rearms its FIFO interrupt after provider work"
);
for id in [0, 1, 2] {
assert!(
io.borrow().sent.iter().any(|(ingress, msg)| *ingress == id
&& matches!(msg, kaptan::MavMessage::FILE_CONTROL(_))),
"each link receives its own response"
);
}
assert_eq!(
worker.config_file.opens, 1,
"USB retry received during provider work is replayed"
);
assert_eq!(
io.borrow()
.sent
.iter()
.filter(|(id, msg)| *id == 0 && matches!(msg, kaptan::MavMessage::FILE_CONTROL(_)))
.count(),
2
);
// A long silent interval followed by a new byte uses the SAME canonical time
// source (serviced independently by TIM2), not a dormant per-sensor clock.
time.set(60_000_000);
io.borrow().registers[2].borrow_mut().rx.extend(mtf());
for t in 1..=27 {
io.borrow_mut()
.irq(Timestamp::from_microseconds(time.get() + t));
}
for _ in 0..2 {
worker.process_worker_events(2, &mut exchange, || {
Timestamp::from_microseconds(time.get() + 100)
});
}
assert_eq!(
worker
.collector
.snapshot()
.range
.unwrap()
.timestamp
.as_microseconds(),
60_000_027
);
});
}
#[test]
fn each_dispatch_rechecks_lease_and_foreign_requests_do_not_renew_it() {
let mut worker = worker(SlowProvider {
work: None,
opens: 0,
});
let time = Cell::new(0u64);
let mut replies = Vec::new();
worker.dispatch_frame(
0,
checked(open(42)),
&mut |id, frame| {
replies.push((id, checked(frame.unwrap().frame).decode().unwrap()));
IoBatch::default()
},
&mut || Timestamp::from_microseconds(time.get()),
);
assert_eq!(worker.file_lease.deadline, Some(10_000_000));
let mut usb = Some(checked(open(43)));
if let Some(SerialEndpoint::Mavlink(link)) = &mut worker.serial[0] {
for &byte in open(42).raw_bytes() {
link.receiver.receive_byte(byte);
}
}
time.set(9_000_000);
worker.process_worker_events(
2,
|id, frame| {
if let Some(frame) = frame {
replies.push((id, checked(frame.frame).decode().unwrap()));
if id == 0 {
time.set(11_000_000);
}
IoBatch::default()
} else if id == 0 {
IoBatch {
usb: usb.take(),
..IoBatch::default()
}
} else {
IoBatch::default()
}
},
|| Timestamp::from_microseconds(time.get()),
);
let statuses: Vec<_> = replies
.iter()
.filter_map(|(id, msg)| match msg {
kaptan::MavMessage::FILE_CONTROL(data) => Some((*id, data.status as u8)),
_ => None,
})
.collect();
assert_eq!(
statuses,
[
(0, FileStatus::Ok as u8),
(0, FileStatus::Busy as u8),
(1, FileStatus::Ok as u8)
]
);
assert_eq!(worker.config_file.opens, 2);
assert_eq!(worker.file_lease.deadline, Some(21_000_000));
}
#[test]
fn level_triggered_usb_and_continuous_uart_notifications_reach_the_actual_consumer() {
usb_bus::with_link(|host, usb| {
let mut io = Io::new();
let mut worker = worker(SlowProvider {
work: None,
opens: 0,
});
for _ in 0..3 {
host.write_packet(open(42).raw_bytes());
}
let uart_frame = MavlinkEncoder::new(43, 7).heartbeat();
let mut workers = 0;
let mut usb_replies = 0;
for tick in 0..128 {
// Each decision has a genuine priority-14 byte arrival on both UARTs.
// These notifications cannot let a repeated priority-8 USB level
// prevent the priority-7 protocol consumer from running.
for registers in &io.registers[..2] {
registers
.borrow_mut()
.rx
.push_back(uart_frame.raw_bytes()[tick % uart_frame.raw_bytes().len()]);
}
let now = Timestamp::from_microseconds(tick as u64 * 100);
io.irq(now);
if host.enter_irq() {
usb.with_rx_interrupt_gate(
|usb| usb.poll(),
|enabled| host.set_rx_interrupt_enabled(enabled),
);
} else {
workers += 1;
worker.process_worker_events(
2,
|id, frame| {
if id == 0 {
if frame.as_ref().is_some_and(|frame| {
matches!(
checked(frame.frame).decode(),
Some(kaptan::MavMessage::FILE_CONTROL(_))
)
}) {
usb_replies += 1;
}
exchange_usb(usb, frame, |enabled| {
host.set_rx_interrupt_enabled(enabled)
})
} else {
io.exchange(id, frame)
}
},
|| now,
);
}
host.take_packet(); // Host ACK, without a hidden device poll.
}
assert!(workers > 0);
assert_eq!(worker.config_file.opens, 1);
assert_eq!(
usb_replies, 3,
"the consumer decoded the request and replayed both retries"
);
assert_eq!(io.admitted[..2], [128, 128]);
assert_eq!(host.receive_backlog(), (0, false));
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
});
}
@@ -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);
}
}
}
@@ -0,0 +1,332 @@
//! Injected hardware exercises the production shared-state and IRQ mechanism.
extern crate std;
use super::{SharedTimer, TimerBackend};
use std::{
collections::VecDeque,
sync::{Arc, Mutex, mpsc},
thread,
time::Duration,
vec::Vec,
};
const CC1IF: u32 = 1 << 1;
const UIF: u32 = 1;
#[derive(Debug, PartialEq)]
enum Access {
Counter(u32),
Ack,
Compare(u32),
Enable,
Pend,
}
#[derive(Default)]
struct Registers {
cnt: u32,
flags: u32,
compare: u32,
enabled: bool,
pended: bool,
// Advance while writing CCR. `true` means the new compare took effect
// before CNT passed it, so hardware raises a fresh CC1IF.
write_delays: VecDeque<(u32, bool)>,
accesses: Vec<Access>,
}
struct Counter {
registers: Arc<Mutex<Registers>>,
read_gate: Option<(mpsc::Sender<()>, mpsc::Receiver<()>)>,
}
impl TimerBackend for Counter {
fn counter(&mut self) -> u32 {
let cnt = {
let mut registers = self.registers.lock().unwrap();
let cnt = registers.cnt;
registers.accesses.push(Access::Counter(cnt));
cnt
};
if let Some((sampled, resume)) = self.read_gate.take() {
sampled.send(()).unwrap();
resume.recv().unwrap();
}
cnt
}
fn compare_pending(&mut self) -> bool {
self.registers.lock().unwrap().flags & CC1IF != 0
}
fn acknowledge_compare(&mut self) {
let mut registers = self.registers.lock().unwrap();
registers.flags &= !CC1IF;
registers.accesses.push(Access::Ack);
}
fn set_compare(&mut self, counter: u32) {
let mut registers = self.registers.lock().unwrap();
registers.compare = counter;
registers.accesses.push(Access::Compare(counter));
if let Some((advance, raises_flag)) = registers.write_delays.pop_front() {
registers.cnt = registers.cnt.wrapping_add(advance);
if raises_flag {
registers.flags |= CC1IF;
}
}
}
fn enable_compare_interrupt(&mut self) {
let mut registers = self.registers.lock().unwrap();
registers.enabled = true;
registers.accesses.push(Access::Enable);
}
fn pend_interrupt(&mut self) {
let mut registers = self.registers.lock().unwrap();
registers.pended = true;
registers.accesses.push(Access::Pend);
}
}
fn timer() -> (SharedTimer<Counter>, Arc<Mutex<Registers>>) {
let registers = Arc::new(Mutex::new(Registers::default()));
let shared = SharedTimer::new();
assert!(shared.initialize(|| Counter {
registers: registers.clone(),
read_gate: None,
}));
(shared, registers)
}
fn tick(shared: &SharedTimer<Counter>, registers: &Mutex<Registers>, cnt: u32) -> bool {
{
let mut registers = registers.lock().unwrap();
registers.cnt = cnt;
registers.flags |= CC1IF;
}
shared.on_interrupt()
}
#[test]
fn one_time_publication_keeps_irq_disabled_until_successful_init_finishes() {
let (shared, registers) = timer();
assert!(!shared.initialize(|| panic!("must not reinitialize hardware")));
assert!(!registers.lock().unwrap().enabled);
// Other initialization may take time, or fail without ever enabling IRQs.
registers.lock().unwrap().cnt = 2_000_000;
registers.lock().unwrap().flags = CC1IF | UIF;
assert_eq!(shared.now().as_microseconds(), 2_000_000);
assert!(!registers.lock().unwrap().enabled);
registers.lock().unwrap().accesses.clear();
shared.enable_interrupt();
let registers = registers.lock().unwrap();
assert!(registers.enabled);
assert_eq!(registers.flags, UIF);
assert_eq!(
registers.accesses,
[
Access::Ack,
Access::Counter(2_000_000),
Access::Compare(2_010_000),
Access::Counter(2_000_000),
Access::Enable,
]
);
}
#[test]
fn readers_and_irq_extend_one_epoch_across_rollover() {
let (shared, registers) = timer();
let reader_a = &shared;
let reader_b = &shared;
registers.lock().unwrap().cnt = u32::MAX - 5;
assert_eq!(reader_a.now().as_microseconds(), 4_294_967_290);
assert!(tick(&shared, &registers, 4));
assert_eq!(reader_b.now().as_microseconds(), 4_294_967_300);
assert_eq!(registers.lock().unwrap().compare, 2_704);
registers.lock().unwrap().cnt = 100;
assert_eq!(reader_a.now().as_microseconds(), 4_294_967_396);
}
#[test]
fn future_compare_can_cross_counter_wrap() {
let (shared, registers) = timer();
assert!(tick(&shared, &registers, u32::MAX - 5));
let registers = registers.lock().unwrap();
assert_eq!(registers.compare, 2_704);
assert!(!registers.pended);
}
#[test]
fn silent_reader_retains_time_across_multiple_irq_serviced_wraps() {
let (shared, registers) = timer();
let silent_reader = &shared;
assert_eq!(silent_reader.now().as_microseconds(), 0);
// Deliberately delayed service, still below the single-wrap observation
// bound. The silent consumer misses more than two whole counter wraps.
for second in 1..=10u64 {
assert!(tick(&shared, &registers, (second * 1_000_000_000) as u32));
}
assert_eq!(silent_reader.now().as_microseconds(), 10_000_000_000);
}
#[test]
fn on_time_and_two_second_delayed_irq_each_emit_one_event_and_future_compare() {
let (shared, registers) = timer();
for (cnt, expected) in [(10_000, 20_000), (2_010_000, 2_020_000)] {
registers.lock().unwrap().accesses.clear();
assert!(tick(&shared, &registers, cnt));
assert_eq!(shared.now().as_microseconds(), u64::from(cnt));
// This represents the consumer callback, after on_interrupt returns.
assert_eq!(registers.lock().unwrap().compare, expected);
assert!(!shared.on_interrupt());
assert_eq!(
registers
.lock()
.unwrap()
.accesses
.iter()
.filter(|access| matches!(access, Access::Compare(_)))
.count(),
1
);
}
}
#[test]
fn stale_nvic_pend_without_cc1if_neither_rearms_nor_notifies() {
let (shared, registers) = timer();
registers.lock().unwrap().flags = UIF;
assert!(!shared.on_interrupt());
let registers = registers.lock().unwrap();
assert!(registers.accesses.is_empty());
assert_eq!(registers.flags, UIF);
}
#[test]
fn target_passed_during_ccr_write_is_retried_before_callbacks() {
let (shared, registers) = timer();
registers
.lock()
.unwrap()
.write_delays
.push_back((10_001, false));
assert!(tick(&shared, &registers, 10_000));
assert_eq!(shared.now().as_microseconds(), 20_001);
let registers = registers.lock().unwrap();
assert_eq!(registers.compare, 30_000);
assert!(!registers.pended);
assert_eq!(
registers.accesses,
[
Access::Ack,
Access::Counter(10_000),
Access::Compare(20_000),
Access::Counter(20_001),
Access::Compare(30_000),
Access::Counter(20_001),
Access::Counter(20_001),
]
);
}
#[test]
fn repeated_missed_writes_pend_bounded_recovery_without_fabricating_an_event() {
let (shared, registers) = timer();
registers.lock().unwrap().write_delays = [(10_001, false), (10_000, false)].into();
assert!(tick(&shared, &registers, 10_000));
{
let mut registers = registers.lock().unwrap();
assert!(registers.pended);
assert_eq!(registers.compare, 30_000);
assert_eq!(registers.cnt, 30_001);
assert_eq!(registers.flags, 0);
assert_eq!(
registers
.accesses
.iter()
.filter(|a| matches!(a, Access::Compare(_)))
.count(),
2
);
registers.pended = false; // NVIC takes the recovery exception.
}
assert!(!shared.on_interrupt());
assert_eq!(registers.lock().unwrap().compare, 40_000);
assert!(!registers.lock().unwrap().pended);
assert!(tick(&shared, &registers, 40_000));
assert_eq!(registers.lock().unwrap().compare, 50_000);
}
#[test]
fn new_compare_flag_during_rearm_and_unrelated_flags_are_preserved() {
let (shared, registers) = timer();
registers.lock().unwrap().flags = UIF;
registers
.lock()
.unwrap()
.write_delays
.push_back((10_000, true));
assert!(tick(&shared, &registers, 10_000));
assert_eq!(registers.lock().unwrap().flags, UIF | CC1IF);
assert_eq!(registers.lock().unwrap().compare, 30_000);
assert!(shared.on_interrupt());
assert_eq!(registers.lock().unwrap().flags, UIF);
assert!(!shared.on_interrupt());
}
#[test]
fn irq_cannot_sample_between_a_readers_cnt_read_and_epoch_update() {
let registers = Arc::new(Mutex::new(Registers::default()));
let shared = SharedTimer::new();
let (sampled_tx, sampled_rx) = mpsc::channel();
let (resume_tx, resume_rx) = mpsc::channel();
assert!(shared.initialize(|| Counter {
registers: registers.clone(),
read_gate: Some((sampled_tx, resume_rx)),
}));
registers.lock().unwrap().cnt = 100;
thread::scope(|scope| {
let reader = scope.spawn(|| shared.now());
sampled_rx.recv().unwrap();
// Hardware continues ticking with PRIMASK set. The IRQ tries to run
// while the reader has sampled CNT but has not committed its delta.
registers.lock().unwrap().cnt = 150;
registers.lock().unwrap().flags = CC1IF;
let (attempt_tx, attempt_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let shared = &shared;
let irq = scope.spawn(move || {
attempt_tx.send(()).unwrap();
let event = shared.on_interrupt();
done_tx.send(()).unwrap();
event
});
attempt_rx.recv().unwrap();
let blocked = done_rx.recv_timeout(Duration::from_millis(20)).is_err();
// Release before assertions so a failing test cannot strand either thread.
resume_tx.send(()).unwrap();
assert_eq!(reader.join().unwrap().as_microseconds(), 100);
assert!(irq.join().unwrap());
assert!(blocked, "IRQ entered while the reader was extending CNT");
});
assert_eq!(shared.now().as_microseconds(), 150);
}
#[test]
fn full_wrap_blackout_reset_and_stopped_clock_are_not_detectable_from_cnt() {
let (shared, registers) = timer();
registers.lock().unwrap().cnt = 100;
assert_eq!(shared.now().as_microseconds(), 100);
// Stopped/deep-sleep time and a whole unseen wrap both present CNT=100.
// No software correction is possible. These modes require a system reset.
assert_eq!(shared.now().as_microseconds(), 100);
registers.lock().unwrap().cnt = ((1u64 << 32) + 100) as u32;
assert_eq!(shared.now().as_microseconds(), 100);
// An illicit reset/regression is indistinguishable from a forward wrap.
registers.lock().unwrap().cnt = 90;
assert_eq!(shared.now().as_microseconds(), 4_294_967_386);
}
@@ -0,0 +1,407 @@
//! Shared packet-level USB hardware fake; runs the real CDC/USB stack.
extern crate std;
use super::UsbMavlink;
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
vec::Vec,
};
use usb_device::{
UsbDirection, UsbError,
bus::{PollResult, UsbBus, UsbBusAllocator},
device::{UsbDeviceBuilder, UsbVidPid},
endpoint::{EndpointAddress, EndpointType},
};
use usbd_serial::CdcAcmClass;
const RXFLVL: u32 = 1 << 4;
const USBSUSP: u32 = 1 << 11;
const USBRST: u32 = 1 << 12;
const ENUMDNE: u32 = 1 << 13;
const IEPINT: u32 = 1 << 18;
const WKUPINT: u32 = 1 << 31;
struct RxPacket {
endpoint: usize,
setup: bool,
bytes: Vec<u8>,
}
#[derive(Default)]
struct BusState {
offline: bool,
fail_poll: bool,
read_error: Option<UsbError>,
write_error: Option<UsbError>,
events: VecDeque<PollResult>,
input: [Option<Vec<u8>>; 4],
output: [Option<Vec<u8>>; 4],
sizes: [[u16; 4]; 2],
stalled: [[bool; 4]; 2],
bulk_in: usize,
bulk_out: usize,
// Hardware FIFO is distinct from the driver's one-packet endpoint buffer
// (`output`). RXFLVL stays asserted if that buffer prevents popping the FIFO.
rx_fifo: VecDeque<RxPacket>,
setup: u16,
gintmsk: u32,
nvic_enabled: bool,
nvic_pending: bool,
endpoints_configured: bool,
}
impl BusState {
fn raw_status(&self) -> u32 {
self.events.iter().fold(
if self.rx_fifo.is_empty() { 0 } else { RXFLVL },
|status, event| {
status
| match event {
// synopsys-usb-otg returns Reset only for ENUMDNE.
PollResult::Reset => ENUMDNE,
PollResult::Suspend => USBSUSP,
PollResult::Resume => WKUPINT,
PollResult::Data { ep_in_complete, .. } if *ep_in_complete != 0 => IEPINT,
_ => 0,
}
},
)
}
fn latch_interrupt(&mut self) {
self.nvic_pending |= self.raw_status() & self.gintmsk != 0;
}
}
#[derive(Clone, Default)]
pub(crate) struct Host(Arc<Mutex<BusState>>);
pub(crate) struct PacketBus(Arc<Mutex<BusState>>);
impl super::UsbBusHealth for PacketBus {
fn is_offline(&self) -> bool {
self.0.lock().unwrap().offline
}
fn disable(&self) {
let mut state = self.0.lock().unwrap();
state.offline = true;
state.nvic_enabled = false;
state.nvic_pending = false;
}
}
impl UsbBus for PacketBus {
fn alloc_ep(
&mut self,
direction: UsbDirection,
address: Option<EndpointAddress>,
kind: EndpointType,
size: u16,
_: u8,
) -> Result<EndpointAddress, UsbError> {
let mut state = self.0.lock().unwrap();
let sizes = &mut state.sizes[usize::from(direction == UsbDirection::In)];
let index = match address {
Some(address) => address.index(),
None => (1..sizes.len())
.find(|index| sizes[*index] == 0)
.ok_or(UsbError::EndpointOverflow)?,
};
if index >= sizes.len() || sizes[index] != 0 {
return Err(UsbError::InvalidEndpoint);
}
sizes[index] = size;
if kind == EndpointType::Bulk {
match direction {
UsbDirection::In => state.bulk_in = index,
UsbDirection::Out => state.bulk_out = index,
}
}
Ok(EndpointAddress::from_parts(index, direction))
}
fn enable(&mut self) {
let mut state = self.0.lock().unwrap();
state.gintmsk = RXFLVL | USBSUSP | USBRST | ENUMDNE | IEPINT | WKUPINT;
state.nvic_enabled = true;
}
fn reset(&self) {
let mut state = self.0.lock().unwrap();
state.input.fill(None);
// configure_all flushes FIFOs but does not reset the bulk OUT software
// EndpointBuffer::has_data. Transport cleanup must discard that packet.
let bulk_out = state.bulk_out;
let buffered = state.output[bulk_out].take();
state.output.fill(None);
state.output[bulk_out] = buffered;
state.events.clear();
state.rx_fifo.clear();
state.setup = 0;
state.endpoints_configured = true;
}
fn set_device_address(&self, _: u8) {}
fn write(&self, address: EndpointAddress, bytes: &[u8]) -> Result<usize, UsbError> {
let mut state = self.0.lock().unwrap();
assert!(!state.offline, "write after terminal USB fault");
if let Some(error) = state.write_error.take() {
return Err(error);
}
if bytes.len() > usize::from(state.sizes[1][address.index()]) {
return Err(UsbError::BufferOverflow);
}
let packet = &mut state.input[address.index()];
if packet.is_some() {
return Err(UsbError::WouldBlock);
}
*packet = Some(bytes.to_vec());
Ok(bytes.len())
}
fn read(&self, address: EndpointAddress, bytes: &mut [u8]) -> Result<usize, UsbError> {
let mut state = self.0.lock().unwrap();
assert!(!state.offline, "read after terminal USB fault");
if let Some(error) = state.read_error.take() {
return Err(error);
}
let packet = state.output[address.index()]
.as_ref()
.ok_or(UsbError::WouldBlock)?;
if packet.len() > bytes.len() {
return Err(UsbError::BufferOverflow);
}
let packet = state.output[address.index()].take().unwrap();
state.setup &= !(1 << address.index());
bytes[..packet.len()].copy_from_slice(&packet);
Ok(packet.len())
}
fn set_stalled(&self, address: EndpointAddress, stalled: bool) {
self.0.lock().unwrap().stalled[usize::from(address.is_in())][address.index()] = stalled;
}
fn is_stalled(&self, address: EndpointAddress) -> bool {
self.0.lock().unwrap().stalled[usize::from(address.is_in())][address.index()]
}
fn suspend(&self) {}
fn resume(&self) {}
fn poll(&self) -> PollResult {
let mut state = self.0.lock().unwrap();
assert!(!state.offline, "poll after terminal USB fault");
if state.fail_poll {
state.offline = true;
state.nvic_enabled = false;
state.nvic_pending = false;
return PollResult::None;
}
// Like synopsys-usb-otg 0.4.0, polling reads raw status, not GINTMSK.
// Non-RX sources remain serviceable even with a blocked FIFO head.
let mut ep_in_complete = 0;
if let Some(event) = state.events.pop_front() {
match event {
PollResult::Data {
ep_in_complete: complete,
..
} => ep_in_complete = complete,
_ => return event,
}
}
if let Some(packet) = state.rx_fifo.front()
&& state.output[packet.endpoint].is_none()
{
let packet = state.rx_fifo.pop_front().unwrap();
if packet.setup {
state.setup |= 1 << packet.endpoint;
}
state.output[packet.endpoint] = Some(packet.bytes);
}
let ep_setup = state.setup;
let ep_out = state
.output
.iter()
.enumerate()
.fold(0, |mask, (index, packet)| {
mask | if packet.is_some() { 1 << index } else { 0 }
})
& !ep_setup;
// A still-asserted level can latch before the following gate write.
state.latch_interrupt();
if ep_out | ep_in_complete | ep_setup != 0 {
PollResult::Data {
ep_out,
ep_in_complete,
ep_setup,
}
} else {
PollResult::None
}
}
}
impl Host {
pub(crate) fn fail_poll(&self) {
self.0.lock().unwrap().fail_poll = true;
}
pub(crate) fn fail_read(&self) {
self.0.lock().unwrap().read_error = Some(UsbError::InvalidState);
}
pub(crate) fn fail_write(&self) {
self.0.lock().unwrap().write_error = Some(UsbError::InvalidState);
}
pub(crate) fn enumeration_done(&self) {
let mut state = self.0.lock().unwrap();
state.events.push_front(PollResult::Reset);
state.latch_interrupt();
}
pub(crate) fn event(&self, event: PollResult) {
let mut state = self.0.lock().unwrap();
state.events.push_back(event);
state.latch_interrupt();
}
#[allow(dead_code)]
pub(crate) fn events_empty(&self) -> bool {
self.0.lock().unwrap().events.is_empty()
}
fn control(&self, link: &mut UsbMavlink<'_, PacketBus>, request: [u8; 8]) {
self.queue_control(request);
link.poll();
assert_eq!(self.take_input(0), Some(Vec::new()));
link.poll();
}
pub(crate) fn queue_control(&self, request: [u8; 8]) {
let mut state = self.0.lock().unwrap();
// EP0 shares the hardware FIFO. Never inject SETUP past a blocked bulk head.
state.rx_fifo.push_back(RxPacket {
endpoint: 0,
setup: true,
bytes: request.to_vec(),
});
state.latch_interrupt();
}
pub(crate) fn configure(&self, link: &mut UsbMavlink<'_, PacketBus>, configured: bool) {
self.control(link, [0, 9, u8::from(configured), 0, 0, 0, 0, 0]);
}
pub(crate) fn dtr(&self, link: &mut UsbMavlink<'_, PacketBus>, open: bool) {
self.control(link, [0x21, 0x22, u8::from(open), 0, 0, 0, 0, 0]);
}
pub(crate) fn write_packet(&self, bytes: &[u8]) {
assert!(
self.try_write_packet(bytes),
"host must retry when the RX FIFO is full"
);
}
pub(crate) fn try_write_packet(&self, bytes: &[u8]) -> bool {
assert!(bytes.len() <= 64);
let mut state = self.0.lock().unwrap();
// Driver allocation: two 16-word OUT buffers + 30 words. Model packet
// data and one receive status word, not cycle-level USB handshakes.
let occupied: usize = state
.rx_fifo
.iter()
.map(|packet| packet.bytes.len().div_ceil(4) + 1)
.sum();
if occupied + bytes.len().div_ceil(4) + 1 > 62 {
return false;
}
let endpoint = state.bulk_out;
state.rx_fifo.push_back(RxPacket {
endpoint,
setup: false,
bytes: bytes.to_vec(),
});
state.latch_interrupt();
true
}
fn take_input(&self, endpoint: usize) -> Option<Vec<u8>> {
let mut state = self.0.lock().unwrap();
let packet = state.input[endpoint].take()?;
state.events.push_back(PollResult::Data {
ep_out: 0,
ep_in_complete: 1 << endpoint,
ep_setup: 0,
});
state.latch_interrupt();
Some(packet)
}
pub(crate) fn set_rx_interrupt_enabled(&self, enabled: bool) {
let mut state = self.0.lock().unwrap();
state.gintmsk = if enabled {
state.gintmsk | RXFLVL
} else {
state.gintmsk & !RXFLVL
};
// Masking never acknowledges pending status. Rearm sees the current level.
state.latch_interrupt();
}
#[allow(dead_code)] // Also shared with transport-only tests without a scheduler.
pub(crate) fn rx_interrupt_enabled(&self) -> bool {
self.0.lock().unwrap().gintmsk & RXFLVL != 0
}
#[allow(dead_code)]
pub(crate) fn nvic_enabled(&self) -> bool {
self.0.lock().unwrap().nvic_enabled
}
#[allow(dead_code)]
pub(crate) fn interrupt_status(&self) -> (u32, u32) {
let state = self.0.lock().unwrap();
(state.raw_status(), state.gintmsk)
}
#[allow(dead_code)]
pub(crate) fn enter_irq(&self) -> bool {
let mut state = self.0.lock().unwrap();
state.latch_interrupt();
if !state.nvic_enabled || !state.nvic_pending {
return false;
}
state.nvic_pending = false; // Exception entry, not an enable/disable side effect.
true
}
#[allow(dead_code)]
pub(crate) fn receive_backlog(&self) -> (usize, bool) {
let state = self.0.lock().unwrap();
(state.rx_fifo.len(), state.output[state.bulk_out].is_some())
}
/// Host ACK without polling device software. The device must service TX completion.
pub(crate) fn take_packet(&self) -> Option<Vec<u8>> {
let state = self.0.lock().unwrap();
if !state.endpoints_configured {
return None;
}
let endpoint = state.bulk_in;
drop(state);
self.take_input(endpoint)
}
pub(crate) fn collect_packets(&self, link: &mut UsbMavlink<'_, PacketBus>) -> Vec<Vec<u8>> {
let mut packets = Vec::new();
for _ in 0..16 {
let Some(packet) = self.take_packet() else {
return packets;
};
packets.push(packet);
link.poll();
}
panic!("transmission did not finish");
}
}
pub(crate) fn with_link(test: impl FnOnce(&Host, &mut UsbMavlink<'_, PacketBus>)) {
let host = Host::default();
let allocator = UsbBusAllocator::new(PacketBus(host.0.clone()));
let class = CdcAcmClass::new(&allocator, 64);
let device = UsbDeviceBuilder::new(&allocator, UsbVidPid(0x1209, 1))
.max_packet_size_0(64)
.unwrap()
.build();
let mut link = UsbMavlink::new(device, class);
host.enumeration_done();
link.poll();
host.configure(&mut link, true);
host.dtr(&mut link, true);
link.with_rx_interrupt_gate(
|link| assert!(link.is_open()),
|enabled| host.set_rx_interrupt_enabled(enabled),
);
// Finish initialization's latched exceptions before scheduling test traffic.
while host.enter_irq() {
link.with_rx_interrupt_gate(
|link| link.poll(),
|enabled| host.set_rx_interrupt_enabled(enabled),
);
}
test(&host, &mut link);
}
@@ -0,0 +1,391 @@
//! Level-triggered interrupt scheduling around the real USB transport operations.
extern crate std;
use super::*;
use crate::f722::usb;
use kaptan_mavlink::{encoder::MavlinkEncoder, generated::kaptan::FILE_DATA_DATA};
use kaptan_tx::FrameClass;
use std::{vec, vec::Vec};
use usb::test_bus as usb_bus;
use usb_bus::{Host, PacketBus, with_link};
use usb_device::bus::PollResult;
fn poll_irq(host: &Host, usb: &mut usb::UsbMavlink<'_, PacketBus>) -> bool {
usb.with_rx_interrupt_gate(
|usb| usb.poll(),
|enabled| host.set_rx_interrupt_enabled(enabled),
)
}
fn exchange(
host: &Host,
usb: &mut usb::UsbMavlink<'_, PacketBus>,
frame: Option<MavlinkFrame>,
) -> IoBatch {
exchange_usb(
usb,
frame.map(|frame| PendingFrame {
class: FrameClass::Critical,
frame,
}),
|enabled| host.set_rx_interrupt_enabled(enabled),
)
}
fn file_frame(length: usize) -> MavlinkFrame {
let mut data = FILE_DATA_DATA::DEFAULT;
data.data[..length - 21].fill(0x5a);
let frame = MavlinkEncoder::new(1, 1).encode(&data);
assert_eq!(frame.raw_bytes().len(), length);
frame
}
#[test]
fn register_rmw_changes_only_rxflvlm_and_preserves_adjacent_registers() {
for bit in 0..32 {
for initial in [1u32 << bit, !(1u32 << bit)] {
let mut registers = [0xa5a5_5a5a, initial, 0x5a5a_a5a5];
for enabled in [false, false, true, true, false] {
// SAFETY: this aligned RAM word substitutes for the RW register.
// No concurrent access exists and RAM needs no device barrier.
unsafe { set_rx_fifo_interrupt_enabled(&mut registers[1], enabled) };
assert_eq!(registers[1] & !(1 << 4), initial & !(1 << 4));
assert_eq!(registers[1] & (1 << 4) != 0, enabled);
assert_eq!(registers[0], 0xa5a5_5a5a);
assert_eq!(registers[2], 0x5a5a_a5a5);
}
}
}
}
// The asserted, enabled priority-8 vector wins over the priority-7 consumer.
fn step(
host: &Host,
usb: &mut usb::UsbMavlink<'_, PacketBus>,
ready: &mut bool,
frames: &mut Vec<MavlinkFrame>,
) -> bool {
if host.enter_irq() {
*ready |= poll_irq(host, usb);
false
} else if core::mem::take(ready) {
let batch = exchange(host, usb, None);
*ready |= batch.pending;
if let Some(frame) = batch.usb {
frames.push(*frame.raw());
}
true
} else {
false
}
}
#[test]
fn level_triggered_burst_starves_legacy_consumer_but_gated_consumer_recovers_exact_frames() {
for gate in [false, true] {
with_link(|host, usb| {
let mut encoder = MavlinkEncoder::new(42, 7);
let expected = core::array::from_fn::<_, 3, _>(|_| encoder.heartbeat());
for frame in expected {
host.write_packet(frame.raw_bytes());
}
let mut ready = false;
let mut frames = Vec::new();
let mut workers = 0;
for _ in 0..32 {
if gate {
workers += usize::from(step(host, usb, &mut ready, &mut frames));
} else if host.enter_irq() {
ready |= usb.poll(); // Original handler: no RX source gate.
} else if core::mem::take(&mut ready) {
workers += 1;
let batch = exchange(host, usb, None);
ready |= batch.pending;
if let Some(frame) = batch.usb {
frames.push(*frame.raw());
}
}
}
if gate {
assert_eq!(frames, expected);
assert!(workers > 0);
assert_eq!(host.receive_backlog(), (0, false));
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
} else {
assert_eq!(
workers, 0,
"level IRQ prevents the only consumer from running"
);
assert!(frames.is_empty());
assert_eq!(
host.receive_backlog(),
(1, true),
"hardware FIFO and driver buffer are both occupied"
);
assert!(
host.enter_irq(),
"source is still asserted after repeated polls"
);
}
});
}
}
#[test]
fn split_coalesced_bad_crc_and_zlp_packets_preserve_rx_and_live_tx_boundaries() {
with_link(|host, usb| {
let incoming = file_frame(130);
let mut encoder = MavlinkEncoder::new(42, 7);
let first = encoder.heartbeat();
let mut corrupt = encoder.heartbeat().raw_bytes().to_vec();
*corrupt.last_mut().unwrap() ^= 1;
let last = encoder.heartbeat();
host.write_packet(&incoming.raw_bytes()[..64]);
host.write_packet(&[]); // An OUT ZLP must not mask a partial MAVLink frame.
host.write_packet(&incoming.raw_bytes()[64..128]);
host.write_packet(
&[
&incoming.raw_bytes()[128..],
first.raw_bytes(),
&corrupt,
last.raw_bytes(),
]
.concat(),
);
let outgoing = file_frame(128);
let after = encoder.heartbeat();
assert!(exchange(host, usb, Some(outgoing)).accepted);
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
assert!(!host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
let mut packets = vec![host.take_packet().unwrap()];
assert!(
host.enter_irq(),
"IN completion interrupts while RX is masked"
);
assert!(poll_irq(host, usb));
packets.push(host.take_packet().unwrap());
assert_eq!(packets.iter().map(Vec::len).collect::<Vec<_>>(), [64, 64]);
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
packets.push(host.take_packet().unwrap());
assert_eq!(packets[2], [], "TX ZLP progresses without the RX consumer");
let sent = exchange(host, usb, Some(after));
assert!(sent.accepted);
assert!(
!sent.pending,
"TX admission's IoBatch flag is not the IRQ policy"
);
assert!(
!host.rx_interrupt_enabled(),
"TX must preserve the actual unread RX gate"
);
if let Some(packet) = host.take_packet() {
packets.push(packet);
}
let partial = exchange(host, usb, None);
assert!(partial.usb.is_none());
assert!(!partial.pending);
assert!(
host.rx_interrupt_enabled(),
"the partial framer is waiting for more bytes, not the worker"
);
let mut ready = false;
let mut frames = Vec::new();
for _ in 0..64 {
step(host, usb, &mut ready, &mut frames);
if let Some(packet) = host.take_packet() {
packets.push(packet);
}
}
assert_eq!(frames, [incoming, first, last]);
assert_eq!(
packets.iter().map(Vec::len).collect::<Vec<_>>(),
[64, 64, 0, 13]
);
assert_eq!(
packets.concat(),
[outgoing.raw_bytes(), after.raw_bytes()].concat()
);
assert!(host.nvic_enabled());
});
}
#[test]
fn final_poll_refill_stays_masked_and_rearm_preserves_already_pending_irq() {
with_link(|host, usb| {
let frame = MavlinkEncoder::new(42, 7).heartbeat();
host.write_packet(&frame.raw_bytes()[..8]);
assert!(host.enter_irq());
usb.with_rx_interrupt_gate(
|usb| {
assert!(usb.poll());
// Latch NVIC pending just before masking the peripheral source.
host.write_packet(&frame.raw_bytes()[8..]);
},
|enabled| host.set_rx_interrupt_enabled(enabled),
);
let partial = exchange(host, usb, None);
assert!(partial.usb.is_none());
assert!(partial.pending, "the final poll refilled rx_remaining");
assert!(!host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
let complete = exchange(host, usb, None);
assert_eq!(complete.usb.unwrap().raw(), &frame);
assert!(!complete.pending);
assert_eq!(host.receive_backlog(), (0, false));
assert!(
host.enter_irq(),
"RX rearm preserves latched NVIC pending even after polling consumed the source"
);
assert!(!poll_irq(host, usb));
});
}
#[test]
fn arrivals_on_either_side_of_rearm_are_not_lost() {
for before in [true, false] {
with_link(|host, usb| {
let mut encoder = MavlinkEncoder::new(42, 7);
let first = encoder.heartbeat();
let next = encoder.heartbeat();
host.write_packet(first.raw_bytes());
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
let batch = exchange_usb(usb, None, |enabled| {
assert!(enabled);
if before {
host.write_packet(next.raw_bytes());
}
host.set_rx_interrupt_enabled(enabled);
if !before {
host.write_packet(next.raw_bytes());
}
});
assert_eq!(batch.usb.unwrap().raw(), &first);
assert!(!batch.pending);
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
assert_eq!(exchange(host, usb, None).usb.unwrap().raw(), &next);
});
}
}
#[test]
fn backpressure_keeps_reset_suspend_and_resume_interrupts_serviceable() {
for reset in [true, false] {
with_link(|host, usb| {
let frame = file_frame(130);
assert!(exchange(host, usb, Some(frame)).accepted);
for _ in 0..3 {
host.write_packet(&frame.raw_bytes()[..8]);
}
assert!(host.enter_irq());
assert!(poll_irq(host, usb));
assert!(host.enter_irq(), "one already-pending exception is allowed");
assert!(poll_irq(host, usb));
assert_eq!(host.receive_backlog(), (1, true));
assert!(!host.enter_irq());
let (_, original_mask) = host.interrupt_status();
host.event(if reset {
PollResult::Reset
} else {
PollResult::Suspend
});
assert!(host.enter_irq());
poll_irq(host, usb);
assert!(!usb.is_open());
assert!(host.events_empty());
assert!(host.nvic_enabled());
assert_eq!(
host.interrupt_status().1 & !(1 << 4),
original_mask & !(1 << 4)
);
if reset {
assert!(host.rx_interrupt_enabled());
assert_eq!(host.receive_backlog(), (0, false));
host.configure(usb, true);
host.dtr(usb, true);
assert!(
host.collect_packets(usb).is_empty(),
"reset discarded the old TX queue"
);
} else {
assert!(!host.rx_interrupt_enabled(), "suspend retains unread RX");
assert_eq!(host.receive_backlog(), (1, true));
host.event(PollResult::Resume);
assert!(host.enter_irq());
poll_irq(host, usb);
assert!(usb.is_open());
assert_eq!(host.collect_packets(usb).concat(), frame.raw_bytes());
}
});
}
}
#[test]
fn ep0_setup_cannot_bypass_a_blocked_bulk_fifo_head() {
with_link(|host, usb| {
let frame = MavlinkEncoder::new(42, 7).heartbeat();
assert!(exchange(host, usb, Some(frame)).accepted);
for _ in 0..3 {
host.write_packet(frame.raw_bytes());
}
assert!(host.enter_irq());
poll_irq(host, usb);
assert!(host.enter_irq());
poll_irq(host, usb);
assert_eq!(host.receive_backlog(), (1, true));
assert!(!host.enter_irq());
host.queue_control([0x21, 0x22, 0, 0, 0, 0, 0, 0]); // DTR close behind bulk
assert_eq!(host.receive_backlog(), (2, true));
assert!(
!host.enter_irq(),
"EP0 arrival also uses the masked RX source"
);
let (raw, mask) = host.interrupt_status();
assert_ne!(raw & (1 << 4), 0);
assert_eq!(raw & mask, 0);
host.take_packet().unwrap(); // IN completion can interrupt and poll raw RX.
assert!(host.enter_irq());
poll_irq(host, usb);
assert_eq!(host.receive_backlog(), (2, true));
assert!(
usb.is_open(),
"SETUP cannot jump over the blocked FIFO head"
);
assert_eq!(exchange(host, usb, None).usb.unwrap().raw(), &frame);
exchange(host, usb, None);
assert!(!usb.is_open(), "consumer progress finally allows DTR close");
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
});
}
#[test]
fn full_fifo_backpressures_host_then_consumer_progress_allows_retry() {
with_link(|host, usb| {
let frame = file_frame(64);
for _ in 0..3 {
assert!(host.try_write_packet(frame.raw_bytes()));
}
assert!(!host.try_write_packet(frame.raw_bytes()));
let mut ready = false;
let mut frames = Vec::new();
for _ in 0..32 {
step(host, usb, &mut ready, &mut frames);
}
assert_eq!(frames, [frame; 3]);
assert!(host.try_write_packet(frame.raw_bytes()));
for _ in 0..16 {
step(host, usb, &mut ready, &mut frames);
}
assert_eq!(frames, [frame; 4]);
assert_eq!(host.receive_backlog(), (0, false));
assert!(host.rx_interrupt_enabled());
assert!(host.nvic_enabled());
});
}