//! 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. #![no_std] /// The vertical-sensing data a board support package makes available. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] pub enum VerticalSensingKind { /// No range or optical-flow data is supported. None, /// Range data is supported. Range, /// Coherent range and optical-flow data are supported. RangeAndOpticalFlow, } /// MAVLink endpoint identity selected by one board support package. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MavlinkEndpoint { /// MAVLink system identifier. pub system_id: u8, /// MAVLink component identifier. pub component_id: u8, } /// Fixed outbound data-channel rates driven by a platform service timer. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DataChannelRates { /// Frequency of the timer that calls the data-channel scheduler. pub service_hz: usize, /// Raw-IMU publication rate. pub raw_imu_hz: usize, /// Range publication rate. `None` means range is not published. pub range_hz: Option, /// Optical-flow publication rate. `None` means flow is not published. pub optical_flow_hz: Option, /// Link heartbeat publication rate. pub heartbeat_hz: usize, } /// 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, /// Exactly one immutable image profile. pub profile: BoardProfile, } impl BoardConfig { /// 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> { 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); } if rates.raw_imu_hz == 0 || rates.heartbeat_hz == 0 { return Err(BoardConfigError::MissingRequiredDataChannel); } if rates.raw_imu_hz > rates.service_hz || rates.heartbeat_hz > rates.service_hz { return Err(BoardConfigError::RateExceedsServiceFrequency); } let Some(mut scheduled_hz) = rates.raw_imu_hz.checked_add(rates.heartbeat_hz) else { return Err(BoardConfigError::DataChannelOvercommitted); }; if let Some(range_hz) = rates.range_hz { if range_hz == 0 || range_hz > rates.service_hz { return Err(BoardConfigError::RateExceedsServiceFrequency); } let Some(total) = scheduled_hz.checked_add(range_hz) else { return Err(BoardConfigError::DataChannelOvercommitted); }; scheduled_hz = total; } if let Some(flow_hz) = rates.optical_flow_hz { if flow_hz == 0 || flow_hz > rates.service_hz { return Err(BoardConfigError::RateExceedsServiceFrequency); } let Some(total) = scheduled_hz.checked_add(flow_hz) else { return Err(BoardConfigError::DataChannelOvercommitted); }; scheduled_hz = total; } if scheduled_hz > rates.service_hz { return Err(BoardConfigError::DataChannelOvercommitted); } let vertical_schedule_is_valid = match profile.vertical_sensing { VerticalSensingKind::None if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() => { true } VerticalSensingKind::Range if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() => { true } VerticalSensingKind::RangeAndOpticalFlow if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() => { true } _ => false, }; if !vertical_schedule_is_valid { return Err(BoardConfigError::CapabilityDataChannelMismatch); } Ok(()) } } /// Invalid board-support configuration. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BoardConfigError { /// A data-channel schedule needs a nonzero service frequency. ZeroServiceFrequency, /// Raw IMU and heartbeat are mandatory on every flight board. MissingRequiredDataChannel, /// A configured stream is faster than its service timer. RateExceedsServiceFrequency, /// The schedule requests more than one frame per service tick. 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, BoardProfile, DataChannelRates, MavlinkEndpoint, TelemetryProfile, VerticalSensingKind, }; const BASE: BoardConfig = BoardConfig { name: "test", 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, }, }), }; #[test] fn accepts_a_schedulable_range_and_flow_board() { assert_eq!(BASE.validate(), Ok(())); } #[test] fn rejects_streams_that_exceed_the_service_budget() { let mut config = BASE; let BoardProfile::TelemetryOnly(profile) = &mut config.profile else { unreachable!(); }; profile.data_channel.optical_flow_hz = Some(80); assert_eq!( config.validate(), Err(BoardConfigError::DataChannelOvercommitted) ); } #[test] fn rejects_capability_and_data_channel_mismatch() { let mut config = BASE; 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) ); } }