diff --git a/boards/geprc-f722-v2/src/board.rs b/boards/geprc-f722-v2/src/board.rs index 5d9bbd5..8fac9d3 100644 --- a/boards/geprc-f722-v2/src/board.rs +++ b/boards/geprc-f722-v2/src/board.rs @@ -1,9 +1,11 @@ //! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack. -use kaptan_board::{BoardConfig, DataChannelRates, MavlinkEndpoint, VerticalSensingKind}; +use kaptan_board::{ + BoardConfig, BoardProfile, DataChannelRates, MavlinkEndpoint, TelemetryProfile, + VerticalSensingKind, +}; use platform_stm32f7::{ - BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, MotorOutputs, Pc3, - VerticalSensor, + BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor, }; /// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack. @@ -16,20 +18,21 @@ impl F7Board for GeprcF722V2 { system_id: 1, component_id: 1, }, - vertical_sensing: VerticalSensingKind::None, - data_channel: DataChannelRates { - service_hz: 100, - raw_imu_hz: 20, - range_hz: None, - optical_flow_hz: None, - heartbeat_hz: 2, - }, + profile: BoardProfile::TelemetryOnly(TelemetryProfile { + vertical_sensing: VerticalSensingKind::None, + data_channel: DataChannelRates { + service_hz: 100, + raw_imu_hz: 20, + range_hz: None, + optical_flow_hz: None, + heartbeat_hz: 2, + }, + }), }; fn build(platform: F7BoardBuilder) -> Result { platform .imu(Icm42688::on_spi3().with_drdy(Pc3)) - .motors(MotorOutputs::geprc_tim3_tim5().dshot600()) .mavlink(Mavlink::on_usart2()) .vertical_sensor(VerticalSensor::none()) .build() diff --git a/boards/kakute-f7-mini-v3/src/board.rs b/boards/kakute-f7-mini-v3/src/board.rs index 6779205..a334932 100644 --- a/boards/kakute-f7-mini-v3/src/board.rs +++ b/boards/kakute-f7-mini-v3/src/board.rs @@ -4,61 +4,43 @@ //! code, interrupt names, DMA handling, or task scheduling. Those concerns //! belong to the STM32F7 platform crate. -use kaptan_board::{BoardConfig, DataChannelRates, MavlinkEndpoint, VerticalSensingKind}; +use kaptan_board::{ + BoardConfig, BoardProfile, DataChannelRates, MavlinkEndpoint, TelemetryProfile, + VerticalSensingKind, +}; use platform_stm32f7::{ - BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, MotorOutputs, Mpu6000, Pa4, - VerticalSensor, + BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor, }; /// Holybro Kakute F7 Mini V3. pub struct KakuteF7MiniV3; -#[cfg(not(feature = "lidar-lite-v3"))] const CONFIG: BoardConfig = BoardConfig { name: "Kakute F7 Mini V3", mavlink: MavlinkEndpoint { system_id: 1, component_id: 1, }, - 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, - }, -}; - -#[cfg(feature = "lidar-lite-v3")] -const CONFIG: BoardConfig = BoardConfig { - name: "Kakute F7 Mini V3", - mavlink: MavlinkEndpoint { - system_id: 1, - component_id: 1, - }, - vertical_sensing: VerticalSensingKind::Range, - data_channel: DataChannelRates { - service_hz: 100, - raw_imu_hz: 20, - range_hz: Some(20), - optical_flow_hz: None, - heartbeat_hz: 2, - }, + 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, + }, + }), }; impl F7Board for KakuteF7MiniV3 { const CONFIG: BoardConfig = CONFIG; fn build(platform: F7BoardBuilder) -> Result { - let platform = platform + platform .imu(Mpu6000::on_spi1().with_drdy(Pa4)) - .motors(MotorOutputs::kakute_tim3_tim5().dshot600()) - .mavlink(Mavlink::on_usart1()); - #[cfg(feature = "lidar-lite-v3")] - let platform = platform.vertical_sensor(VerticalSensor::lidar_lite_v3_on_i2c1()); - #[cfg(not(feature = "lidar-lite-v3"))] - let platform = platform.vertical_sensor(VerticalSensor::mtf01_on_uart4()); - platform.build() + .mavlink(Mavlink::on_usart1()) + .vertical_sensor(VerticalSensor::mtf01_on_uart4()) + .build() } } diff --git a/boards/kakute-f7-mini-v3/src/main.rs b/boards/kakute-f7-mini-v3/src/main.rs index 9301ef2..8a69580 100644 --- a/boards/kakute-f7-mini-v3/src/main.rs +++ b/boards/kakute-f7-mini-v3/src/main.rs @@ -2,5 +2,4 @@ #![no_main] mod board; - platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3); diff --git a/libs/kaptan-board/src/lib.rs b/libs/kaptan-board/src/lib.rs index 5406f9e..a6fab9b 100644 --- a/libs/kaptan-board/src/lib.rs +++ b/libs/kaptan-board/src/lib.rs @@ -1,8 +1,9 @@ //! Portable board-support contract for Kaptan. //! //! This crate describes logical board identity, sensing capability and -//! outbound data-channel policy. It intentionally has no dependency on an MCU family, -//! peripheral access crate, RTIC, or a runtime framework. +//! outbound data-channel policy. It intentionally has no dependency on an MCU +//! family, peripheral access crate, RTIC, or a runtime framework. + #![no_std] /// The vertical-sensing data a board support package makes available. @@ -41,26 +42,75 @@ pub struct DataChannelRates { pub heartbeat_hz: usize, } -/// Static board identity, capability, and data-channel configuration. +/// Telemetry-only physical-sensor and outbound-data policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TelemetryProfile { + /// Supported physical vertical-sensing capability. + pub vertical_sensing: VerticalSensingKind, + /// Data-channel timer and publication rates. + pub data_channel: DataChannelRates, +} + +/// Exclusive build-time image selection. +/// +/// `ExternalControlOnly` remains representable for an explicit validation +/// rejection rather than silently falling back to telemetry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BoardProfile { + /// Physical-sensor telemetry image. + TelemetryOnly(TelemetryProfile), + /// Reserved and deliberately unimplemented production authority profile. + ExternalControlOnly, +} + +/// Static board identity and exclusive build-time image configuration. /// /// The selected platform trait determines the MCU family. Keeping a free-form /// MCU string here would duplicate that type-level fact without validating it. +/// +/// TODO(configuration): board images currently receive this complete profile +/// at build time. Ground-station configuration must stage and validate a +/// whole profile (identity, sensor topology, link rates, actuator profile, +/// and calibration revision) before it is persisted. It must never apply +/// independent field updates or permit an active output while a profile is +/// staged, committed, or restored after power loss. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct BoardConfig { /// Stable board name used in build and diagnostic tooling. pub name: &'static str, /// MAVLink endpoint identity. pub mavlink: MavlinkEndpoint, - /// Supported vertical-sensing capability. - pub vertical_sensing: VerticalSensingKind, - /// Data-channel timer and publication rates. - pub data_channel: DataChannelRates, + /// Exactly one immutable image profile. + pub profile: BoardProfile, } impl BoardConfig { - /// Validates rate bounds and the one-frame-per-service-tick budget. + /// Returns physical telemetry configuration only for a telemetry image. + pub const fn telemetry(self) -> Option { + match self.profile { + BoardProfile::TelemetryOnly(profile) => Some(profile), + BoardProfile::ExternalControlOnly => None, + } + } + + /// Validates the selected exclusive profile. pub const fn validate(self) -> Result<(), BoardConfigError> { - let rates = self.data_channel; + match self.profile { + BoardProfile::TelemetryOnly(profile) => Self::validate_telemetry(profile), + BoardProfile::ExternalControlOnly => { + // TODO(flight-config): replace this hard rejection only with a + // complete runtime and atomic configuration activation. Required + // inputs are vehicle geometry, calibrated sensors, actuator + // limits, authenticated MAVLink-v2 authority, persistent replay + // state, estimator/controller/allocator readiness, and a + // verified output watchdog. + Err(BoardConfigError::UnsupportedFlightProfile) + } + } + } + + const fn validate_telemetry(profile: TelemetryProfile) -> Result<(), BoardConfigError> { + let rates = profile.data_channel; if rates.service_hz == 0 { return Err(BoardConfigError::ZeroServiceFrequency); } @@ -96,24 +146,29 @@ impl BoardConfig { return Err(BoardConfigError::DataChannelOvercommitted); } - match self.vertical_sensing { + let vertical_schedule_is_valid = match profile.vertical_sensing { VerticalSensingKind::None if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() => { - Ok(()) + true } VerticalSensingKind::Range if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() => { - Ok(()) + true } VerticalSensingKind::RangeAndOpticalFlow if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() => { - Ok(()) + true } - _ => Err(BoardConfigError::CapabilityDataChannelMismatch), + _ => false, + }; + if !vertical_schedule_is_valid { + return Err(BoardConfigError::CapabilityDataChannelMismatch); } + + Ok(()) } } @@ -130,12 +185,15 @@ pub enum BoardConfigError { DataChannelOvercommitted, /// Declared vertical sensing and published streams disagree. CapabilityDataChannelMismatch, + /// The selected operation profile has no complete runtime implementation. + UnsupportedFlightProfile, } #[cfg(test)] mod tests { use super::{ - BoardConfig, BoardConfigError, DataChannelRates, MavlinkEndpoint, VerticalSensingKind, + BoardConfig, BoardConfigError, BoardProfile, DataChannelRates, MavlinkEndpoint, + TelemetryProfile, VerticalSensingKind, }; const BASE: BoardConfig = BoardConfig { @@ -144,14 +202,16 @@ mod tests { system_id: 1, component_id: 1, }, - 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, - }, + 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, + }, + }), }; #[test] @@ -162,7 +222,10 @@ mod tests { #[test] fn rejects_streams_that_exceed_the_service_budget() { let mut config = BASE; - config.data_channel.optical_flow_hz = Some(80); + let BoardProfile::TelemetryOnly(profile) = &mut config.profile else { + unreachable!(); + }; + profile.data_channel.optical_flow_hz = Some(80); assert_eq!( config.validate(), @@ -173,11 +236,25 @@ mod tests { #[test] fn rejects_capability_and_data_channel_mismatch() { let mut config = BASE; - config.vertical_sensing = VerticalSensingKind::Range; + let BoardProfile::TelemetryOnly(profile) = &mut config.profile else { + unreachable!(); + }; + profile.vertical_sensing = VerticalSensingKind::Range; assert_eq!( config.validate(), Err(BoardConfigError::CapabilityDataChannelMismatch) ); } + + #[test] + fn rejects_external_control_until_its_runtime_exists() { + let mut config = BASE; + config.profile = BoardProfile::ExternalControlOnly; + + assert_eq!( + config.validate(), + Err(BoardConfigError::UnsupportedFlightProfile) + ); + } } diff --git a/libs/kaptan-data-channel/src/lib.rs b/libs/kaptan-data-channel/src/lib.rs index 754fede..ab0b83c 100644 --- a/libs/kaptan-data-channel/src/lib.rs +++ b/libs/kaptan-data-channel/src/lib.rs @@ -2,7 +2,8 @@ //! //! This crate owns fixed-rate scheduling, mailbox consumption, vertical-data //! freshness, MAVLink encoding, and bounded queue classification. A platform -//! supplies only mailbox references, health/arming state, and a transport sink. +//! supplies only mailbox references, a declared MAVLink state, arming state, +//! and a transport sink. #![no_std] @@ -57,6 +58,8 @@ pub struct VerticalDataConfig { /// Data-channel construction failure. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DataChannelError { + /// Rejected profiles must not construct a physical telemetry channel. + NotTelemetryProfile, /// A configured channel must be due at least once per second. ZeroRate, /// A configured channel exceeds the service frequency. @@ -92,7 +95,10 @@ where vertical: VerticalDataConfig, sources: DataSources<'a>, ) -> Result { - let schedule = Schedule::from_rates(config.data_channel)?; + let telemetry = config + .telemetry() + .ok_or(DataChannelError::NotTelemetryProfile)?; + let schedule = Schedule::from_rates(telemetry.data_channel)?; Ok(Self { sink, encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id), @@ -114,7 +120,7 @@ where /// No schedule token escapes this method. It refreshes cached vertical /// observations, selects due data, encodes the MAVLink message, and admits /// it with the correct bounded-queue class. - pub fn on_service_tick(&mut self, imu_healthy: bool, motors_armed: bool) { + pub fn on_service_tick(&mut self, system_status: MavState, motors_armed: bool) { self.refresh_vertical_data(); match self.schedule.next_due() { @@ -148,14 +154,7 @@ where } Some(DueData::Heartbeat) => self.sink.send( FrameClass::Critical, - self.encoder.heartbeat( - if imu_healthy { - MavState::MAV_STATE_STANDBY - } else { - MavState::MAV_STATE_CRITICAL - }, - motors_armed, - ), + self.encoder.heartbeat(system_status, motors_armed), ), Some(DueData::Distance | DueData::OpticalFlow) | None => {} } @@ -281,7 +280,7 @@ mod tests { }; use kaptan_board::{BoardConfig, DataChannelRates, MavlinkEndpoint, VerticalSensingKind}; use kaptan_dma::TxCompletion; - use kaptan_mavlink::{MavlinkFrame, transmitter::FrameClass}; + use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass}; use kaptan_sensor::{ imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot, }; @@ -312,14 +311,16 @@ mod tests { system_id: 1, component_id: 1, }, - 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, - }, + profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::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, + }, + }), }; #[test] @@ -367,7 +368,7 @@ mod tests { 100, 100, )); - channel.on_service_tick(true, false); + channel.on_service_tick(MavState::MAV_STATE_STANDBY, false); } channel.on_dma_complete(TxCompletion::Complete);