use std::{fs, path::PathBuf}; use kaptan_board::{ BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort, TelemetryProfile, VerticalSensingKind, }; use kaptan_log::{ErrorAdditionalInformation, ErrorPath, LogFlags, LogRecord, LogText, Severity}; use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; use kaptan_mavlink::encoder::MavlinkEncoder; use platform_stm32f7::{ BoardError, F7BoardBuilder, Mavlink, Mpu6000, Pa4, VerticalSensor, validate_board_config, }; 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") } #[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_motor_pins") .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 output_mode = interlock .find("port.moder()") .expect("interlock must configure the former motor pins as GPIO outputs"); assert!( reset < output_mode, "output latches must be low before GPIO output mode is selected" ); 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" ); } } fn mpu_config() -> BoardConfig { BoardConfig { name: "test", mavlink: MavlinkEndpoint { system_id: 1, component_id: 1, port: MavlinkPort::Serial1, baud: 115_200, }, profile: BoardProfile::TelemetryOnly(TelemetryProfile { imu: ImuDevice::Mpu6000, imu_mount: ImuMount::BodyAligned, vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, data_channel: DataChannelRates { service_hz: 100, raw_imu_hz: 20, range_hz: Some(20), optical_flow_hz: Some(20), heartbeat_hz: 2, }, maximum_log_level: Severity::Info, }), } } #[test] fn immutable_board_policy_must_match_the_executed_route_graph() { let routes = F7BoardBuilder::new() .imu(Mpu6000::on_spi1().with_drdy(Pa4)) .mavlink(Mavlink::on_usart1()) .vertical_sensor(VerticalSensor::mtf01_on_uart4()) .build() .unwrap(); assert_eq!(validate_board_config(mpu_config(), routes), Ok(())); let mut wrong_imu = mpu_config(); let BoardProfile::TelemetryOnly(profile) = &mut wrong_imu.profile else { unreachable!(); }; profile.imu = ImuDevice::Icm42688; assert_eq!( validate_board_config(wrong_imu, routes), Err(BoardError::ImuRouteMismatch) ); let mut wrong_mount = mpu_config(); let BoardProfile::TelemetryOnly(profile) = &mut wrong_mount.profile else { unreachable!(); }; profile.imu_mount = ImuMount::Yaw180; assert_eq!( validate_board_config(wrong_mount, routes), Err(BoardError::ImuMountMismatch) ); let mut wrong_vertical = mpu_config(); let BoardProfile::TelemetryOnly(profile) = &mut wrong_vertical.profile else { unreachable!(); }; profile.vertical_sensing = VerticalSensingKind::Range; profile.data_channel.optical_flow_hz = None; assert_eq!( validate_board_config(wrong_vertical, routes), Err(BoardError::VerticalSensorCapabilityMismatch) ); let mut wrong_port = mpu_config(); wrong_port.mavlink.port = MavlinkPort::Serial2; assert_eq!( validate_board_config(wrong_port, routes), Err(BoardError::MavlinkRouteMismatch) ); let mut overcommitted_link = mpu_config(); overcommitted_link.mavlink.baud = 9_600; assert_eq!( validate_board_config(overcommitted_link, routes), Err(BoardError::TelemetryLinkOvercommitted) ); } #[test] fn telemetry_platform_has_no_physical_actuator_peripheral_path() { 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"), ] .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}" ); } let manifest = platform_source("Cargo.toml"); assert!( !manifest.contains("kaptan-actuator"), "telemetry platform must not link the physical actuator crate" ); } #[test] fn telemetry_has_no_inbound_command_authority() { let board = platform_source("src/f722/board.rs"); let firmware = platform_source("src/f722/firmware.rs"); assert!( !board.contains("MavlinkReceiver"), "telemetry must not retain an inbound MAVLink dispatch path" ); assert!( !board.contains("ArmRequest"), "telemetry must not decode arm commands" ); assert!( !board.contains("MAV_RESULT_ACCEPTED"), "telemetry must not accept an arm command" ); assert!( !firmware.contains("shared = [mavlink, motors]"), "the MAVLink worker must not share a motor resource" ); assert!( !firmware.contains("MAVLINK_EVENT_RECEIVED"), "telemetry must not schedule a worker from an inbound command" ); } #[test] fn f722_link_budget_uses_both_maximum_generated_diagnostic_frames() { let text = LogText::from_message(&"x".repeat(160)); let log_record = LogRecord::new(Severity::Info, LogFlags::NONE, text); let error_record = LogRecord::error( LogFlags::NONE, ErrorPath::from_message(&"p".repeat(96)), ErrorAdditionalInformation::from_message(&"i".repeat(64)), ); let mut encoder = MavlinkEncoder::new(1, 1); let DiagnosticPayload::Log(log_payload) = encode_diagnostic(&log_record) else { panic!("info record must use KAPTAN_LOG"); }; let log_frame = encoder.custom(&log_payload); let DiagnosticPayload::Error(error_payload) = encode_diagnostic(&error_record) else { panic!("error record must use KAPTAN_ERROR"); }; let error_frame = encoder.custom(&error_payload); assert_eq!(log_frame.raw_bytes().len(), 175); assert_eq!( kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA::ENCODED_LEN, 163 ); assert_eq!(error_frame.raw_bytes().len(), 175); assert_eq!( kaptan_mavlink::message::kaptan::KAPTAN_ERROR_DATA::ENCODED_LEN, 163 ); assert_eq!(error_payload.path_length, 96); assert_eq!(error_payload.additional_information_length, 64); assert_eq!(error_payload.flags.bits(), LogFlags::NONE.bits()); assert_eq!( 62 * 48 + 10 * error_frame.raw_bytes().len() + 5 * log_frame.raw_bytes().len(), 5_601 ); let board = platform_source("src/f722/board.rs"); assert!(board.contains("KAPTAN_ERROR_FRAME_MAXIMUM_BYTES")); assert!(board.contains("KAPTAN_LOG_FRAME_MAXIMUM_BYTES")); assert!(board.contains("5,601 bytes/s")); }