261 lines
9.3 KiB
Rust
261 lines
9.3 KiB
Rust
//! 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<usize>,
|
|
/// Optical-flow publication rate. `None` means flow is not published.
|
|
pub optical_flow_hz: Option<usize>,
|
|
/// 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<TelemetryProfile> {
|
|
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)
|
|
);
|
|
}
|
|
}
|