diff --git a/platforms/stm32f7/Cargo.toml b/platforms/stm32f7/Cargo.toml index c92b885..3c3a687 100644 --- a/platforms/stm32f7/Cargo.toml +++ b/platforms/stm32f7/Cargo.toml @@ -24,6 +24,8 @@ kaptan-data-channel = { path = "../../libs/kaptan-data-channel" } kaptan-dma = { path = "../../libs/kaptan-dma" } kaptan-drivers = { path = "../../libs/kaptan-drivers" } kaptan-i2c = { path = "../../libs/kaptan-i2c" } +kaptan-log = { path = "../../libs/kaptan-log" } +kaptan-log-mavlink = { path = "../../libs/kaptan-log-mavlink" } kaptan-mavlink = { path = "../../libs/kaptan-mavlink" } kaptan-sensor = { path = "../../libs/kaptan-sensor" } kaptan-spi = { path = "../../libs/kaptan-spi" } diff --git a/platforms/stm32f7/src/f722/board.rs b/platforms/stm32f7/src/f722/board.rs index 88f7a5f..bcb2291 100644 --- a/platforms/stm32f7/src/f722/board.rs +++ b/platforms/stm32f7/src/f722/board.rs @@ -17,6 +17,11 @@ use kaptan_drivers::{ lidar_lite_v3::{AcquisitionMode, LidarLiteV3, LidarLiteV3Service, LidarLiteV3ServiceEvent}, mtf01::Mtf01Service, }; +use kaptan_log::{ + DeliveryClass, ErrorReport, EventHub, KaptanError, LogFilter, LogFlags, StaticErrorMailbox, + StaticErrorMessage, TransportAdmission, +}; +use kaptan_log_mavlink::{DiagnosticPayload, encode_diagnostic}; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; @@ -37,7 +42,47 @@ const MTF01_BAUD: u32 = 115_200; const I2C1_CONFIG: I2c1Config = I2c1Config::from_timing_register(0x2040_4768); const LIDAR_MODE: AcquisitionMode = AcquisitionMode::WithReceiverBiasCorrection; const LIDAR_STARTUP_TICKS: u8 = 3; +const ERROR_RECORDS_PER_SECOND: u8 = 10; +const LOG_RECORDS_PER_SECOND: u8 = 5; +const SCHEDULED_FRAMES_PER_SECOND: usize = 62; +const SCHEDULED_FRAME_MAXIMUM_BYTES: usize = 48; +const MAVLINK_V2_UNSIGNED_OVERHEAD_BYTES: usize = 12; +const KAPTAN_ERROR_FRAME_MAXIMUM_BYTES: usize = + kaptan_mavlink::message::kaptan::KAPTAN_ERROR_DATA::ENCODED_LEN + + MAVLINK_V2_UNSIGNED_OVERHEAD_BYTES; +const KAPTAN_LOG_FRAME_MAXIMUM_BYTES: usize = + kaptan_mavlink::message::kaptan::KAPTAN_LOG_DATA::ENCODED_LEN + + MAVLINK_V2_UNSIGNED_OVERHEAD_BYTES; +// The static F722 telemetry profile is bounded at 62 scheduled frames/s. +// It permits ten `KAPTAN_ERROR` and five `KAPTAN_LOG` frames/s. Their maxima +// are respectively 162 and 163 payload bytes plus 12 unsigned MAVLink V2 +// framing bytes, so 62×48 + 10×174 + 5×175 = 5,591 bytes/s. MAVLink signing +// is not enabled in this telemetry-only image; any future signing profile +// must re-budget its signature overhead before it can enable the endpoint. +pub(crate) const WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND: usize = SCHEDULED_FRAMES_PER_SECOND + * SCHEDULED_FRAME_MAXIMUM_BYTES + + ERROR_RECORDS_PER_SECOND as usize * KAPTAN_ERROR_FRAME_MAXIMUM_BYTES + + LOG_RECORDS_PER_SECOND as usize * KAPTAN_LOG_FRAME_MAXIMUM_BYTES; + +/// Returns whether the immutable board UART configuration can carry the fixed +/// telemetry and bounded diagnostic schedule at ten UART bits per byte. +pub(crate) const fn link_budget_fits(baud: u32) -> bool { + WORST_CASE_OBSERVABILITY_BYTES_PER_SECOND < baud as usize / 10 +} static UART_COMPLETION: AtomicU8 = AtomicU8::new(0); +#[derive(Clone, Copy, Debug, KaptanError)] +enum F722TelemetryError { + Mtf01Uart, + TransmitMemory, + TransmitEngine, + TransmitTerminal, +} +// High-priority/ISR-adjacent producers never encode, lock, allocate, or send +// MAVLink. They retain the first fault text and coalesce later reports for +// the low-priority MAVLink worker to publish. +static IMU_FAULTS: StaticErrorMailbox = StaticErrorMailbox::new(); +static LIDAR_FAULTS: StaticErrorMailbox = StaticErrorMailbox::new(); +static MTF01_FAULTS: StaticErrorMailbox = StaticErrorMailbox::new(); static SAMPLES: LatestImuMailbox = LatestImuMailbox::new(); static RANGES: LatestRangeMailbox = LatestRangeMailbox::new(); static OPTICAL_FLOW: LatestOpticalFlowMailbox = LatestOpticalFlowMailbox::new(); @@ -56,6 +101,10 @@ pub type F722ServiceTimer = Tim2Periodic; /// rather than the IMU or vertical-sensor IRQ ceiling. pub struct F722MavlinkState { data_channel: DataChannel<'static, F722MavlinkTransmitter, SERVICE_HZ>, + logs: EventHub<2, 4>, + budget_ticks: u8, + error_budget: u8, + log_budget: u8, led: F722StatusLed, } @@ -83,7 +132,7 @@ pub struct F722InitResources { /// Validates the relationship between portable metadata and selected sensor /// route before platform initialization claims a peripheral. -pub(crate) fn validate_vertical_route( +pub(crate) fn validate_board_routes( config: BoardConfig, routes: F722BoardRoutes, ) -> Result<(), BoardError> { @@ -99,6 +148,18 @@ pub(crate) fn validate_vertical_route( if telemetry.vertical_sensing != expected { return Err(BoardError::VerticalSensorCapabilityMismatch); } + if telemetry.imu != routes.imu() { + return Err(BoardError::ImuRouteMismatch); + } + if telemetry.imu_mount != routes.imu_mount() { + return Err(BoardError::ImuMountMismatch); + } + if config.mavlink.port != routes.mavlink_port() { + return Err(BoardError::MavlinkRouteMismatch); + } + if !link_budget_fits(config.mavlink.baud) { + return Err(BoardError::TelemetryLinkOvercommitted); + } Ok(()) } @@ -145,15 +206,18 @@ fn initialize_mpu6000( clock.fork(), vertical, )?; + let telemetry = config.telemetry().ok_or(BoardError::Initialization)?; let imu = F722Imu::mpu6000( &device.RCC, device.SPI1, device.DMA2, device.GPIOB, clock.fork(), + telemetry.imu_mount, ); - let transmitter = F722MavlinkTransmitter::usart1(&device.RCC, device.USART1) - .map_err(|_| BoardError::Initialization)?; + let transmitter = + F722MavlinkTransmitter::usart1(&device.RCC, device.USART1, config.mavlink.baud) + .map_err(|_| BoardError::Initialization)?; let led = F722StatusLed::Kakute(super::peripherals::ActiveLowLedPd2::new( &device.RCC, device.GPIOD, @@ -199,15 +263,18 @@ fn initialize_icm42688( clock.fork(), vertical, )?; + let telemetry = config.telemetry().ok_or(BoardError::Initialization)?; let imu = F722Imu::icm42688( &device.RCC, device.SPI3, device.DMA1, &device.GPIOA, clock.fork(), + telemetry.imu_mount, ); - let transmitter = F722MavlinkTransmitter::usart2(&device.RCC, device.USART2) - .map_err(|_| BoardError::Initialization)?; + let transmitter = + F722MavlinkTransmitter::usart2(&device.RCC, device.USART2, config.mavlink.baud) + .map_err(|_| BoardError::Initialization)?; let led = F722StatusLed::Geprc(GeprcStatusLed::new(&device.RCC, device.GPIOA)); finish( &device.RCC, @@ -256,7 +323,19 @@ fn finish( range, startup_ticks: 0, }, - mavlink: F722MavlinkState { data_channel, led }, + mavlink: F722MavlinkState { + data_channel, + logs: EventHub::new(LogFilter::new( + config + .telemetry() + .ok_or(BoardError::Initialization)? + .maximum_log_level, + )), + budget_ticks: 0, + error_budget: ERROR_RECORDS_PER_SECOND, + log_budget: LOG_RECORDS_PER_SECOND, + led, + }, }) } @@ -346,7 +425,10 @@ pub(crate) fn on_uart4_interrupt(mtf01: &mut Option) { } } } - ReceiveInterrupt::Fault => mtf01.reset(), + ReceiveInterrupt::Fault => { + mtf01.reset(); + record_mtf01_fault(F722TelemetryError::Mtf01Uart.error_message()); + } ReceiveInterrupt::NotPending => {} } } @@ -370,8 +452,10 @@ impl F722LidarState { if let Some(range) = self.range.as_mut() { if self.startup_ticks < LIDAR_STARTUP_TICKS { self.startup_ticks += 1; - if self.startup_ticks == LIDAR_STARTUP_TICKS { - let _ = range.finish_startup(); + if self.startup_ticks == LIDAR_STARTUP_TICKS + && let Err(error) = range.finish_startup() + { + record_lidar_fault(error.error_message()); } } else { publish_lidar_event(range.on_periodic_tick()); @@ -410,7 +494,7 @@ fn accept_imu_event(event: ImuEvent) { ImuEvent::Sample(sample) => { SAMPLES.publish(sample); } - ImuEvent::Fault => {} + ImuEvent::Fault(message) => record_imu_fault(message), ImuEvent::Idle | ImuEvent::Initialized => {} } } @@ -423,7 +507,7 @@ impl F722MavlinkState { if events & DMA != 0 { let completion = take_mavlink_completion(); - self.data_channel.on_dma_complete(completion); + let _ = self.data_channel.on_dma_complete(completion); if completion == kaptan_dma::TxCompletion::Complete { self.led.toggle(); } @@ -436,6 +520,94 @@ impl F722MavlinkState { // would incorrectly claim initialization never completed. self.data_channel .on_service_tick(kaptan_mavlink::message::MavState::MAV_STATE_STANDBY, false); + self.drain_driver_faults(); + self.budget_ticks = self.budget_ticks.saturating_add(1); + if self.budget_ticks == SERVICE_HZ as u8 { + self.budget_ticks = 0; + self.error_budget = ERROR_RECORDS_PER_SECOND; + self.log_budget = LOG_RECORDS_PER_SECOND; + } + self.drain_one_log_record(); + } + } + + fn drain_driver_faults(&mut self) { + self.drain_fault_mailbox(&IMU_FAULTS); + self.drain_fault_mailbox(&LIDAR_FAULTS); + self.drain_fault_mailbox(&MTF01_FAULTS); + self.drain_transport_faults(); + } + + fn drain_transport_faults(&mut self) { + while let Some(fault) = self.data_channel.take_transport_fault() { + let message = match fault { + kaptan_mavlink::transmitter::TransmitFaultKind::Memory => { + F722TelemetryError::TransmitMemory.error_message() + } + kaptan_mavlink::transmitter::TransmitFaultKind::Engine => { + F722TelemetryError::TransmitEngine.error_message() + } + kaptan_mavlink::transmitter::TransmitFaultKind::Terminal => { + F722TelemetryError::TransmitTerminal.error_message() + } + }; + let _ = self.logs.publish_error_static(LogFlags::NONE, message); + } + } + + fn drain_fault_mailbox(&mut self, mailbox: &StaticErrorMailbox) { + let (report, coalesced) = mailbox.take(); + let Some(message) = report else { + return; + }; + let flags = if coalesced { + LogFlags::COALESCED + } else { + LogFlags::NONE + }; + let _ = self.logs.publish_error_static(flags, message); + } + + fn drain_one_log_record(&mut self) { + let Some(record) = self.logs.peek() else { + return; + }; + let delivery = record.delivery(); + if (delivery == DeliveryClass::Critical && self.error_budget == 0) + || (delivery == DeliveryClass::BestEffort && self.log_budget == 0) + { + return; + } + let data_channel = &mut self.data_channel; + let mut accepted = None; + let _ = self.logs.submit_next(|record| { + let class = match record.delivery() { + DeliveryClass::Critical => kaptan_mavlink::transmitter::FrameClass::Critical, + DeliveryClass::BestEffort => kaptan_mavlink::transmitter::FrameClass::BestEffort, + }; + let payload = encode_diagnostic(record); + let admission = match &payload { + DiagnosticPayload::Log(payload) => data_channel.send_custom(class, payload), + DiagnosticPayload::Error(payload) => data_channel.send_custom(class, payload), + }; + let outcome = if admission.is_retained() { + accepted = Some(record.delivery()); + TransportAdmission::Accepted + } else if record.delivery() == DeliveryClass::BestEffort { + TransportAdmission::DropBestEffort + } else { + TransportAdmission::Retry + }; + (outcome, ()) + }); + match accepted { + Some(DeliveryClass::Critical) => { + self.error_budget = self.error_budget.saturating_sub(1); + } + Some(DeliveryClass::BestEffort) => { + self.log_budget = self.log_budget.saturating_sub(1); + } + None => {} } } } @@ -459,49 +631,22 @@ pub fn record_mavlink_completion(completion: kaptan_dma::TxCompletion) { } } -fn publish_lidar_event(event: LidarLiteV3ServiceEvent) { - if let LidarLiteV3ServiceEvent::Measurement(snapshot) = event { - RANGES.publish(snapshot); +fn publish_lidar_event(event: LidarLiteV3ServiceEvent) { + match event { + LidarLiteV3ServiceEvent::Measurement(snapshot) => RANGES.publish(snapshot), + LidarLiteV3ServiceEvent::Fault(error) => record_lidar_fault(error.error_message()), + LidarLiteV3ServiceEvent::Idle => {} } } -#[cfg(test)] -mod tests { - use super::validate_vertical_route; - use crate::{F7BoardBuilder, Mavlink, Mpu6000, Pa4, VerticalSensor}; - use kaptan_board::{ - BoardConfig, BoardProfile, DataChannelRates, MavlinkEndpoint, TelemetryProfile, - VerticalSensingKind, - }; +fn record_imu_fault(message: &'static StaticErrorMessage) { + IMU_FAULTS.record(message); +} - #[test] - fn rejects_metadata_that_claims_flow_for_a_lidar_route() { - let routes = F7BoardBuilder::new() - .imu(Mpu6000::on_spi1().with_drdy(Pa4)) - .mavlink(Mavlink::on_usart1()) - .vertical_sensor(VerticalSensor::lidar_lite_v3_on_i2c1()) - .build() - .unwrap(); - let config = BoardConfig { - name: "invalid", - mavlink: MavlinkEndpoint { - system_id: 1, - component_id: 1, - }, - profile: BoardProfile::TelemetryOnly(TelemetryProfile { - 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, - }, - }), - }; - assert_eq!( - validate_vertical_route(config, routes), - Err(crate::BoardError::VerticalSensorCapabilityMismatch) - ); - } +fn record_lidar_fault(message: &'static StaticErrorMessage) { + LIDAR_FAULTS.record(message); +} + +fn record_mtf01_fault(message: &'static StaticErrorMessage) { + MTF01_FAULTS.record(message); } diff --git a/platforms/stm32f7/src/f722/clock.rs b/platforms/stm32f7/src/f722/clock.rs index 8a199c1..2724ecd 100644 --- a/platforms/stm32f7/src/f722/clock.rs +++ b/platforms/stm32f7/src/f722/clock.rs @@ -1,12 +1,14 @@ //! STM32F722 Pll216 clock-source assembly. use super::peripherals::{DwtMicrosecondClock, select_pll216_profile}; +use kaptan_log::KaptanError; use stm32f7::stm32f722; /// Core frequency in whole megahertz after selecting the PLL216 board profile. const CORE_CLOCK_MHZ: u32 = 216; /// A failure while establishing the board's clock tree or monotonic clock. +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub(crate) enum ClockInitializationError { Profile, Monotonic, diff --git a/platforms/stm32f7/src/f722/data_channel.rs b/platforms/stm32f7/src/f722/data_channel.rs index b85fc11..8deb057 100644 --- a/platforms/stm32f7/src/f722/data_channel.rs +++ b/platforms/stm32f7/src/f722/data_channel.rs @@ -1,22 +1,19 @@ //! F722 transport and vertical-route adapters for the portable data channel. use super::{VerticalRoute, peripherals}; -use kaptan_data_channel::{FrameSink, VerticalDataConfig}; +use kaptan_data_channel::{DmaCompletion, FrameAdmission, FrameSink, TxHealth, VerticalDataConfig}; use kaptan_dma::TxCompletion; use kaptan_mavlink::{ MavlinkFrame, encoder::DistanceSensorConfig, - transmitter::{FrameClass, MavlinkTransmitter}, + transmitter::{FrameClass, MavlinkTransmitter, TransmitFaultKind}, }; -// TODO(configuration): transport baud rate, sensor orientation/range, and -// freshness are currently development profile constants. A ground-station -// configuration flow must stage them with sensor calibration and link -// authority, apply them only with output inhibited, then reset estimator, -// controller, allocator, and safety prearm state as one revision. +// Board endpoint, baud, sensor topology, and mounting remain immutable +// build-time policy. They are not ground-station-editable in this telemetry +// image: the F722 has no authenticated config ingress or activation runtime. pub(crate) const UART1_CLOCK_HZ: u32 = 108_000_000; pub(crate) const UART2_CLOCK_HZ: u32 = 54_000_000; -pub(crate) const MAVLINK_BAUD: u32 = 115_200; pub(crate) const SERVICE_HZ: usize = 100; /// Fixed service-timer configuration for the validated Pll216 profile. @@ -60,65 +57,154 @@ pub(crate) fn vertical_data_config(route: VerticalRoute) -> VerticalDataConfig { } /// The two F722 DMA engines behind the portable data-channel transport. -pub(crate) enum F722MavlinkTransmitter { +enum F722MavlinkTransport { Usart1(MavlinkTransmitter), Usart2(MavlinkTransmitter), } +/// Sole F722 MAVLink DMA owner plus unconfigurable transport-loss accounting. +pub(crate) struct F722MavlinkTransmitter { + inner: F722MavlinkTransport, + health: TxHealth, + pending_faults: u8, +} + impl F722MavlinkTransmitter { pub(crate) fn usart1( rcc: &stm32f7::stm32f722::RCC, uart: stm32f7::stm32f722::USART1, + baud: u32, ) -> Result { - let uart = - peripherals::Usart1Tx::new(rcc, uart, UART1_CLOCK_HZ, MAVLINK_BAUD).map_err(|_| ())?; + let uart = peripherals::Usart1Tx::new(rcc, uart, UART1_CLOCK_HZ, baud).map_err(|_| ())?; // SAFETY: platform initialization disables D-cache before this object // is created, and the active MAVLink frame remains queue-owned until // this platform receives a terminal DMA completion notification. let memory = unsafe { peripherals::DcacheDisabledDmaMemory::new() }; - Ok(Self::Usart1(MavlinkTransmitter::new( - memory, - peripherals::Usart1TxDma::new(rcc, uart), - ))) + Ok(Self { + inner: F722MavlinkTransport::Usart1(MavlinkTransmitter::new( + memory, + peripherals::Usart1TxDma::new(rcc, uart), + )), + health: TxHealth::default(), + pending_faults: 0, + }) } pub(crate) fn usart2( rcc: &stm32f7::stm32f722::RCC, uart: stm32f7::stm32f722::USART2, + baud: u32, ) -> Result { - let uart = - peripherals::Usart2Tx::new(rcc, uart, UART2_CLOCK_HZ, MAVLINK_BAUD).map_err(|_| ())?; + let uart = peripherals::Usart2Tx::new(rcc, uart, UART2_CLOCK_HZ, baud).map_err(|_| ())?; // SAFETY: identical DMA-memory invariant to the USART1 route above; // only the fixed platform DMA endpoint differs. let memory = unsafe { peripherals::DcacheDisabledDmaMemory::new() }; - Ok(Self::Usart2(MavlinkTransmitter::new( - memory, - peripherals::Usart2TxDma::new(rcc, uart), - ))) + Ok(Self { + inner: F722MavlinkTransport::Usart2(MavlinkTransmitter::new( + memory, + peripherals::Usart2TxDma::new(rcc, uart), + )), + health: TxHealth::default(), + pending_faults: 0, + }) } } impl FrameSink for F722MavlinkTransmitter { - fn send(&mut self, class: FrameClass, frame: MavlinkFrame) { - match self { - Self::Usart1(transmitter) => { - let _ = transmitter.send_frame(class, frame); + fn send(&mut self, class: FrameClass, frame: MavlinkFrame) -> FrameAdmission { + let (admission, start_fault) = match &mut self.inner { + F722MavlinkTransport::Usart1(transmitter) => { + let outcome = transmitter.admit_and_start(class, frame); + ( + outcome.admission, + outcome.start.err().map(|fault| fault.kind()), + ) } - Self::Usart2(transmitter) => { - let _ = transmitter.send_frame(class, frame); + F722MavlinkTransport::Usart2(transmitter) => { + let outcome = transmitter.admit_and_start(class, frame); + ( + outcome.admission, + outcome.start.err().map(|fault| fault.kind()), + ) } + }; + if !matches!( + admission, + kaptan_mavlink::transmitter::EnqueueResult::Enqueued + ) { + self.health.admission_loss_count = self.health.admission_loss_count.saturating_add(1); + } + if let Some(fault) = start_fault { + self.record_fault(fault); + } + FrameAdmission::Retained(admission) + } + + fn on_dma_complete(&mut self, completion: TxCompletion) -> DmaCompletion { + let start_fault = match &mut self.inner { + F722MavlinkTransport::Usart1(transmitter) => transmitter + .on_dma_complete(completion) + .err() + .map(|error| error.kind()), + F722MavlinkTransport::Usart2(transmitter) => transmitter + .on_dma_complete(completion) + .err() + .map(|error| error.kind()), + }; + let terminal = matches!( + completion, + TxCompletion::TransferFault | TxCompletion::Halted + ); + if let Some(fault) = start_fault { + self.record_fault(fault); + } + if terminal { + self.record_fault(TransmitFaultKind::Terminal); + } + if start_fault.is_some() || terminal { + DmaCompletion::Fault + } else { + DmaCompletion::Complete } } - fn on_dma_complete(&mut self, completion: TxCompletion) { - match self { - Self::Usart1(transmitter) => { - let _ = transmitter.on_dma_complete(completion); + fn health(&self) -> TxHealth { + self.health + } + + fn take_transport_fault(&mut self) -> Option { + let (bit, fault) = if self.pending_faults & 1 != 0 { + (1, TransmitFaultKind::Memory) + } else if self.pending_faults & 2 != 0 { + (2, TransmitFaultKind::Engine) + } else if self.pending_faults & 4 != 0 { + (4, TransmitFaultKind::Terminal) + } else { + return None; + }; + self.pending_faults &= !bit; + Some(fault) + } +} + +impl F722MavlinkTransmitter { + fn record_fault(&mut self, fault: TransmitFaultKind) { + self.health.dma_fault_count = self.health.dma_fault_count.saturating_add(1); + self.pending_faults |= match fault { + TransmitFaultKind::Memory => { + self.health.memory_fault_count = self.health.memory_fault_count.saturating_add(1); + 1 } - Self::Usart2(transmitter) => { - let _ = transmitter.on_dma_complete(completion); + TransmitFaultKind::Engine => { + self.health.engine_fault_count = self.health.engine_fault_count.saturating_add(1); + 2 } - } + TransmitFaultKind::Terminal => { + self.health.terminal_fault_count = + self.health.terminal_fault_count.saturating_add(1); + 4 + } + }; } } diff --git a/platforms/stm32f7/src/f722/mod.rs b/platforms/stm32f7/src/f722/mod.rs index 3ab5b25..8f38996 100644 --- a/platforms/stm32f7/src/f722/mod.rs +++ b/platforms/stm32f7/src/f722/mod.rs @@ -46,6 +46,7 @@ pub(crate) enum VerticalRoute { pub(crate) use board::{ F722InitResources, F722LidarState, F722MavlinkState, F722Mtf01, F722Mtf01Uart, F722ServiceTimer, on_uart4_interrupt, process_imu, record_mavlink_completion, + validate_board_routes, }; pub(crate) use interrupts::{ acknowledge_exti3, acknowledge_exti4, acknowledge_imu_rx_dma1, acknowledge_imu_rx_dma2, @@ -68,8 +69,7 @@ pub fn initialize( // validation, clocks, sensor routes, interrupts, or UART setup. It keeps // the former four motor pins low even for malformed configuration. motor_inhibit::inhibit_motor_pins(&device.RCC, &device.GPIOB); - config.validate().map_err(BoardError::InvalidConfig)?; - board::validate_vertical_route(config, routes)?; + crate::validate_board_config(config, routes)?; board::initialize(core, device, config, routes) } @@ -94,6 +94,7 @@ fn registered_routes() -> Result { // This is one source-level task graph. Its compile-time bindings are the // platform-owned finite interrupt superset, independent of the board crate. +#[cfg(target_os = "none")] firmware::f722_flight_application!( crate::f722::registered_routes, crate::f722::registered_config diff --git a/platforms/stm32f7/src/f722/peripherals/clock.rs b/platforms/stm32f7/src/f722/peripherals/clock.rs index 47b6f23..21b4978 100644 --- a/platforms/stm32f7/src/f722/peripherals/clock.rs +++ b/platforms/stm32f7/src/f722/peripherals/clock.rs @@ -1,11 +1,12 @@ //! Deterministic STM32F722 PLL clock-source selection. +use kaptan_log::KaptanError; use stm32f7::stm32f722; const CLOCK_SWITCH_POLL_LIMIT: u16 = 1_024; /// Failure to establish the 216 MHz PLL clock profile. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum ClockProfileError { HsiNotReady, SystemClockNotHsi, diff --git a/platforms/stm32f7/src/f722/peripherals/i2c1.rs b/platforms/stm32f7/src/f722/peripherals/i2c1.rs index 4564ccd..b1fb87d 100644 --- a/platforms/stm32f7/src/f722/peripherals/i2c1.rs +++ b/platforms/stm32f7/src/f722/peripherals/i2c1.rs @@ -1,6 +1,7 @@ //! STM32F722 I²C1 interrupt-driven controller. use kaptan_i2c::{I2cBus, I2cCompletion}; +use kaptan_log::KaptanError; use stm32f7::stm32f722; const MAX_BYTES: usize = 255; @@ -20,7 +21,7 @@ const ICR_TERMINAL: u32 = ICR_NACKCF | ICR_STOPCF | ICR_ERRORS; const ICR_FAULTS: u32 = ICR_NACKCF | ICR_ERRORS; const CR2_STOP: u32 = 1 << 14; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum I2c1Error { InvalidLength, InvalidAddress, diff --git a/platforms/stm32f7/src/f722/peripherals/spi1.rs b/platforms/stm32f7/src/f722/peripherals/spi1.rs index bf5002e..86f82dc 100644 --- a/platforms/stm32f7/src/f722/peripherals/spi1.rs +++ b/platforms/stm32f7/src/f722/peripherals/spi1.rs @@ -5,13 +5,14 @@ //! is generated after the last serial frame; transmit completion can occur //! while the final serial clock is still in progress. +use kaptan_log::KaptanError; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; const MAX_TRANSFER_LENGTH: usize = u16::MAX as usize; /// Failure while submitting one SPI1 DMA transaction. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum Spi1Error { /// The transaction is malformed or exceeds the DMA counter range. InvalidLength, diff --git a/platforms/stm32f7/src/f722/peripherals/spi3.rs b/platforms/stm32f7/src/f722/peripherals/spi3.rs index 4f28b8d..35a599d 100644 --- a/platforms/stm32f7/src/f722/peripherals/spi3.rs +++ b/platforms/stm32f7/src/f722/peripherals/spi3.rs @@ -5,13 +5,14 @@ //! is generated after the last serial frame; transmit completion can occur //! while the final serial clock is still in progress. +use kaptan_log::KaptanError; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; const MAX_TRANSFER_LENGTH: usize = u16::MAX as usize; /// Failure while submitting one SPI3 DMA transaction. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum Spi3Error { /// The transaction is malformed or exceeds the DMA counter range. InvalidLength, diff --git a/platforms/stm32f7/src/f722/peripherals/time.rs b/platforms/stm32f7/src/f722/peripherals/time.rs index dfdda6e..0f75244 100644 --- a/platforms/stm32f7/src/f722/peripherals/time.rs +++ b/platforms/stm32f7/src/f722/peripherals/time.rs @@ -1,6 +1,7 @@ //! DWT-backed monotonic microsecond clock for STM32F722. use cortex_m::peripheral::{DCB, DWT}; +use kaptan_log::KaptanError; use kaptan_time::{MonotonicClock, Timestamp}; /// A DWT cycle counter extended into monotonic microseconds. @@ -15,7 +16,7 @@ pub struct DwtMicrosecondClock { } /// Invalid fixed clock configuration for the DWT timestamp source. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum DwtClockStartError { /// A zero-megahertz core clock cannot produce microsecond timestamps. ZeroFrequency, diff --git a/platforms/stm32f7/src/f722/peripherals/usart.rs b/platforms/stm32f7/src/f722/peripherals/usart.rs index 582225e..5471a5a 100644 --- a/platforms/stm32f7/src/f722/peripherals/usart.rs +++ b/platforms/stm32f7/src/f722/peripherals/usart.rs @@ -1,9 +1,10 @@ //! STM32F722 USART routes used by supported boards. +use kaptan_log::KaptanError; use stm32f7::stm32f722; /// UART baud-rate configuration error. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BaudRateError { /// Zero is not a valid baud rate. Zero, diff --git a/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs b/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs index 5bb4b47..5029a17 100644 --- a/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs +++ b/platforms/stm32f7/src/f722/peripherals/usart1_tx_dma2_stream7.rs @@ -5,6 +5,7 @@ //! cannot be selected at construction. use kaptan_dma::{DmaReadableBuffer, DmaTxEngine, TxCompletion}; +use kaptan_log::KaptanError; use stm32f7::stm32f722; use super::Usart1Tx; @@ -20,7 +21,7 @@ pub struct Usart1TxDma { } /// Failure to start one DMA frame transfer. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum StartError { /// DMA cannot transfer an empty buffer or one exceeding the NDTR limit. InvalidBufferLength, diff --git a/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs b/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs index e14abd1..0821e9b 100644 --- a/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs +++ b/platforms/stm32f7/src/f722/peripherals/usart2_tx_dma1_stream6.rs @@ -5,6 +5,7 @@ //! cannot be selected at construction. use kaptan_dma::{DmaReadableBuffer, DmaTxEngine, TxCompletion}; +use kaptan_log::KaptanError; use stm32f7::stm32f722; use super::Usart2Tx; @@ -15,7 +16,7 @@ pub struct Usart2TxDma { } /// Failure to start one DMA frame transfer on USART2. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum Usart2TxDmaStartError { /// DMA cannot transfer an empty buffer or one exceeding the NDTR limit. InvalidBufferLength, diff --git a/platforms/stm32f7/src/f722/sensors.rs b/platforms/stm32f7/src/f722/sensors.rs index 43133a5..f85adcc 100644 --- a/platforms/stm32f7/src/f722/sensors.rs +++ b/platforms/stm32f7/src/f722/sensors.rs @@ -1,6 +1,7 @@ //! Platform-owned F722 sensor-service assembly. use super::peripherals::{DwtMicrosecondClock, Spi1, Spi3}; +use kaptan_board::ImuMount; use kaptan_drivers::{ icm42688::{Icm42688, Icm42688Service, Icm42688ServiceEvent}, mpu6000::{ @@ -8,18 +9,15 @@ use kaptan_drivers::{ Mpu6000Service, Mpu6000ServiceEvent, }, }; +use kaptan_log::{ErrorReport, StaticErrorMessage}; use kaptan_sensor::imu::ImuSnapshot; use kaptan_spi::SpiCompletion; use stm32f7::stm32f722; use super::routes::{Icm42688Spi3Route, Mpu6000Spi1Route}; -// TODO(configuration): this is an immutable board-development value, not an -// airframe calibration. A ground configuration flow must stage the complete -// IMU profile (device settings, mounting transform, bias/scale calibration, -// noise model, source/revision) while output is inhibited, then reset the -// estimator/controller/allocation state before activation. Never apply one -// register/calibration field to a live flight runtime. +// This is fixed driver setup for the selected board route. It is distinct from +// the immutable board mount and from a future safety-gated calibration flow. const MPU6000_CONFIG: Mpu6000Config = Mpu6000Config { accelerometer: AccelerometerRange::PlusMinus4G, gyroscope: GyroscopeRange::PlusMinus500DegreesPerSecond, @@ -33,7 +31,7 @@ pub(crate) enum ImuEvent { Idle, Initialized, Sample(ImuSnapshot), - Fault, + Fault(&'static StaticErrorMessage), } /// Opaque owner of the selected F722 IMU service. @@ -43,6 +41,7 @@ pub(crate) enum ImuEvent { /// platform, preserving the board-facing abstraction boundary. pub struct F722Imu { inner: F722ImuKind, + mount: ImuMount, } /// The two concrete IMU services supported by the F722 platform. @@ -58,6 +57,7 @@ impl F722Imu { dma: stm32f722::DMA2, chip_select: stm32f722::GPIOB, clock: DwtMicrosecondClock, + mount: ImuMount, ) -> Self { Self { inner: F722ImuKind::Mpu6000(Mpu6000Service::new( @@ -67,6 +67,7 @@ impl F722Imu { MPU6000_CONFIG, ), )), + mount, } } @@ -76,6 +77,7 @@ impl F722Imu { dma: stm32f722::DMA1, chip_select: &stm32f722::GPIOA, clock: DwtMicrosecondClock, + mount: ImuMount, ) -> Self { Self { inner: F722ImuKind::Icm42688(Icm42688Service::new( @@ -86,87 +88,104 @@ impl F722Imu { chip_select, )), )), + mount, } } pub(crate) fn begin(&mut self) -> ImuEvent { - let ok = match &mut self.inner { - F722ImuKind::Mpu6000(imu) => imu.begin().is_ok(), - F722ImuKind::Icm42688(imu) => imu.begin().is_ok(), - }; - if ok { ImuEvent::Idle } else { ImuEvent::Fault } + match &mut self.inner { + F722ImuKind::Mpu6000(imu) => match imu.begin() { + Ok(()) => ImuEvent::Idle, + Err(error) => ImuEvent::Fault(error.error_message()), + }, + F722ImuKind::Icm42688(imu) => match imu.begin() { + Ok(()) => ImuEvent::Idle, + Err(error) => ImuEvent::Fault(error.error_message()), + }, + } } pub(crate) fn on_periodic_tick(&mut self) -> ImuEvent { match &mut self.inner { F722ImuKind::Mpu6000(imu) => mpu_event(imu.on_periodic_tick()), - F722ImuKind::Icm42688(imu) => icm_event(imu.on_periodic_tick()), + F722ImuKind::Icm42688(imu) => icm_event(imu.on_periodic_tick(), self.mount), } } pub(crate) fn on_data_ready(&mut self) -> ImuEvent { match &mut self.inner { F722ImuKind::Mpu6000(imu) => mpu_event(imu.on_data_ready()), - F722ImuKind::Icm42688(imu) => icm_event(imu.on_data_ready()), + F722ImuKind::Icm42688(imu) => icm_event(imu.on_data_ready(), self.mount), } } pub(crate) fn on_transfer_complete(&mut self, completion: SpiCompletion) -> ImuEvent { match &mut self.inner { F722ImuKind::Mpu6000(imu) => mpu_event(imu.on_transfer_complete(completion)), - F722ImuKind::Icm42688(imu) => icm_event(imu.on_transfer_complete(completion)), + F722ImuKind::Icm42688(imu) => { + icm_event(imu.on_transfer_complete(completion), self.mount) + } } } } -fn mpu_event(event: Mpu6000ServiceEvent) -> ImuEvent { +fn mpu_event(event: Mpu6000ServiceEvent) -> ImuEvent { match event { Mpu6000ServiceEvent::Idle => ImuEvent::Idle, Mpu6000ServiceEvent::Initialized => ImuEvent::Initialized, Mpu6000ServiceEvent::Sample(sample) => ImuEvent::Sample(sample), - Mpu6000ServiceEvent::Fault(_) => ImuEvent::Fault, + Mpu6000ServiceEvent::Fault(error) => ImuEvent::Fault(error.error_message()), } } -fn icm_event(event: Icm42688ServiceEvent) -> ImuEvent { +fn icm_event(event: Icm42688ServiceEvent, mount: ImuMount) -> ImuEvent { match event { Icm42688ServiceEvent::Idle => ImuEvent::Idle, Icm42688ServiceEvent::Initialized => ImuEvent::Initialized, - Icm42688ServiceEvent::Sample(sample) => ImuEvent::Sample(align_icm42688(sample)), - Icm42688ServiceEvent::Fault(_) => ImuEvent::Fault, + Icm42688ServiceEvent::Sample(sample) => ImuEvent::Sample(align_mount(sample, mount)), + Icm42688ServiceEvent::Fault(error) => ImuEvent::Fault(error.error_message()), } } -/// Aligns the development GEP-F722-HD V2 ICM-42688-P yaw-180° mounting to the -/// flight-controller body frame. The MPU6000 profile is already aligned. +/// Applies the board-declared fixed IMU-to-body transform. /// -/// TODO(configuration): replace this build-time transform only as part of the -/// same atomic sensor-profile activation described at `MPU6000_CONFIG`; it is -/// not a user-adjustable live setting. -fn align_icm42688(mut sample: ImuSnapshot) -> ImuSnapshot { - sample.acceleration_milligravity[0] = sample.acceleration_milligravity[0].saturating_neg(); - sample.acceleration_milligravity[1] = sample.acceleration_milligravity[1].saturating_neg(); - sample.angular_rate_milliradians_per_second[0] = - sample.angular_rate_milliradians_per_second[0].saturating_neg(); - sample.angular_rate_milliradians_per_second[1] = - sample.angular_rate_milliradians_per_second[1].saturating_neg(); - sample +/// This is immutable board metadata validated against the selected electrical +/// route before initialization. It is not an airframe calibration setting. +fn align_mount(mut sample: ImuSnapshot, mount: ImuMount) -> ImuSnapshot { + match mount { + ImuMount::BodyAligned => sample, + ImuMount::Yaw180 => { + sample.acceleration_milligravity[0] = + sample.acceleration_milligravity[0].saturating_neg(); + sample.acceleration_milligravity[1] = + sample.acceleration_milligravity[1].saturating_neg(); + sample.angular_rate_milliradians_per_second[0] = + sample.angular_rate_milliradians_per_second[0].saturating_neg(); + sample.angular_rate_milliradians_per_second[1] = + sample.angular_rate_milliradians_per_second[1].saturating_neg(); + sample + } + } } #[cfg(test)] mod tests { - use super::align_icm42688; + use super::align_mount; + use kaptan_board::ImuMount; use kaptan_sensor::imu::ImuSnapshot; use kaptan_time::Timestamp; #[test] fn geprc_profile_applies_its_documented_yaw_alignment() { - let sample = align_icm42688(ImuSnapshot::new( - Timestamp::from_microseconds(0), - [10, 20, 30], - [40, 50, 60], - 0, - )); + let sample = align_mount( + ImuSnapshot::new( + Timestamp::from_microseconds(0), + [10, 20, 30], + [40, 50, 60], + 0, + ), + ImuMount::Yaw180, + ); assert_eq!(sample.acceleration_milligravity, [-10, -20, 30]); assert_eq!(sample.angular_rate_milliradians_per_second, [-40, -50, 60]); } diff --git a/platforms/stm32f7/src/lib.rs b/platforms/stm32f7/src/lib.rs index e3e4583..ff4d69e 100644 --- a/platforms/stm32f7/src/lib.rs +++ b/platforms/stm32f7/src/lib.rs @@ -10,40 +10,67 @@ #![no_std] use core::marker::PhantomData; +use kaptan_log::KaptanError; pub use kaptan_board::BoardConfig; -use kaptan_board::BoardConfigError; +use kaptan_board::{BoardConfigError, ImuDevice, ImuMount, MavlinkPort}; +#[cfg_attr( + not(target_os = "none"), + allow(dead_code, unused_imports, unused_macros) +)] mod f722; /// Dependencies used only while the platform expands its private RTIC macro. /// They stay crate-private: board binaries never name them. +#[cfg_attr(not(target_os = "none"), allow(unused_imports))] pub(crate) mod __platform_internal { pub(crate) use cortex_m as cm; pub(crate) use cortex_m_rt as cmrt; pub(crate) use kaptan_dma as dma; - #[cfg(not(test))] + #[cfg(target_os = "none")] pub(crate) use panic_halt as panic; pub(crate) use rtic as rtic_app; pub(crate) use stm32f7 as pac; - #[cfg(test)] + #[cfg(not(target_os = "none"))] pub(crate) mod panic {} } /// Construction or validation failure for a declarative F722 board. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)] pub enum BoardError { /// Portable board metadata is self-inconsistent. InvalidConfig(BoardConfigError), /// Metadata and the selected vertical-sensor route disagree. VerticalSensorCapabilityMismatch, + /// Metadata and the selected immutable IMU route disagree. + ImuRouteMismatch, + /// Metadata and the selected immutable MAVLink route disagree. + MavlinkRouteMismatch, + /// Metadata and the selected immutable IMU mounting transform disagree. + ImuMountMismatch, /// The declaration requests a service-timer frequency not implemented by /// the selected F722 platform profile. UnsupportedServiceFrequency, + /// The selected baud cannot carry the fixed telemetry/log schedule. + TelemetryLinkOvercommitted, /// The platform could not establish a clock, peripheral, or DMA owner. Initialization, } +/// Validates immutable board metadata against a selected F722 route graph. +/// +/// This is pure validation: it does not claim a pin, clock, interrupt, DMA +/// stream, or peripheral. The runtime invokes it immediately after the motor +/// inhibit boundary and before every other hardware action. +pub fn validate_board_config( + config: BoardConfig, + routes: F722BoardRoutes, +) -> Result<(), BoardError> { + config.validate().map_err(BoardError::InvalidConfig)?; + f722::validate_board_routes(config, routes) +} + /// MPU6000 component selector. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Mpu6000; @@ -235,9 +262,31 @@ pub struct F722BoardRoutes { } impl F722BoardRoutes { + #[cfg_attr(not(target_os = "none"), allow(dead_code))] pub(crate) const fn profile(self) -> F722Profile { self.profile } + + pub(crate) const fn imu(self) -> ImuDevice { + match self.profile { + F722Profile::Mpu6000Spi1Usart1 => ImuDevice::Mpu6000, + F722Profile::Icm42688Spi3Usart2 => ImuDevice::Icm42688, + } + } + + pub(crate) const fn imu_mount(self) -> ImuMount { + match self.profile { + F722Profile::Mpu6000Spi1Usart1 => ImuMount::BodyAligned, + F722Profile::Icm42688Spi3Usart2 => ImuMount::Yaw180, + } + } + + pub(crate) const fn mavlink_port(self) -> MavlinkPort { + match self.profile { + F722Profile::Mpu6000Spi1Usart1 => MavlinkPort::Serial1, + F722Profile::Icm42688Spi3Usart2 => MavlinkPort::Serial2, + } + } } macro_rules! impl_mpu6000_routes { @@ -303,17 +352,6 @@ impl_icm42688_routes!(LidarLiteV3I2c1, f722::VerticalRoute::LidarLiteV3); /// .build(); /// ``` /// -/// ```compile_fail -/// use platform_stm32f7::{ -/// F7BoardBuilder, Icm42688, Mavlink, Pc3, VerticalSensor, -/// }; -/// -/// let _ = F7BoardBuilder::new() -/// .imu(Icm42688::on_spi3().with_drdy(Pc3)) -/// .mavlink(Mavlink::on_usart2()) -/// .vertical_sensor(VerticalSensor::none()) -/// .build(); // MPU6000/USART1 is the only other supported profile. -/// ``` /// Contract implemented by a declarative STM32F722 board crate. pub trait F7Board { /// Portable identity, capabilities and outbound data-channel policy. @@ -366,30 +404,3 @@ macro_rules! flight_firmware { /// Link anchor for the platform-owned F722 board-I/O application. #[doc(hidden)] pub fn firmware_anchor() {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn route_descriptors_select_the_mpu6000_profile() { - 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!(routes.profile(), F722Profile::Mpu6000Spi1Usart1); - } - - #[test] - fn route_descriptors_select_the_icm42688_profile() { - let routes = F7BoardBuilder::new() - .imu(Icm42688::on_spi3().with_drdy(Pc3)) - .mavlink(Mavlink::on_usart2()) - .vertical_sensor(VerticalSensor::none()) - .build() - .unwrap(); - assert_eq!(routes.profile(), F722Profile::Icm42688Spi3Usart2); - } -} diff --git a/platforms/stm32f7/tests/platform_safety_boundary.rs b/platforms/stm32f7/tests/platform_safety_boundary.rs index c7a6ae5..9e553ee 100644 --- a/platforms/stm32f7/tests/platform_safety_boundary.rs +++ b/platforms/stm32f7/tests/platform_safety_boundary.rs @@ -1,5 +1,16 @@ use std::{fs, path::PathBuf}; +use kaptan_board::{ + BoardConfig, BoardProfile, DataChannelRates, ImuDevice, ImuMount, MavlinkEndpoint, MavlinkPort, + TelemetryProfile, VerticalSensingKind, +}; +use kaptan_log::{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") @@ -12,7 +23,7 @@ fn former_motor_pins_are_inhibited_before_all_other_initialization() { .find("motor_inhibit::inhibit_motor_pins") .expect("F722 initialization must claim the former motor pins first"); let validation = module - .find("config.validate()") + .find("validate_board_config") .expect("F722 initialization must validate its board configuration"); let board_initialization = module .find("board::initialize") @@ -55,6 +66,87 @@ fn former_motor_pins_are_inhibited_before_all_other_initialization() { } } +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 = [ @@ -117,3 +209,38 @@ fn telemetry_has_no_inbound_command_authority() { "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::new(Severity::Error, LogFlags::NONE, text); + 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(), 174); + assert_eq!( + kaptan_mavlink::message::kaptan::KAPTAN_ERROR_DATA::ENCODED_LEN, + 162 + ); + assert_eq!( + 62 * 48 + 10 * error_frame.raw_bytes().len() + 5 * log_frame.raw_bytes().len(), + 5_591 + ); + 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,591 bytes/s")); +}