board: make telemetry profile selection explicit

This commit is contained in:
Onur Özkan committed 2026-08-09 13:57:06 +03:00
1 parent c44286bdcf
commit 01463fa878
5 files changed
+158 -96

No files matched your search

+15 -12
View File
@@ -1,9 +1,11 @@
//! Declarative support for the GEPRC TAKER F722 BLS 60A V2 stack. //! 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::{ use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, MotorOutputs, Pc3, BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Icm42688, Mavlink, Pc3, VerticalSensor,
VerticalSensor,
}; };
/// ICM-42688-P-based GEP-F722-HD V2 paired with the 60 A stack. /// 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, system_id: 1,
component_id: 1, component_id: 1,
}, },
vertical_sensing: VerticalSensingKind::None, profile: BoardProfile::TelemetryOnly(TelemetryProfile {
data_channel: DataChannelRates { vertical_sensing: VerticalSensingKind::None,
service_hz: 100, data_channel: DataChannelRates {
raw_imu_hz: 20, service_hz: 100,
range_hz: None, raw_imu_hz: 20,
optical_flow_hz: None, range_hz: None,
heartbeat_hz: 2, optical_flow_hz: None,
}, heartbeat_hz: 2,
},
}),
}; };
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> { fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
platform platform
.imu(Icm42688::on_spi3().with_drdy(Pc3)) .imu(Icm42688::on_spi3().with_drdy(Pc3))
.motors(MotorOutputs::geprc_tim3_tim5().dshot600())
.mavlink(Mavlink::on_usart2()) .mavlink(Mavlink::on_usart2())
.vertical_sensor(VerticalSensor::none()) .vertical_sensor(VerticalSensor::none())
.build() .build()
+19 -37
View File
@@ -4,61 +4,43 @@
//! code, interrupt names, DMA handling, or task scheduling. Those concerns //! code, interrupt names, DMA handling, or task scheduling. Those concerns
//! belong to the STM32F7 platform crate. //! 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::{ use platform_stm32f7::{
BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, MotorOutputs, Mpu6000, Pa4, BoardError, F7Board, F7BoardBuilder, F722BoardRoutes, Mavlink, Mpu6000, Pa4, VerticalSensor,
VerticalSensor,
}; };
/// Holybro Kakute F7 Mini V3. /// Holybro Kakute F7 Mini V3.
pub struct KakuteF7MiniV3; pub struct KakuteF7MiniV3;
#[cfg(not(feature = "lidar-lite-v3"))]
const CONFIG: BoardConfig = BoardConfig { const CONFIG: BoardConfig = BoardConfig {
name: "Kakute F7 Mini V3", name: "Kakute F7 Mini V3",
mavlink: MavlinkEndpoint { mavlink: MavlinkEndpoint {
system_id: 1, system_id: 1,
component_id: 1, component_id: 1,
}, },
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, profile: BoardProfile::TelemetryOnly(TelemetryProfile {
data_channel: DataChannelRates { vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
service_hz: 100, data_channel: DataChannelRates {
raw_imu_hz: 20, service_hz: 100,
range_hz: Some(20), raw_imu_hz: 20,
optical_flow_hz: Some(20), range_hz: Some(20),
heartbeat_hz: 2, 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,
},
}; };
impl F7Board for KakuteF7MiniV3 { impl F7Board for KakuteF7MiniV3 {
const CONFIG: BoardConfig = CONFIG; const CONFIG: BoardConfig = CONFIG;
fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> { fn build(platform: F7BoardBuilder) -> Result<F722BoardRoutes, BoardError> {
let platform = platform platform
.imu(Mpu6000::on_spi1().with_drdy(Pa4)) .imu(Mpu6000::on_spi1().with_drdy(Pa4))
.motors(MotorOutputs::kakute_tim3_tim5().dshot600()) .mavlink(Mavlink::on_usart1())
.mavlink(Mavlink::on_usart1()); .vertical_sensor(VerticalSensor::mtf01_on_uart4())
#[cfg(feature = "lidar-lite-v3")] .build()
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()
} }
} }
-1
View File
@@ -2,5 +2,4 @@
#![no_main] #![no_main]
mod board; mod board;
platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3); platform_stm32f7::flight_firmware!(board::KakuteF7MiniV3);
+102 -25
View File
@@ -1,8 +1,9 @@
//! Portable board-support contract for Kaptan. //! Portable board-support contract for Kaptan.
//! //!
//! This crate describes logical board identity, sensing capability and //! This crate describes logical board identity, sensing capability and
//! outbound data-channel policy. It intentionally has no dependency on an MCU family, //! outbound data-channel policy. It intentionally has no dependency on an MCU
//! peripheral access crate, RTIC, or a runtime framework. //! family, peripheral access crate, RTIC, or a runtime framework.
#![no_std] #![no_std]
/// The vertical-sensing data a board support package makes available. /// The vertical-sensing data a board support package makes available.
@@ -41,26 +42,75 @@ pub struct DataChannelRates {
pub heartbeat_hz: usize, 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 /// The selected platform trait determines the MCU family. Keeping a free-form
/// MCU string here would duplicate that type-level fact without validating it. /// 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)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BoardConfig { pub struct BoardConfig {
/// Stable board name used in build and diagnostic tooling. /// Stable board name used in build and diagnostic tooling.
pub name: &'static str, pub name: &'static str,
/// MAVLink endpoint identity. /// MAVLink endpoint identity.
pub mavlink: MavlinkEndpoint, pub mavlink: MavlinkEndpoint,
/// Supported vertical-sensing capability. /// Exactly one immutable image profile.
pub vertical_sensing: VerticalSensingKind, pub profile: BoardProfile,
/// Data-channel timer and publication rates.
pub data_channel: DataChannelRates,
} }
impl BoardConfig { 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<TelemetryProfile> {
match self.profile {
BoardProfile::TelemetryOnly(profile) => Some(profile),
BoardProfile::ExternalControlOnly => None,
}
}
/// Validates the selected exclusive profile.
pub const fn validate(self) -> Result<(), BoardConfigError> { 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 { if rates.service_hz == 0 {
return Err(BoardConfigError::ZeroServiceFrequency); return Err(BoardConfigError::ZeroServiceFrequency);
} }
@@ -96,24 +146,29 @@ impl BoardConfig {
return Err(BoardConfigError::DataChannelOvercommitted); return Err(BoardConfigError::DataChannelOvercommitted);
} }
match self.vertical_sensing { let vertical_schedule_is_valid = match profile.vertical_sensing {
VerticalSensingKind::None VerticalSensingKind::None
if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() => if rates.range_hz.is_none() && rates.optical_flow_hz.is_none() =>
{ {
Ok(()) true
} }
VerticalSensingKind::Range VerticalSensingKind::Range
if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() => if rates.range_hz.is_some() && rates.optical_flow_hz.is_none() =>
{ {
Ok(()) true
} }
VerticalSensingKind::RangeAndOpticalFlow VerticalSensingKind::RangeAndOpticalFlow
if rates.range_hz.is_some() && rates.optical_flow_hz.is_some() => 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, DataChannelOvercommitted,
/// Declared vertical sensing and published streams disagree. /// Declared vertical sensing and published streams disagree.
CapabilityDataChannelMismatch, CapabilityDataChannelMismatch,
/// The selected operation profile has no complete runtime implementation.
UnsupportedFlightProfile,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
BoardConfig, BoardConfigError, DataChannelRates, MavlinkEndpoint, VerticalSensingKind, BoardConfig, BoardConfigError, BoardProfile, DataChannelRates, MavlinkEndpoint,
TelemetryProfile, VerticalSensingKind,
}; };
const BASE: BoardConfig = BoardConfig { const BASE: BoardConfig = BoardConfig {
@@ -144,14 +202,16 @@ mod tests {
system_id: 1, system_id: 1,
component_id: 1, component_id: 1,
}, },
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, profile: BoardProfile::TelemetryOnly(TelemetryProfile {
data_channel: DataChannelRates { vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
service_hz: 100, data_channel: DataChannelRates {
raw_imu_hz: 20, service_hz: 100,
range_hz: Some(20), raw_imu_hz: 20,
optical_flow_hz: Some(20), range_hz: Some(20),
heartbeat_hz: 2, optical_flow_hz: Some(20),
}, heartbeat_hz: 2,
},
}),
}; };
#[test] #[test]
@@ -162,7 +222,10 @@ mod tests {
#[test] #[test]
fn rejects_streams_that_exceed_the_service_budget() { fn rejects_streams_that_exceed_the_service_budget() {
let mut config = BASE; 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!( assert_eq!(
config.validate(), config.validate(),
@@ -173,11 +236,25 @@ mod tests {
#[test] #[test]
fn rejects_capability_and_data_channel_mismatch() { fn rejects_capability_and_data_channel_mismatch() {
let mut config = BASE; 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!( assert_eq!(
config.validate(), config.validate(),
Err(BoardConfigError::CapabilityDataChannelMismatch) 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)
);
}
} }
+22 -21
View File
@@ -2,7 +2,8 @@
//! //!
//! This crate owns fixed-rate scheduling, mailbox consumption, vertical-data //! This crate owns fixed-rate scheduling, mailbox consumption, vertical-data
//! freshness, MAVLink encoding, and bounded queue classification. A platform //! 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] #![no_std]
@@ -57,6 +58,8 @@ pub struct VerticalDataConfig {
/// Data-channel construction failure. /// Data-channel construction failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DataChannelError { pub enum DataChannelError {
/// Rejected profiles must not construct a physical telemetry channel.
NotTelemetryProfile,
/// A configured channel must be due at least once per second. /// A configured channel must be due at least once per second.
ZeroRate, ZeroRate,
/// A configured channel exceeds the service frequency. /// A configured channel exceeds the service frequency.
@@ -92,7 +95,10 @@ where
vertical: VerticalDataConfig, vertical: VerticalDataConfig,
sources: DataSources<'a>, sources: DataSources<'a>,
) -> Result<Self, DataChannelError> { ) -> Result<Self, DataChannelError> {
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 { Ok(Self {
sink, sink,
encoder: MavlinkEncoder::new(config.mavlink.system_id, config.mavlink.component_id), 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 /// No schedule token escapes this method. It refreshes cached vertical
/// observations, selects due data, encodes the MAVLink message, and admits /// observations, selects due data, encodes the MAVLink message, and admits
/// it with the correct bounded-queue class. /// 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(); self.refresh_vertical_data();
match self.schedule.next_due() { match self.schedule.next_due() {
@@ -148,14 +154,7 @@ where
} }
Some(DueData::Heartbeat) => self.sink.send( Some(DueData::Heartbeat) => self.sink.send(
FrameClass::Critical, FrameClass::Critical,
self.encoder.heartbeat( self.encoder.heartbeat(system_status, motors_armed),
if imu_healthy {
MavState::MAV_STATE_STANDBY
} else {
MavState::MAV_STATE_CRITICAL
},
motors_armed,
),
), ),
Some(DueData::Distance | DueData::OpticalFlow) | None => {} Some(DueData::Distance | DueData::OpticalFlow) | None => {}
} }
@@ -281,7 +280,7 @@ mod tests {
}; };
use kaptan_board::{BoardConfig, DataChannelRates, MavlinkEndpoint, VerticalSensingKind}; use kaptan_board::{BoardConfig, DataChannelRates, MavlinkEndpoint, VerticalSensingKind};
use kaptan_dma::TxCompletion; use kaptan_dma::TxCompletion;
use kaptan_mavlink::{MavlinkFrame, transmitter::FrameClass}; use kaptan_mavlink::{MavlinkFrame, message::MavState, transmitter::FrameClass};
use kaptan_sensor::{ use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot, imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
}; };
@@ -312,14 +311,16 @@ mod tests {
system_id: 1, system_id: 1,
component_id: 1, component_id: 1,
}, },
vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow, profile: kaptan_board::BoardProfile::TelemetryOnly(kaptan_board::TelemetryProfile {
data_channel: DataChannelRates { vertical_sensing: VerticalSensingKind::RangeAndOpticalFlow,
service_hz: 100, data_channel: DataChannelRates {
raw_imu_hz: 20, service_hz: 100,
range_hz: Some(20), raw_imu_hz: 20,
optical_flow_hz: Some(20), range_hz: Some(20),
heartbeat_hz: 2, optical_flow_hz: Some(20),
}, heartbeat_hz: 2,
},
}),
}; };
#[test] #[test]
@@ -367,7 +368,7 @@ mod tests {
100, 100,
100, 100,
)); ));
channel.on_service_tick(true, false); channel.on_service_tick(MavState::MAV_STATE_STANDBY, false);
} }
channel.on_dma_complete(TxCompletion::Complete); channel.on_dma_complete(TxCompletion::Complete);