feat(firmware): validate configuration and migrate sensor-driven flight APIs
Validate configuration through its owning schemas and keep board declarations separate from shared F722 resource ownership. Bound USB transitions, isolate transport failures and retain restart-only activation. Migrate navigation and flight consumers with the sensor and schema contracts. Require explicit installation data and synchronous output authority; board motor outputs remain inhibited. Validation: assembled-prefix host tests, strict Clippy, platform/USB tests and both ARM release builds. Final-source coverage and scoped Miri/Kani evidence are retained separately from hardware qualification.
This commit is contained in:
1 parent
fb1b8af2d1
commit
f9a61a086f
211 files changed
+45657
-12740
No files matched your search
@@ -1,415 +1,132 @@
|
||||
//! Source checks only for hardware boundaries that host execution cannot probe.
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use platform_stm32f7::{
|
||||
AccelerometerRange, DigitalLowPassFilter, F722Hardware, F722ImmutableConfig, GyroscopeRange,
|
||||
ImuMount, Mavlink, Mpu6000, Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, VerticalSensor,
|
||||
};
|
||||
|
||||
fn platform_source(relative: &str) -> String {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
fs::read_to_string(root.join(relative)).expect("platform source must exist")
|
||||
fn source(relative: &str) -> String {
|
||||
fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn former_motor_pins_are_inhibited_before_all_other_initialization() {
|
||||
let module = platform_source("src/f722/mod.rs");
|
||||
let interlock = module
|
||||
.find("motor_inhibit::inhibit_pb0_pb1_pb4_pb5")
|
||||
.expect("F722 initialization must claim the former motor pins first");
|
||||
let validation = module
|
||||
.find("validate_board_config")
|
||||
.expect("F722 initialization must validate its board configuration");
|
||||
let board_initialization = module
|
||||
.find("board::initialize")
|
||||
.expect("F722 initialization must subsequently claim telemetry I/O");
|
||||
|
||||
assert!(interlock < validation);
|
||||
assert!(validation < board_initialization);
|
||||
|
||||
let interlock = platform_source("src/f722/motor_inhibit.rs");
|
||||
let reset = interlock
|
||||
.find("port.bsrr()")
|
||||
.expect("interlock must reset the former motor-pin output latches");
|
||||
let clock_delay = interlock
|
||||
.find("rcc.ahb1enr().read()")
|
||||
.expect("GPIO access must follow the documented RCC synchronization delay");
|
||||
let output_mode = interlock
|
||||
.find("port.moder()")
|
||||
.expect("interlock must configure the former motor pins as GPIO outputs");
|
||||
fn motor_pins_are_inhibited_before_initialization() {
|
||||
let module = source("src/f722/mod.rs");
|
||||
let interlock = module.find("motor_inhibit::inhibit(").unwrap();
|
||||
let validation = module.find("board::validate_board_config").unwrap();
|
||||
let initialize = module.find("board::initialize").unwrap();
|
||||
assert!(interlock < validation && validation < initialize);
|
||||
let interlock = source("src/f722/motor_inhibit.rs");
|
||||
assert!(
|
||||
clock_delay < reset && reset < output_mode,
|
||||
"output latches must be low before GPIO output mode is selected"
|
||||
interlock.find("rcc.ahb1enr().read()").unwrap() < interlock.find("port.bsrr()").unwrap()
|
||||
);
|
||||
for pin in ["br0", "br1", "br4", "br5"] {
|
||||
assert!(interlock.contains(pin), "interlock must reset PB pin {pin}");
|
||||
}
|
||||
for pin in ["moder0", "moder1", "moder4", "moder5"] {
|
||||
assert!(
|
||||
interlock.contains(pin),
|
||||
"interlock must configure PB pin {pin}"
|
||||
);
|
||||
}
|
||||
for pin in ["ot0", "ot1", "ot4", "ot5"] {
|
||||
assert!(
|
||||
interlock.contains(pin),
|
||||
"interlock must set PB pin {pin} push-pull"
|
||||
);
|
||||
}
|
||||
for pin in ["ospeedr0", "ospeedr1", "ospeedr4", "ospeedr5"] {
|
||||
assert!(
|
||||
interlock.contains(pin),
|
||||
"interlock must set PB pin {pin} low speed"
|
||||
);
|
||||
}
|
||||
assert!(interlock.find("port.bsrr()").unwrap() < interlock.find("port.moder()").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immutable_board_policy_is_one_complete_literal_manifest() {
|
||||
const IMMUTABLE: F722ImmutableConfig = F722ImmutableConfig {
|
||||
name: "test",
|
||||
service_hz: 100,
|
||||
hardware: F722Hardware::new()
|
||||
.output_inhibit(Pb0Pb1Pb4Pb5Low)
|
||||
.imu(
|
||||
Mpu6000::on_spi1()
|
||||
.with_drdy(Pa4)
|
||||
.mounted(ImuMount::BodyAligned)
|
||||
.configured(Mpu6000Config {
|
||||
accelerometer: AccelerometerRange::PlusMinus4G,
|
||||
gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond,
|
||||
digital_low_pass_filter: DigitalLowPassFilter::Bandwidth42Hz,
|
||||
sample_rate_divider: 9,
|
||||
}),
|
||||
)
|
||||
.mavlink(Mavlink::on_usart1().baud(115_200))
|
||||
.vertical_sensor(VerticalSensor::mtf01_on_uart4().baud(115_200))
|
||||
.status_led(Pd2ActiveLow)
|
||||
.telemetry_only(),
|
||||
};
|
||||
|
||||
assert_eq!(IMMUTABLE.name, "test");
|
||||
assert_eq!(IMMUTABLE.service_hz, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn telemetry_platform_has_no_physical_actuator_peripheral_path() {
|
||||
fn board_images_have_no_actuator_route() {
|
||||
let sources = [
|
||||
platform_source("src/lib.rs"),
|
||||
platform_source("src/f722/mod.rs"),
|
||||
platform_source("src/f722/board.rs"),
|
||||
platform_source("src/f722/firmware.rs"),
|
||||
platform_source("src/f722/interrupts.rs"),
|
||||
"src/lib.rs",
|
||||
"src/f722/mod.rs",
|
||||
"src/f722/board.rs",
|
||||
"src/f722/firmware.rs",
|
||||
"src/f722/interrupts.rs",
|
||||
]
|
||||
.map(source)
|
||||
.join("\n");
|
||||
|
||||
for forbidden in [
|
||||
"mod actuator",
|
||||
"MotorOutput",
|
||||
"MotorRoute",
|
||||
"MotorOutputs",
|
||||
"TIM3",
|
||||
"TIM5",
|
||||
"Dshot",
|
||||
"Pwm",
|
||||
"device.TIM3",
|
||||
"device.TIM5",
|
||||
] {
|
||||
assert!(
|
||||
!sources.contains(forbidden),
|
||||
"telemetry platform must not expose actuator path {forbidden}"
|
||||
);
|
||||
assert!(!sources.contains(forbidden), "{forbidden}");
|
||||
}
|
||||
|
||||
let manifest = platform_source("Cargo.toml");
|
||||
assert!(
|
||||
!manifest.contains("kaptan-actuator"),
|
||||
"telemetry platform must not link the physical actuator crate"
|
||||
);
|
||||
assert!(!source("Cargo.toml").contains("kaptan-actuator"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_f722_image_has_direct_configuration_after_output_inhibition() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
let firmware = platform_source("src/f722/firmware.rs");
|
||||
let routes = platform_source("src/f722/routes.rs");
|
||||
let manifest = platform_source("Cargo.toml");
|
||||
|
||||
assert!(
|
||||
!manifest.contains("development-config")
|
||||
&& !manifest.contains("development-authority")
|
||||
&& !manifest.contains("development-unsigned"),
|
||||
"ordinary board images must not hide configuration behind a development feature"
|
||||
);
|
||||
for required in [
|
||||
"config_file: F722ConfigFile",
|
||||
"file_service: FileService",
|
||||
"uart_receiver: IntegrityMavlinkReceiver<1>",
|
||||
"file_lease_ticks: u16",
|
||||
"TRANSFER_REPLAY_LEASE_SECONDS",
|
||||
"let Some(message) = frame.decode()",
|
||||
"wire::decode_request",
|
||||
".dispatch(&mut self.config_file",
|
||||
"send_file_response",
|
||||
"let class = kaptan_mavlink::transmitter::FrameClass::Critical",
|
||||
"control.status != FileStatus::RequestConflict",
|
||||
fn uart_runtime_has_no_dma_or_static_register_aliases() {
|
||||
for path in [
|
||||
"src/f722/serial.rs",
|
||||
"src/f722/peripherals/usart.rs",
|
||||
"src/f722/data_channel.rs",
|
||||
] {
|
||||
assert!(
|
||||
board.contains(required),
|
||||
"ordinary image must retain configuration composition: {required}"
|
||||
);
|
||||
}
|
||||
assert!(!board.contains("DirectConfigAdmission"));
|
||||
assert!(
|
||||
routes.contains("pub(crate) struct Mpu6000Spi1Route {\n spi: Spi1,\n}")
|
||||
&& routes.contains("stm32f722::GPIOB::ptr()")
|
||||
&& routes.contains("write.br2().set_bit()")
|
||||
&& routes.contains("write.bs2().set_bit()"),
|
||||
"the MPU runtime may drive only PB2 atomically, not retain GPIOB ownership"
|
||||
);
|
||||
assert!(!board.contains("development-config"));
|
||||
assert!(
|
||||
!firmware.contains("shared = [mavlink, motors]"),
|
||||
"the MAVLink worker must not share a motor resource"
|
||||
);
|
||||
assert!(
|
||||
firmware.contains("const MAVLINK_EVENT_RECEIVED")
|
||||
&& firmware.contains("mavlink.receive_mavlink_interrupt(event)")
|
||||
&& !firmware.contains("development-config"),
|
||||
"ordinary images must always dispatch received MAVLink frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_busy_deadline_resets_entirely_from_sram() {
|
||||
let config = platform_source("src/f722/config.rs");
|
||||
assert!(config.contains("link_section = \".data.kaptan_flash_operation\""));
|
||||
assert!(config.contains("fn execute(&self, operation: F722FlashOperation)"));
|
||||
assert!(config.contains("\"mrs {primask}, PRIMASK\""));
|
||||
assert!(config.contains("\"cpsid i\""));
|
||||
assert!(config.contains("\"cpsie i\""));
|
||||
assert!(config.contains("fn reset_after_flash_timeout() -> !"));
|
||||
assert!(config.contains("SYSRESETREQ"));
|
||||
assert!(config.contains("core::arch::asm!(\"dsb sy\""));
|
||||
assert!(!config.contains("BusyTimeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usart_tx_dma_arms_each_stream_before_its_request() {
|
||||
let usart = platform_source("src/f722/peripherals/usart.rs");
|
||||
assert!(usart.contains("disable_dma_transmit_from_interrupt"));
|
||||
|
||||
for (name, path) in [
|
||||
("USART1", "src/f722/peripherals/usart1_tx_dma2_stream7.rs"),
|
||||
("USART2", "src/f722/peripherals/usart2_tx_dma1_stream6.rs"),
|
||||
] {
|
||||
let route = platform_source(path);
|
||||
let constructor_start = route.find("pub fn new").expect("DMA constructor");
|
||||
let transfer_start = route.find("pub fn start").expect("DMA start operation");
|
||||
let acknowledge_start = route
|
||||
.find("pub fn acknowledge_interrupt")
|
||||
.expect("DMA interrupt acknowledgement");
|
||||
|
||||
assert!(
|
||||
!route[constructor_start..transfer_start].contains("enable_dma_transmit"),
|
||||
"{name} must not leave its DMA request armed between transfers"
|
||||
);
|
||||
|
||||
assert!(
|
||||
route.contains("write.dmdis().enabled().feie().disabled()"),
|
||||
"{name} must explicitly select direct mode without FE interrupts"
|
||||
);
|
||||
let start = &route[transfer_start..acknowledge_start];
|
||||
let stream_enable = start
|
||||
.find("write.en().enabled()")
|
||||
.expect("DMA stream must be enabled");
|
||||
let request_enable = start
|
||||
.find("self.usart.enable_dma_transmit()")
|
||||
.expect("USART DMA request must be enabled");
|
||||
assert!(
|
||||
stream_enable < request_enable,
|
||||
"{name} must enable its stream before the peripheral request"
|
||||
);
|
||||
|
||||
let acknowledge = &route[acknowledge_start..];
|
||||
let stream_disable = acknowledge
|
||||
.find("write.en().disabled()")
|
||||
.expect("DMA stream must be disabled");
|
||||
let request_disable = acknowledge
|
||||
.find("disable_dma_transmit_from_interrupt()")
|
||||
.expect("USART DMA request must be disabled");
|
||||
assert!(
|
||||
stream_disable < request_disable,
|
||||
"{name} must disable its stream before the peripheral request"
|
||||
);
|
||||
|
||||
let classifier = acknowledge
|
||||
.find("impl DmaTerminalStatus")
|
||||
.map(|index| &acknowledge[index..])
|
||||
.expect("DMA terminal classifier");
|
||||
assert!(classifier.contains("self.transfer_error || self.direct_mode_error"));
|
||||
assert!(classifier.contains("TxCompletion::Complete"));
|
||||
assert!(
|
||||
!classifier.contains("fifo_error"),
|
||||
"{name} direct-mode FEIF must not manufacture a terminal fault"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_schema_is_generated_without_board_forwarding_code() {
|
||||
let platform = platform_source("src/lib.rs");
|
||||
let platform_config = platform_source("src/f722/config.rs");
|
||||
let platform_firmware = platform_source("src/f722/firmware.rs");
|
||||
let kakute = platform_source("../../boards/kakute-f7-mini-v3/src/board.rs");
|
||||
let geprc = platform_source("../../boards/geprc-f722-v2/src/board.rs");
|
||||
|
||||
assert!(
|
||||
platform.contains("pub struct F722ImmutableConfig")
|
||||
&& platform.contains("pub hardware: F722Hardware")
|
||||
&& platform.contains("type UserConfig: Clone + Config")
|
||||
&& platform_config.contains("struct UserConfigModel")
|
||||
&& platform_config.contains("fn project_runtime<C, I>")
|
||||
&& platform_config.contains("candidate.field_value(field)"),
|
||||
"the platform must retain exactly one immutable manifest and one generic user adapter"
|
||||
);
|
||||
for semantic_id in [
|
||||
"MAVLINK_SYSTEM_ID",
|
||||
"MAVLINK_COMPONENT_ID",
|
||||
"IMU_HZ",
|
||||
"HEARTBEAT_HZ",
|
||||
"MAVLINK_LOG_LEVEL_FILTER",
|
||||
"RANGE_HZ",
|
||||
"OPTICAL_FLOW_HZ",
|
||||
] {
|
||||
assert!(
|
||||
platform_config.contains(&format!("const {semantic_id}: FieldId")),
|
||||
"missing stable platform-consumed semantic ID {semantic_id}"
|
||||
);
|
||||
}
|
||||
for (name, board) in [("Kakute", kakute), ("GEPRC", geprc)] {
|
||||
assert!(
|
||||
board.contains("type UserConfig = UserConfig;")
|
||||
&& board.contains("type ImmutableConfig = ImmutableConfig;")
|
||||
&& board.contains("const FACTORY_USER_CONFIG: UserConfig = UserConfig {")
|
||||
&& board.contains("const IMMUTABLE_CONFIG: ImmutableConfig = ImmutableConfig {")
|
||||
&& board.contains("hardware: F722Hardware::new()")
|
||||
&& board.contains(".telemetry_only()"),
|
||||
"{name} must visibly declare the two literal roots"
|
||||
);
|
||||
for redundant in [
|
||||
"impl F7ImmutableConfig for",
|
||||
"impl F7UserConfig for",
|
||||
"impl_f722_user_config!",
|
||||
let code = source(path);
|
||||
for forbidden in [
|
||||
"DMA1::ptr",
|
||||
"DMA2::ptr",
|
||||
"USART1::ptr",
|
||||
"USART2::ptr",
|
||||
"USART3::ptr",
|
||||
"UART4::ptr",
|
||||
"USART6::ptr",
|
||||
"DmaReadableBuffer",
|
||||
"MavlinkTransmitter",
|
||||
"dmat()",
|
||||
] {
|
||||
assert!(!board.contains(redundant), "{name} retains {redundant}");
|
||||
assert!(!code.contains(forbidden), "{path}: {forbidden}");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
platform_firmware.contains("<$board as $crate::F7Board>::UserConfig")
|
||||
&& platform_firmware.contains("<$board as $crate::F7Board>::ImmutableConfig")
|
||||
&& platform_firmware.contains("config_snapshot_length::<"),
|
||||
"the common RTIC graph must specialize storage for the two board roots"
|
||||
);
|
||||
let firmware = source("src/f722/firmware.rs");
|
||||
assert!(!firmware.contains("DMA2_STREAM7"));
|
||||
assert!(!firmware.contains("DMA1_STREAM6"));
|
||||
assert_eq!(firmware.matches("imu_worker::spawn()").count(), 1);
|
||||
assert_eq!(firmware.matches("mavlink_worker::spawn()").count(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn source_keeps_atomic_chip_select_without_gpio_owner() {
|
||||
let routes = source("src/f722/routes.rs");
|
||||
let compact: String = routes.split_whitespace().collect();
|
||||
assert!(compact.contains("pub(crate)structMpu6000Spi1Route{spi:Spi1,}"));
|
||||
for bit in ["br2", "bs2", "br15", "bs15"] {
|
||||
assert!(routes.contains(&format!("write.{bit}().set_bit()")));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn source_keeps_flash_irq_mask_and_reset() {
|
||||
let config = source("src/f722/config.rs");
|
||||
for required in [
|
||||
"link_section = \".data.kaptan_flash_operation\"",
|
||||
"\"mrs {primask}, PRIMASK\"",
|
||||
"\"cpsid i\"",
|
||||
"\"cpsie i\"",
|
||||
"fn reset_after_flash_timeout() -> !",
|
||||
"SYSRESETREQ",
|
||||
"core::arch::asm!(\"dsb sy\"",
|
||||
] {
|
||||
assert!(config.contains(required), "{required}");
|
||||
}
|
||||
assert!(!config.contains("BusyTimeout"));
|
||||
}
|
||||
#[test]
|
||||
fn normal_images_keep_usb_configuration_and_shared_file_service() {
|
||||
let manifest = source("Cargo.toml");
|
||||
assert!(!manifest.contains("development-config"));
|
||||
let board = source("src/f722/board.rs");
|
||||
assert_eq!(board.matches("FileService::new").count(), 1);
|
||||
assert!(board.contains("usb_mavlink("));
|
||||
assert!(board.contains("IngressId(id)"));
|
||||
assert!(board.contains("result.refresh_lease"));
|
||||
assert!(!board.contains("file_lease_ticks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f722_link_budget_reserves_only_deterministic_telemetry() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
assert!(board.contains("SCHEDULED_TELEMETRY_BYTES_PER_SECOND"));
|
||||
assert!(board.contains("SERVICE_HZ * SCHEDULED_FRAME_MAXIMUM_BYTES"));
|
||||
assert!(board.contains("self.drain_one_mavlink_diagnostic();"));
|
||||
assert!(board.contains("let mavlink_log_filter = runtime.mavlink_log_level_filter;"));
|
||||
assert!(board.contains("LogSinkQueue::new(mavlink_log_filter)"));
|
||||
assert!(!board.contains("mavlink_logging_enabled"));
|
||||
assert!(!board.contains("LogFilter"));
|
||||
for periodic_diagnostic_policy in [
|
||||
"ERROR_RECORDS_PER_SECOND",
|
||||
"LOG_RECORDS_PER_SECOND",
|
||||
"WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND",
|
||||
"ERROR_FRAME_MAXIMUM_BYTES",
|
||||
"LOG_FRAME_MAXIMUM_BYTES",
|
||||
"budget_ticks",
|
||||
"error_budget",
|
||||
"log_budget",
|
||||
] {
|
||||
assert!(
|
||||
!board.contains(periodic_diagnostic_policy),
|
||||
"diagnostics must not have a periodic policy: {periodic_diagnostic_policy}"
|
||||
);
|
||||
fn source_keeps_protocol_local_and_irq_priorities() {
|
||||
let firmware = source("src/f722/firmware.rs");
|
||||
let shared =
|
||||
&firmware[firmware.find("struct Shared").unwrap()..firmware.find("#[local]").unwrap()];
|
||||
assert!(!shared.contains("mavlink:"));
|
||||
for owner in ["uarts:", "usb:", "serial_clock:"] {
|
||||
assert!(shared.contains(owner));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mavlink_transport_faults_do_not_recurse_or_retain_dead_accounting() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
let transport = platform_source("src/f722/data_channel.rs");
|
||||
let portable_channel = platform_source("../../libs/kaptan-data-channel/src/lib.rs");
|
||||
|
||||
assert!(
|
||||
!board.contains("mavlink transmit dma fault"),
|
||||
"the MAVLink sink must not report its own transmit failure through itself"
|
||||
);
|
||||
|
||||
for recursive_path in [
|
||||
"drain_transport_faults",
|
||||
"take_transport_fault",
|
||||
"TransmitMemory",
|
||||
"TransmitEngine",
|
||||
"TransmitTerminal",
|
||||
"pending_faults",
|
||||
] {
|
||||
assert!(
|
||||
!board.contains(recursive_path)
|
||||
&& !transport.contains(recursive_path)
|
||||
&& !portable_channel.contains(recursive_path),
|
||||
"MAVLink transport must not feed {recursive_path} back into itself"
|
||||
);
|
||||
assert!(firmware.contains("context.local.mavlink.process_worker_events"));
|
||||
assert!(!firmware.contains(".lock(|mavlink|"));
|
||||
assert!(firmware.contains("priority = 7, local = [mavlink]"));
|
||||
for vector in ["USART1", "USART2", "USART3", "UART4", "USART6"] {
|
||||
assert!(firmware.contains(&format!("binds = {vector}, priority = 14")));
|
||||
}
|
||||
for dead_counter in [
|
||||
"dma_fault_count",
|
||||
"memory_fault_count",
|
||||
"engine_fault_count",
|
||||
"terminal_fault_count",
|
||||
] {
|
||||
assert!(
|
||||
!transport.contains(dead_counter),
|
||||
"unused transport accounting must stay absent: {dead_counter}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f722_producers_keep_logs_and_reportable_errors_separate() {
|
||||
let board = platform_source("src/f722/board.rs");
|
||||
let config = platform_source("src/f722/config.rs");
|
||||
let logger = platform_source("../../libs/kaptan-log/src/facade.rs");
|
||||
let errors = platform_source("../../libs/kaptan-err-reportable/src/ingress.rs");
|
||||
|
||||
for macro_name in [
|
||||
"kaptan_log::debug!",
|
||||
"kaptan_log::info!",
|
||||
"kaptan_log::warn!",
|
||||
"kaptan_log::error!",
|
||||
] {
|
||||
assert!(
|
||||
board.contains(macro_name),
|
||||
"f722 does not exercise {macro_name}"
|
||||
);
|
||||
}
|
||||
assert!(board.contains("#[kaptan_err_reportable::propagate]"));
|
||||
assert!(
|
||||
config.contains("enum PersistConfigurationError {")
|
||||
&& config.contains("#[kaptan_error(id = 2210)]")
|
||||
&& config.contains("#[kaptan_error(id = 2209)]")
|
||||
&& config.contains("impl From<JournalError<F722FlashError>>")
|
||||
&& config.contains("fn persist_candidate(")
|
||||
&& config.contains("#[kaptan_err_reportable::propagate]")
|
||||
);
|
||||
assert!(!board.contains("report_error"));
|
||||
assert!(!board.contains("report_static_error"));
|
||||
assert!(logger.contains("GLOBAL_INGRESS_CAPACITY"));
|
||||
assert!(logger.contains("compare_exchange"));
|
||||
assert!(errors.contains("AtomicU8"));
|
||||
assert!(errors.contains("UnsafeCell<MaybeUninit<ErrorRecord>>"));
|
||||
assert!(errors.contains("compare_exchange"));
|
||||
assert!(firmware.contains("binds = OTG_FS, priority = 8, shared = [usb]"));
|
||||
assert!(firmware.contains("binds = TIM2, priority = 10"));
|
||||
assert!(firmware.contains("local = [timer], shared = [lidar]"));
|
||||
let timer_irq = &firmware
|
||||
[firmware.find("fn tim2(").unwrap()..firmware.find("async fn imu_worker").unwrap()];
|
||||
assert!(timer_irq.contains("if !context.local.timer.on_interrupt()"));
|
||||
assert!(timer_irq.find(".on_interrupt()").unwrap() < timer_irq.find(".lidar.lock").unwrap());
|
||||
}
|
||||
+31
-41
@@ -1,15 +1,11 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use kaptan_config::{Config, ConfigManager, ConfigModel, ConfigStore, ConfigValue, FieldId};
|
||||
use platform_stm32f7::{
|
||||
F7ImmutableConfig, F722Hardware, F722ImmutableConfig, ImuMount, LevelFilter, Mavlink, Mpu6000,
|
||||
Mpu6000Config, Pa4, Pb0Pb1Pb4Pb5Low, Pd2ActiveLow, VerticalSensor,
|
||||
F722Hardware, ImuMount, LevelFilter, MotorPins, Mpu6000Config, Mpu6000Spi1, SerialInventory,
|
||||
StatusLed, VerticalSensor,
|
||||
};
|
||||
|
||||
// Include the implementation as a child module so this platform-internal test
|
||||
// can exercise the crate-private user-candidate adapter without flash hardware.
|
||||
#[path = "../src/f722/config.rs"]
|
||||
mod target_config;
|
||||
type F722ImmutableConfig = platform_stm32f7::F722ImmutableConfig<Mpu6000Spi1>;
|
||||
use crate::f722::config as target_config;
|
||||
|
||||
use target_config::UserConfigModel;
|
||||
|
||||
@@ -67,23 +63,21 @@ struct TestUserConfig {
|
||||
const IMMUTABLE: F722ImmutableConfig = F722ImmutableConfig {
|
||||
name: "active-candidate-test",
|
||||
service_hz: 100,
|
||||
hardware: F722Hardware::new()
|
||||
.output_inhibit(Pb0Pb1Pb4Pb5Low)
|
||||
.imu(
|
||||
Mpu6000::on_spi1()
|
||||
.with_drdy(Pa4)
|
||||
.mounted(ImuMount::BodyAligned)
|
||||
.configured(Mpu6000Config {
|
||||
accelerometer: platform_stm32f7::AccelerometerRange::PlusMinus4G,
|
||||
gyroscope: platform_stm32f7::GyroscopeRange::PlusMinus500DegreesPerSecond,
|
||||
digital_low_pass_filter: platform_stm32f7::DigitalLowPassFilter::Bandwidth42Hz,
|
||||
sample_rate_divider: 9,
|
||||
}),
|
||||
)
|
||||
.mavlink(Mavlink::on_usart1().baud(115_200))
|
||||
.vertical_sensor(VerticalSensor::none())
|
||||
.status_led(Pd2ActiveLow)
|
||||
.telemetry_only(),
|
||||
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>);
|
||||
@@ -158,13 +152,13 @@ fn mavlink_logging_policy_is_projected_and_validated_independently() {
|
||||
9,
|
||||
"runtime projection must retain narrow board-native scalar widths"
|
||||
);
|
||||
let mut candidate = UserConfigModel::<TestUserConfig, F722ImmutableConfig>::new(factory());
|
||||
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, &IMMUTABLE).unwrap();
|
||||
let runtime = target_config::project_runtime(&active.candidate, &IMMUTABLE).unwrap();
|
||||
assert_eq!(runtime.mavlink_log_level_filter, LevelFilter::Off);
|
||||
}
|
||||
|
||||
@@ -188,43 +182,39 @@ fn runtime_projection_rejects_missing_and_wrong_kind_values() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_construction_reads_the_persisted_board_owned_user_root() {
|
||||
let factory = UserConfigModel::<TestUserConfig, F722ImmutableConfig>::new(factory());
|
||||
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_compatible_or_factory(
|
||||
factory,
|
||||
IMMUTABLE,
|
||||
OneRecord(Some(persisted)),
|
||||
)
|
||||
.expect("synthetic persisted user configuration must recover");
|
||||
let manager = ConfigManager::recover(factory, IMMUTABLE, OneRecord(Some(persisted)))
|
||||
.expect("synthetic persisted user configuration must recover");
|
||||
|
||||
assert_eq!(
|
||||
manager.active().field_value(FieldId::new(1000)),
|
||||
manager.active().candidate.field_value(FieldId::new(1000)),
|
||||
Some(ConfigValue::U8(42)),
|
||||
"runtime construction must receive the validated persisted field"
|
||||
"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>::new(factory());
|
||||
let factory = UserConfigModel::<TestUserConfig, F722ImmutableConfig, 0>::new(factory());
|
||||
let mut manager = ConfigManager::recover(factory, IMMUTABLE, OneRecord(None)).unwrap();
|
||||
let mut replacement = manager.committed().clone();
|
||||
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().field_value(FieldId::new(1000)),
|
||||
manager.active().candidate.field_value(FieldId::new(1000)),
|
||||
Some(ConfigValue::U8(7))
|
||||
);
|
||||
assert_eq!(
|
||||
manager.committed().field_value(FieldId::new(1000)),
|
||||
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(®isters.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!(
|
||||
®isters.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, ®isters, 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, ®isters, 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, ®isters, (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, ®isters, 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, ®isters, 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, ®isters, 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, ®isters, 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, ®isters, 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());
|
||||
});
|
||||
}
|
||||
@@ -1,210 +1,146 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use kaptan_mavlink::{
|
||||
MavlinkFrame,
|
||||
encoder::MavlinkEncoder,
|
||||
generated::kaptan::{FILE_DATA_DATA, MavMessage},
|
||||
};
|
||||
use kaptan_tx::{FrameClass, PendingFrame};
|
||||
use usb_device::{
|
||||
UsbDirection, UsbError,
|
||||
bus::{PollResult, UsbBus, UsbBusAllocator},
|
||||
device::{UsbDeviceBuilder, UsbVidPid},
|
||||
endpoint::{EndpointAddress, EndpointType},
|
||||
};
|
||||
use usbd_serial::CdcAcmClass;
|
||||
use usb_device::bus::PollResult;
|
||||
|
||||
#[path = "../src/f722/usb.rs"]
|
||||
mod usb;
|
||||
use usb::UsbMavlink;
|
||||
use usb::test_bus as usb_bus;
|
||||
use usb_bus::with_link;
|
||||
#[path = "../src/f722/serial.rs"]
|
||||
mod serial;
|
||||
use serial::{IrqTransmit, UartIo};
|
||||
|
||||
// One pending packet per endpoint, with host-controlled completion. Device and
|
||||
// CDC control requests run through the real usb-device/usbd-serial code.
|
||||
#[derive(Default)]
|
||||
struct BusState {
|
||||
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,
|
||||
struct MockUart {
|
||||
bytes: Vec<u8>,
|
||||
ready: bool,
|
||||
complete: bool,
|
||||
ready_irq: bool,
|
||||
complete_irq: bool,
|
||||
}
|
||||
impl UartIo for MockUart {
|
||||
fn tx_ready(&mut self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
fn tx_complete(&mut self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
fn write_byte(&mut self, byte: u8) {
|
||||
self.bytes.push(byte);
|
||||
self.complete = false;
|
||||
}
|
||||
fn interrupts(&mut self, ready: bool, complete: bool) {
|
||||
self.ready_irq = ready;
|
||||
self.complete_irq = complete;
|
||||
}
|
||||
}
|
||||
fn flush_uart(tx: &mut IrqTransmit, io: &mut MockUart) {
|
||||
io.ready = true;
|
||||
for _ in 0..2048 {
|
||||
if !io.ready_irq && !io.complete_irq {
|
||||
return;
|
||||
}
|
||||
let previous = io.bytes.len();
|
||||
io.complete = true;
|
||||
tx.on_interrupt(io);
|
||||
assert!(
|
||||
io.bytes.len() <= previous + 1,
|
||||
"IRQ work is bounded to one byte"
|
||||
);
|
||||
}
|
||||
panic!("UART did not drain its bounded queue");
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Host(Arc<Mutex<BusState>>);
|
||||
#[test]
|
||||
fn moving_irq_queue_and_waiting_for_tc_preserves_exact_frame_bytes() {
|
||||
let mut io = MockUart {
|
||||
ready: true,
|
||||
..MockUart::default()
|
||||
};
|
||||
let mut tx = IrqTransmit::new();
|
||||
let mut encoder = MavlinkEncoder::new(1, 1);
|
||||
let frame = encoder.encode(&kaptan_mavlink::generated::kaptan::HEARTBEAT_DATA::default());
|
||||
assert!(tx.send(
|
||||
&mut io,
|
||||
PendingFrame {
|
||||
class: FrameClass::Vital,
|
||||
frame
|
||||
}
|
||||
));
|
||||
assert!(!tx.on_interrupt(&mut io));
|
||||
let mut moved = tx;
|
||||
for _ in 1..frame.raw_bytes().len() {
|
||||
assert!(!moved.on_interrupt(&mut io));
|
||||
}
|
||||
assert_eq!(io.bytes, frame.raw_bytes());
|
||||
assert!(!io.ready_irq && io.complete_irq);
|
||||
assert!(!moved.on_interrupt(&mut io));
|
||||
io.complete = true;
|
||||
assert!(moved.on_interrupt(&mut io));
|
||||
assert!(!io.ready_irq && !io.complete_irq);
|
||||
assert!(!moved.on_interrupt(&mut io));
|
||||
}
|
||||
|
||||
struct PacketBus(Arc<Mutex<BusState>>);
|
||||
|
||||
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)?,
|
||||
#[test]
|
||||
fn usb_and_two_uarts_have_independent_sequences_queues_and_completion() {
|
||||
use kaptan_data_channel::{DataChannel, DataChannelRates, TelemetrySamples};
|
||||
use kaptan_time::Timestamp;
|
||||
with_link(|host, usb| {
|
||||
host.configure(usb, true);
|
||||
host.dtr(usb, true);
|
||||
let rates = DataChannelRates {
|
||||
imu_hz: 20,
|
||||
heartbeat_hz: 2,
|
||||
range_hz: None,
|
||||
optical_flow_hz: None,
|
||||
};
|
||||
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,
|
||||
let mut channels = core::array::from_fn::<_, 3, _>(|_| {
|
||||
DataChannel::<100>::new(1, 1, rates, 100_000).unwrap()
|
||||
});
|
||||
let mut uart1 = IrqTransmit::new();
|
||||
let mut uart2 = IrqTransmit::new();
|
||||
let mut slow = MockUart::default();
|
||||
let mut fast = MockUart::default();
|
||||
let samples = TelemetrySamples::default();
|
||||
let mut usb_bytes = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
let mut dropped = false;
|
||||
for tick in 0..400 {
|
||||
let now = Timestamp::from_microseconds(tick * 10_000);
|
||||
let frames = channels
|
||||
.each_mut()
|
||||
.map(|channel| channel.on_service_tick(&samples, now));
|
||||
if let [Some(a), Some(b), Some(c)] = frames {
|
||||
assert_eq!(a.frame.raw_bytes(), b.frame.raw_bytes());
|
||||
assert_eq!(b.frame.raw_bytes(), c.frame.raw_bytes());
|
||||
expected.extend_from_slice(c.frame.raw_bytes());
|
||||
assert!(usb.send(a));
|
||||
dropped |= !uart1.send(&mut slow, b);
|
||||
assert!(uart2.send(&mut fast, c));
|
||||
}
|
||||
flush_uart(&mut uart2, &mut fast);
|
||||
usb.poll();
|
||||
usb_bytes.extend(host.collect_packets(usb).into_iter().flatten());
|
||||
}
|
||||
Ok(EndpointAddress::from_parts(index, direction))
|
||||
}
|
||||
|
||||
fn enable(&mut self) {}
|
||||
|
||||
fn reset(&self) {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
state.input.fill(None);
|
||||
state.output.fill(None);
|
||||
state.events.clear();
|
||||
}
|
||||
|
||||
fn set_device_address(&self, _: u8) {}
|
||||
|
||||
fn write(&self, address: EndpointAddress, bytes: &[u8]) -> Result<usize, UsbError> {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
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();
|
||||
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();
|
||||
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 {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.events
|
||||
.pop_front()
|
||||
.unwrap_or(PollResult::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn event(&self, event: PollResult) {
|
||||
self.0.lock().unwrap().events.push_back(event);
|
||||
}
|
||||
|
||||
fn control(&self, link: &mut UsbMavlink<'_, PacketBus>, request: [u8; 8]) {
|
||||
self.0.lock().unwrap().output[0] = Some(request.to_vec());
|
||||
self.event(PollResult::Data {
|
||||
ep_out: 0,
|
||||
ep_in_complete: 0,
|
||||
ep_setup: 1,
|
||||
});
|
||||
link.poll();
|
||||
assert_eq!(self.take_input(0), Some(Vec::new()));
|
||||
link.poll();
|
||||
}
|
||||
|
||||
fn configure(&self, link: &mut UsbMavlink<'_, PacketBus>, configured: bool) {
|
||||
self.control(link, [0, 9, u8::from(configured), 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
fn dtr(&self, link: &mut UsbMavlink<'_, PacketBus>, open: bool) {
|
||||
self.control(link, [0x21, 0x22, u8::from(open), 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
fn write_packet(&self, bytes: &[u8]) {
|
||||
assert!(bytes.len() <= 64);
|
||||
let mut state = self.0.lock().unwrap();
|
||||
let endpoint = state.bulk_out;
|
||||
assert!(state.output[endpoint].is_none());
|
||||
state.output[endpoint] = Some(bytes.to_vec());
|
||||
state.events.push_back(PollResult::Data {
|
||||
ep_out: 1 << endpoint,
|
||||
ep_in_complete: 0,
|
||||
ep_setup: 0,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
Some(packet)
|
||||
}
|
||||
|
||||
fn collect_packets(&self, link: &mut UsbMavlink<'_, PacketBus>) -> Vec<Vec<u8>> {
|
||||
let endpoint = self.0.lock().unwrap().bulk_in;
|
||||
let mut packets = Vec::new();
|
||||
for _ in 0..16 {
|
||||
let Some(packet) = self.take_input(endpoint) else {
|
||||
return packets;
|
||||
};
|
||||
packets.push(packet);
|
||||
link.poll();
|
||||
}
|
||||
panic!("transmission did not finish");
|
||||
}
|
||||
}
|
||||
|
||||
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.event(PollResult::Reset);
|
||||
link.poll();
|
||||
host.configure(&mut link, true);
|
||||
host.dtr(&mut link, true);
|
||||
assert!(link.is_open());
|
||||
test(&host, &mut link);
|
||||
assert!(
|
||||
dropped,
|
||||
"blocked UART must exhaust only its own bounded queue"
|
||||
);
|
||||
assert!(slow.bytes.is_empty());
|
||||
assert_eq!(usb_bytes, expected);
|
||||
assert_eq!(fast.bytes, expected);
|
||||
let fast_before = fast.bytes.clone();
|
||||
flush_uart(&mut uart1, &mut slow);
|
||||
assert!(!slow.bytes.is_empty());
|
||||
assert_eq!(
|
||||
fast.bytes, fast_before,
|
||||
"UART1 completions cannot advance UART2"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn pending(frame: MavlinkFrame) -> PendingFrame<MavlinkFrame> {
|
||||
@@ -269,7 +205,7 @@ fn drains_endpoint_backlog_after_its_poll_event_was_consumed() {
|
||||
assert_eq!(link.take_received().unwrap().raw(), &first);
|
||||
assert!(link.take_received().is_none());
|
||||
|
||||
assert!(host.0.lock().unwrap().events.is_empty());
|
||||
assert!(host.events_empty());
|
||||
assert!(link.poll());
|
||||
assert_eq!(link.take_received().unwrap().raw(), &second);
|
||||
});
|
||||
@@ -432,3 +368,30 @@ fn bus_reset_discards_transmit_and_receive_state() {
|
||||
assert_eq!(link.take_received().unwrap().raw(), &next);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_fault_closes_link_and_discards_pending_data() {
|
||||
for fail in [
|
||||
usb_bus::Host::fail_poll,
|
||||
usb_bus::Host::fail_read,
|
||||
usb_bus::Host::fail_write,
|
||||
] {
|
||||
with_link(|host, link| {
|
||||
let frame = file_frame(130);
|
||||
assert!(link.send(pending(frame)));
|
||||
host.write_packet(&frame.raw_bytes()[..8]);
|
||||
link.poll();
|
||||
assert!(link.take_received().is_none());
|
||||
fail(host);
|
||||
link.with_rx_interrupt_gate(
|
||||
|link| assert!(!link.poll()),
|
||||
|_| panic!("interrupt register access after terminal fault"),
|
||||
);
|
||||
assert!(!link.is_open());
|
||||
assert!(!link.send(pending(frame)));
|
||||
assert!(link.take_received().is_none());
|
||||
assert!(!link.poll());
|
||||
assert!(!host.nvic_enabled());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user