feat(firmware): validate configuration and migrate sensor-driven flight APIs

Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
This commit is contained in:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

@@ -9,6 +9,7 @@ version.workspace = true
workspace = true
[dependencies]
critical-section = "1.2"
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-mavlink = { path = "../kaptan-mavlink", default-features = false, features = [
"sensor-telemetry",
@@ -17,3 +18,6 @@ kaptan-sensor = { path = "../kaptan-sensor" }
kaptan-time = { path = "../kaptan-time" }
kaptan-tx = { path = "../kaptan-tx" }
mavlink-core = { version = "0.18", default-features = false }
[target.'cfg(not(target_os = "none"))'.dev-dependencies]
critical-section = { version = "1.2", features = ["std"] }
+90 -139
View File
@@ -8,19 +8,19 @@
use kaptan_err_reportable::KaptanError;
use kaptan_mavlink::{MavlinkFrame, encoder::MavlinkEncoder, generated::kaptan::MavMessage};
use kaptan_sensor::{optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use kaptan_sensor::{imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use kaptan_time::Timestamp;
use kaptan_tx::{FrameClass, PendingFrame};
use mavlink_core::MessageData;
mod mailbox;
pub use mailbox::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
pub use mailbox::{
Consumer, LatestImuMailbox, LatestMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox,
Producer,
};
/// Validated publication schedule consumed by the outbound data channel.
///
/// This is a derived runtime input, not a persisted configuration root. Board
/// packages own the factory values and the platform constructs this view only
/// after validating the active user configuration against immutable hardware.
/// Publication rates in hertz.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DataChannelRates {
/// IMU publication rate.
@@ -33,15 +33,55 @@ pub struct DataChannelRates {
pub heartbeat_hz: usize,
}
/// Mailboxes read by one data-channel worker.
#[derive(Clone, Copy)]
/// Mailboxes read by the single telemetry collector.
pub struct DataSources<'a> {
/// Latest IMU observation from the acquisition worker.
pub imu: &'a LatestImuMailbox,
pub imu: Consumer<'a, ImuSnapshot>,
/// Latest range observation from either supported vertical route.
pub range: &'a LatestRangeMailbox,
pub range: Consumer<'a, RangeSnapshot>,
/// Latest optical-flow observation from MTF-01.
pub optical_flow: &'a LatestOpticalFlowMailbox,
pub optical_flow: Consumer<'a, OpticalFlowSnapshot>,
}
/// Latest collected observations shared by any number of outbound links.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TelemetrySamples {
/// Latest IMU observation.
pub imu: Option<ImuSnapshot>,
/// Latest range observation.
pub range: Option<RangeSnapshot>,
/// Latest optical-flow observation.
pub optical_flow: Option<OpticalFlowSnapshot>,
}
/// Sole consumer of a set of SPSC mailboxes, retaining their latest values.
pub struct DataCollector<'a> {
sources: DataSources<'a>,
samples: TelemetrySamples,
}
impl<'a> DataCollector<'a> {
/// Creates the single collector for these mailboxes.
pub fn new(sources: DataSources<'a>) -> Self {
Self {
sources,
samples: TelemetrySamples::default(),
}
}
/// Collects each mailbox once, retaining values without a newer publication.
pub fn snapshot(&mut self) -> TelemetrySamples {
if let Some(imu) = self.sources.imu.take() {
self.samples.imu = Some(imu);
}
if let Some(range) = self.sources.range.take() {
self.samples.range = Some(range);
}
if let Some(flow) = self.sources.optical_flow.take() {
self.samples.optical_flow = Some(flow);
}
self.samples
}
}
/// Data-channel construction failure.
@@ -59,80 +99,70 @@ pub enum DataChannelError {
}
/// Scheduled outbound MAVLink frame producer.
pub struct DataChannel<'a, const SERVICE_HZ: usize> {
pub struct DataChannel<const SERVICE_HZ: usize> {
encoder: MavlinkEncoder,
schedule: Schedule<SERVICE_HZ>,
sources: DataSources<'a>,
maximum_vertical_age_ticks: u8,
observed_imu: u32,
observed_range: u32,
last_range: Option<RangeSnapshot>,
range_age_ticks: u8,
observed_optical_flow: u32,
last_optical_flow: Option<OpticalFlowSnapshot>,
optical_flow_age_ticks: u8,
maximum_vertical_age_microseconds: u64,
last_imu_timestamp: Option<Timestamp>,
}
impl<'a, const SERVICE_HZ: usize> DataChannel<'a, SERVICE_HZ> {
impl<const SERVICE_HZ: usize> DataChannel<SERVICE_HZ> {
/// Creates an outbound data flow from already-validated runtime inputs.
pub fn new(
system_id: u8,
component_id: u8,
rates: DataChannelRates,
maximum_vertical_age_ticks: u8,
sources: DataSources<'a>,
maximum_vertical_age_microseconds: u64,
) -> Result<Self, DataChannelError> {
let schedule = Schedule::from_rates(rates)?;
Ok(Self {
encoder: MavlinkEncoder::new(system_id, component_id),
schedule,
sources,
maximum_vertical_age_ticks,
observed_imu: 0,
observed_range: 0,
last_range: None,
range_age_ticks: u8::MAX,
observed_optical_flow: 0,
last_optical_flow: None,
optical_flow_age_ticks: u8::MAX,
maximum_vertical_age_microseconds,
last_imu_timestamp: None,
})
}
/// Runs all outbound work due for one platform service tick.
///
/// At most one classified frame is returned for transport admission.
pub fn on_service_tick(&mut self) -> Option<PendingFrame<MavlinkFrame>> {
self.refresh_vertical_data();
/// IMU timestamps must increase; recreate the channel for a new clock epoch.
pub fn on_service_tick(
&mut self,
samples: &TelemetrySamples,
now: Timestamp,
) -> Option<PendingFrame<MavlinkFrame>> {
match self.schedule.next_due() {
Some(DueData::Imu) => {
self.sources
.imu
.take_newer(&mut self.observed_imu)
.map(|sample| PendingFrame {
class: FrameClass::BestEffort,
frame: self.encoder.imu_snapshot(&sample),
})
let sample = samples.imu.filter(|sample| {
self.last_imu_timestamp
.is_none_or(|last| sample.timestamp > last)
})?;
self.last_imu_timestamp = Some(sample.timestamp);
Some(PendingFrame {
class: FrameClass::BestEffort,
frame: self.encoder.imu_snapshot(&sample),
})
}
Some(DueData::Distance) if self.range_age_ticks <= self.maximum_vertical_age_ticks => {
self.last_range.map(|range| PendingFrame {
Some(DueData::Distance) => samples
.range
.filter(|range| self.vertical_is_fresh(range.timestamp, now))
.map(|range| PendingFrame {
class: FrameClass::BestEffort,
frame: self.encoder.range_snapshot(&range),
})
}
Some(DueData::OpticalFlow)
if self.optical_flow_age_ticks <= self.maximum_vertical_age_ticks =>
{
self.last_optical_flow.map(|flow| PendingFrame {
}),
Some(DueData::OpticalFlow) => samples
.optical_flow
.filter(|flow| self.vertical_is_fresh(flow.timestamp, now))
.map(|flow| PendingFrame {
class: FrameClass::BestEffort,
frame: self.encoder.optical_flow_snapshot(&flow),
})
}
}),
Some(DueData::Heartbeat) => Some(PendingFrame {
class: FrameClass::Vital,
frame: self.encoder.heartbeat(),
}),
Some(DueData::Distance | DueData::OpticalFlow) | None => None,
None => None,
}
}
@@ -149,23 +179,10 @@ impl<'a, const SERVICE_HZ: usize> DataChannel<'a, SERVICE_HZ> {
}
}
fn refresh_vertical_data(&mut self) {
if let Some(range) = self.sources.range.take_newer(&mut self.observed_range) {
self.last_range = Some(range);
self.range_age_ticks = 0;
} else {
self.range_age_ticks = self.range_age_ticks.saturating_add(1);
}
if let Some(flow) = self
.sources
.optical_flow
.take_newer(&mut self.observed_optical_flow)
{
self.last_optical_flow = Some(flow);
self.optical_flow_age_ticks = 0;
} else {
self.optical_flow_age_ticks = self.optical_flow_age_ticks.saturating_add(1);
}
fn vertical_is_fresh(&self, timestamp: Timestamp, now: Timestamp) -> bool {
now.as_microseconds()
.checked_sub(timestamp.as_microseconds())
.is_some_and(|age| age <= self.maximum_vertical_age_microseconds)
}
}
@@ -245,70 +262,4 @@ impl<const SERVICE_HZ: usize> Schedule<SERVICE_HZ> {
}
#[cfg(test)]
mod tests {
use super::{
DataChannel, DataChannelRates, DataSources, LatestImuMailbox, LatestOpticalFlowMailbox,
LatestRangeMailbox,
};
use kaptan_mavlink::generated::kaptan::MavMessage;
use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
};
use kaptan_time::Timestamp;
use kaptan_tx::FrameClass;
const RATES: DataChannelRates = DataChannelRates {
imu_hz: 20,
range_hz: Some(20),
optical_flow_hz: Some(20),
heartbeat_hz: 2,
};
#[test]
fn owns_rate_selection_encoding_and_classification() {
let imu = LatestImuMailbox::new();
let range = LatestRangeMailbox::new();
let flow = LatestOpticalFlowMailbox::new();
let mut channel: DataChannel<'_, 100> = DataChannel::new(
1,
1,
RATES,
10,
DataSources {
imu: &imu,
range: &range,
optical_flow: &flow,
},
)
.unwrap();
let mut best_effort = 0;
let mut vital = 0;
for tick in 0..100 {
imu.publish(ImuSnapshot::new(
Timestamp::from_microseconds(tick),
[0; 3],
[0; 3],
0,
));
range.publish(RangeSnapshot::new(Timestamp::from_microseconds(tick), 100));
flow.publish(OpticalFlowSnapshot::new(
Timestamp::from_microseconds(tick),
[0; 2],
100,
100,
));
if let Some(frame) = channel.on_service_tick() {
assert!(frame.frame.has_valid_crc::<MavMessage>());
assert_eq!(frame.frame.sequence(), best_effort + vital);
match frame.class {
FrameClass::Vital => vital += 1,
FrameClass::BestEffort => best_effort += 1,
FrameClass::Critical => unreachable!(),
}
}
}
assert_eq!(best_effort, 60);
assert_eq!(vital, 2);
}
}
mod tests;
+185 -192
View File
@@ -1,227 +1,220 @@
//! Lock-free single-producer, single-consumer latest-value handoff.
use core::{
marker::PhantomData,
sync::atomic::{AtomicU32, Ordering},
};
//! Single-producer, single-consumer latest-value handoff.
use core::cell::Cell;
use critical_section::Mutex;
use kaptan_sensor::{imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use kaptan_time::Timestamp;
/// Converts an observation to the atomic word representation used by a
/// [`LatestMailbox`].
/// Fixed-size latest-value mailbox with one producer and one consumer.
///
/// This is a data-channel handoff contract, not a sensor-domain contract:
/// sensor crates own their fields and units, while this crate owns their
/// lock-free representation between an acquisition producer and consumer.
pub trait MailboxValue<const WORDS: usize>: Copy {
/// Converts this observation into its handoff representation.
fn into_words(self) -> [u32; WORDS];
/// Reconstructs an observation from its handoff representation.
fn from_words(words: [u32; WORDS]) -> Self;
/// A publication replaces any unread value. Each operation acquires the platform
/// critical section only to copy or take an `Option<T>`. There are no callbacks,
/// allocation, retries, or waits inside that section. The platform must supply a
/// critical-section implementation that excludes every participating context.
/// On a host this requires the `critical-section` crate's `std` backend.
///
/// Storage is borrowed by the endpoints and cannot move while they are in use.
/// Dropping or forgetting an endpoint does not leave a lock held. After both
/// borrows end the mailbox can be split again, retaining any unread value.
///
/// ```
/// use kaptan_data_channel::LatestMailbox;
/// let mut mailbox = LatestMailbox::new();
/// let (mut producer, mut consumer) = mailbox.split();
/// producer.publish([1, 2, 3]);
/// producer.publish([4, 5, 6]);
/// assert_eq!(consumer.take(), Some([4, 5, 6]));
/// assert_eq!(consumer.take(), None);
/// ```
pub struct LatestMailbox<T: Copy> {
slot: Mutex<Cell<Option<T>>>,
}
/// Lock-free latest-value SPSC mailbox.
///
/// Publishing a newer value intentionally replaces an unread older value: the
/// output path is best-effort and must never delay acquisition. The sequence
/// counter is a seqlock; odd values indicate a writer and even values identify
/// a stable snapshot. Exactly one producer and one consumer may use an
/// instance concurrently. Sequence operations are sequentially consistent so
/// a reader cannot validate an old even sequence around concurrently written
/// atomic payload words; the payload words themselves need only be relaxed.
pub struct LatestMailbox<Value, const WORDS: usize> {
sequence: AtomicU32,
words: [AtomicU32; WORDS],
value: PhantomData<Value>,
}
impl<Value: MailboxValue<WORDS>, const WORDS: usize> LatestMailbox<Value, WORDS> {
/// Creates an empty mailbox.
impl<T: Copy> LatestMailbox<T> {
/// Creates an empty mailbox without acquiring a critical section.
pub const fn new() -> Self {
Self {
sequence: AtomicU32::new(0),
words: [const { AtomicU32::new(0) }; WORDS],
value: PhantomData,
slot: Mutex::new(Cell::new(None)),
}
}
/// Publishes a snapshot, replacing any unread older snapshot.
pub fn publish(&self, value: Value) {
let sequence = self.sequence.fetch_add(1, Ordering::SeqCst);
debug_assert_eq!(sequence & 1, 0, "SPSC producer must not reenter");
for (destination, word) in self.words.iter().zip(value.into_words()) {
destination.store(word, Ordering::Relaxed);
}
self.sequence
.store(sequence.wrapping_add(2), Ordering::SeqCst);
}
/// Returns the newest stable snapshot different from `observed_sequence`.
pub fn take_newer(&self, observed_sequence: &mut u32) -> Option<Value> {
let before = self.sequence.load(Ordering::SeqCst);
if before == *observed_sequence || before & 1 != 0 {
return None;
}
let mut words = [0; WORDS];
for (destination, source) in words.iter_mut().zip(&self.words) {
*destination = source.load(Ordering::Relaxed);
}
if self.sequence.load(Ordering::SeqCst) != before {
return None;
}
*observed_sequence = before;
Some(Value::from_words(words))
/// Borrows the mailbox as exactly one producer and one consumer.
///
/// A second split cannot overlap either endpoint's use:
///
/// ```compile_fail,E0499
/// use kaptan_data_channel::LatestMailbox;
/// let mut mailbox = LatestMailbox::<u32>::new();
/// let (mut producer, _consumer) = mailbox.split();
/// let (mut duplicate, _) = mailbox.split();
/// producer.publish(1);
/// duplicate.publish(2);
/// ```
pub fn split(&mut self) -> (Producer<'_, T>, Consumer<'_, T>) {
(Producer { mailbox: self }, Consumer { mailbox: self })
}
}
impl<Value: MailboxValue<WORDS>, const WORDS: usize> Default for LatestMailbox<Value, WORDS> {
impl<T: Copy> Default for LatestMailbox<T> {
fn default() -> Self {
Self::new()
}
}
/// Exclusive publication endpoint. Moving it transfers publication ownership.
///
/// ```compile_fail,E0599
/// use kaptan_data_channel::LatestMailbox;
/// let mut mailbox = LatestMailbox::<u32>::new();
/// let (producer, _) = mailbox.split();
/// let duplicate = producer.clone();
/// ```
pub struct Producer<'a, T: Copy> {
mailbox: &'a LatestMailbox<T>,
}
impl<T: Copy> Producer<'_, T> {
/// Publishes a complete value, replacing any unread older value.
///
/// ```compile_fail,E0596
/// use kaptan_data_channel::LatestMailbox;
/// let mut mailbox = LatestMailbox::<u32>::new();
/// let (producer, _) = mailbox.split();
/// let shared = &producer;
/// shared.publish(1);
/// ```
pub fn publish(&mut self, value: T) {
critical_section::with(|cs| self.mailbox.slot.borrow(cs).set(Some(value)));
}
}
/// Exclusive consumption endpoint. Values are removed at most once.
///
/// ```compile_fail,E0599
/// use kaptan_data_channel::LatestMailbox;
/// let mut mailbox = LatestMailbox::<u32>::new();
/// let (_, consumer) = mailbox.split();
/// let duplicate = consumer.clone();
/// ```
pub struct Consumer<'a, T: Copy> {
mailbox: &'a LatestMailbox<T>,
}
impl<T: Copy> Consumer<'_, T> {
/// Takes the latest unread value, or `None` if no publication is pending.
pub fn take(&mut self) -> Option<T> {
critical_section::with(|cs| self.mailbox.slot.borrow(cs).take())
}
}
/// Latest-value mailbox for IMU data-channel snapshots.
pub type LatestImuMailbox = LatestMailbox<ImuSnapshot, 9>;
pub type LatestImuMailbox = LatestMailbox<ImuSnapshot>;
/// Latest-value mailbox for range data-channel snapshots.
pub type LatestRangeMailbox = LatestMailbox<RangeSnapshot, 3>;
pub type LatestRangeMailbox = LatestMailbox<RangeSnapshot>;
/// Latest-value mailbox for optical-flow data-channel snapshots.
pub type LatestOpticalFlowMailbox = LatestMailbox<OpticalFlowSnapshot, 6>;
impl MailboxValue<9> for ImuSnapshot {
fn into_words(self) -> [u32; 9] {
let timestamp = self.timestamp.as_microseconds();
[
timestamp as u32,
(timestamp >> 32) as u32,
self.acceleration_milligravity[0] as u16 as u32,
self.acceleration_milligravity[1] as u16 as u32,
self.acceleration_milligravity[2] as u16 as u32,
self.angular_rate_milliradians_per_second[0] as u16 as u32,
self.angular_rate_milliradians_per_second[1] as u16 as u32,
self.angular_rate_milliradians_per_second[2] as u16 as u32,
self.temperature_centidegrees_celsius as u16 as u32,
]
}
fn from_words(words: [u32; 9]) -> Self {
Self::new(
timestamp_from_words(words[0], words[1]),
[
words[2] as u16 as i16,
words[3] as u16 as i16,
words[4] as u16 as i16,
],
[
words[5] as u16 as i16,
words[6] as u16 as i16,
words[7] as u16 as i16,
],
words[8] as u16 as i16,
)
}
}
impl MailboxValue<3> for RangeSnapshot {
fn into_words(self) -> [u32; 3] {
let timestamp = self.timestamp.as_microseconds();
[
timestamp as u32,
(timestamp >> 32) as u32,
self.distance_millimetres,
]
}
fn from_words(words: [u32; 3]) -> Self {
Self::new(timestamp_from_words(words[0], words[1]), words[2])
}
}
impl MailboxValue<6> for OpticalFlowSnapshot {
fn into_words(self) -> [u32; 6] {
let timestamp = self.timestamp.as_microseconds();
[
timestamp as u32,
(timestamp >> 32) as u32,
self.velocity_millimetres_per_second[0] as u32,
self.velocity_millimetres_per_second[1] as u32,
self.ground_distance_millimetres,
u32::from(self.quality),
]
}
fn from_words(words: [u32; 6]) -> Self {
Self::new(
timestamp_from_words(words[0], words[1]),
[words[2] as i32, words[3] as i32],
words[4],
words[5] as u8,
)
}
}
const fn timestamp_from_words(low: u32, high: u32) -> Timestamp {
Timestamp::from_microseconds((low as u64) | ((high as u64) << 32))
}
pub type LatestOpticalFlowMailbox = LatestMailbox<OpticalFlowSnapshot>;
#[cfg(test)]
mod tests {
use super::{LatestImuMailbox, LatestOpticalFlowMailbox, LatestRangeMailbox};
use kaptan_sensor::{
imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot,
};
extern crate std;
use super::*;
use kaptan_time::Timestamp;
use std::thread;
#[test]
fn publishes_only_the_newest_unread_imu_sample() {
let mailbox = LatestImuMailbox::new();
let mut observed = 0;
mailbox.publish(ImuSnapshot::new(
Timestamp::from_microseconds(1),
[1; 3],
[2; 3],
3,
));
mailbox.publish(ImuSnapshot::new(
Timestamp::from_microseconds(2),
[4; 3],
[5; 3],
6,
));
let sample = mailbox.take_newer(&mut observed).unwrap();
assert_eq!(sample.timestamp, Timestamp::from_microseconds(2));
assert_eq!(sample.acceleration_milligravity, [4; 3]);
assert!(mailbox.take_newer(&mut observed).is_none());
}
#[test]
fn publishes_only_the_newest_unread_range_sample() {
let mailbox = LatestRangeMailbox::new();
let mut observed = 0;
mailbox.publish(RangeSnapshot::new(Timestamp::from_microseconds(1), 100));
mailbox.publish(RangeSnapshot::new(Timestamp::from_microseconds(2), 200));
assert_eq!(
mailbox.take_newer(&mut observed),
Some(RangeSnapshot::new(Timestamp::from_microseconds(2), 200))
fn latest_publication_overwrites_and_is_taken_once() {
let mut mailbox = LatestImuMailbox::new();
let (mut producer, mut consumer) = mailbox.split();
assert_eq!(consumer.take(), None);
let first = ImuSnapshot::new(Timestamp::from_microseconds(1), [1; 3], [2; 3], 3);
let latest = ImuSnapshot::new(
Timestamp::from_microseconds(u64::MAX),
[-32_768, 0, 32_767],
[-34_906, 0, 34_906],
-2_501,
);
assert!(mailbox.take_newer(&mut observed).is_none());
producer.publish(first);
producer.publish(latest);
assert_eq!(consumer.take(), Some(latest));
assert_eq!(consumer.take(), None);
producer.publish(latest);
assert_eq!(consumer.take(), Some(latest));
}
#[test]
fn preserves_optical_flow_fields() {
let mailbox = LatestOpticalFlowMailbox::new();
let mut observed = 0;
let sample = OpticalFlowSnapshot::new(Timestamp::from_microseconds(1), [-6, 7], 8, 9);
mailbox.publish(sample);
fn vertical_snapshots_preserve_all_fields() {
let mut range = LatestRangeMailbox::new();
let mut flow = LatestOpticalFlowMailbox::new();
let (mut range_producer, mut range_consumer) = range.split();
let (mut flow_producer, mut flow_consumer) = flow.split();
let timestamp = Timestamp::from_microseconds(u64::MAX);
let range = RangeSnapshot::new(timestamp, u32::MAX);
let flow = OpticalFlowSnapshot::new(timestamp, [i32::MIN, i32::MAX], u32::MAX, 255);
range_producer.publish(range);
flow_producer.publish(flow);
assert_eq!(range_consumer.take(), Some(range));
assert_eq!(flow_consumer.take(), Some(flow));
}
assert_eq!(mailbox.take_newer(&mut observed), Some(sample));
#[test]
// Forget is deliberate: endpoint safety must never depend on destruction.
#[allow(clippy::forget_non_drop)]
fn moving_dropping_and_forgetting_endpoints_needs_no_cleanup() {
let mut mailbox = LatestMailbox::new();
{
let mut consumer = {
let (producer, consumer) = mailbox.split();
let mut moved = producer;
moved.publish(1);
consumer
};
// The producer left scope before its final publication is taken.
assert_eq!(consumer.take(), Some(1));
assert_eq!(consumer.take(), None);
}
{
let (mut producer, consumer) = mailbox.split();
core::mem::forget(consumer);
producer.publish(2);
producer.publish(3);
core::mem::forget(producer);
}
// Neither endpoint owns a guard or controls the backing storage lifetime.
let mut moved_mailbox = mailbox;
let (_, mut consumer) = moved_mailbox.split();
assert_eq!(consumer.take(), Some(3));
assert_eq!(consumer.take(), None);
}
#[test]
fn owned_endpoints_publish_coherent_values_across_host_threads() {
const LAST: u64 = 50_000;
let mut mailbox = LatestMailbox::<[u64; 16]>::new();
let (mut producer, mut consumer) = mailbox.split();
thread::scope(|scope| {
let writer = scope.spawn(move || {
for value in 1..=LAST {
producer.publish([value; 16]);
}
});
let mut last = 0;
let mut observe = |value: [u64; 16]| {
assert_eq!(value, [value[0]; 16], "torn publication");
assert!(value[0] > last, "duplicate or reordered publication");
last = value[0];
};
while !writer.is_finished() {
if let Some(value) = consumer.take() {
observe(value);
}
thread::yield_now();
}
writer.join().unwrap();
if let Some(value) = consumer.take() {
observe(value);
}
assert_eq!(last, LAST);
assert_eq!(consumer.take(), None);
});
}
}
@@ -0,0 +1,442 @@
use super::{
DataChannel, DataChannelError, DataChannelRates, DataCollector, DataSources, LatestImuMailbox,
LatestOpticalFlowMailbox, LatestRangeMailbox, Producer, TelemetrySamples,
};
use kaptan_mavlink::{
MavlinkFrame,
generated::kaptan::{HEARTBEAT_DATA, HeartbeatState, MavMessage},
receiver::IntegrityMavlinkReceiver,
};
use kaptan_sensor::{imu::ImuSnapshot, optical_flow::OpticalFlowSnapshot, range::RangeSnapshot};
use kaptan_time::Timestamp;
use kaptan_tx::{EnqueueResult, FrameClass, PendingFrame, TransmitQueue};
const RATES: DataChannelRates = DataChannelRates {
imu_hz: 20,
range_hz: Some(20),
optical_flow_hz: Some(20),
heartbeat_hz: 2,
};
const ALL_STREAMS: DataChannelRates = DataChannelRates {
imu_hz: 1,
range_hz: Some(1),
optical_flow_hz: Some(1),
heartbeat_hz: 1,
};
const HEARTBEAT: HEARTBEAT_DATA = HEARTBEAT_DATA {
state: HeartbeatState::HEARTBEAT_STATE_INITIALIZED_OUTPUTS_INHIBITED,
};
#[derive(Default)]
struct Mailboxes {
imu: LatestImuMailbox,
range: LatestRangeMailbox,
optical_flow: LatestOpticalFlowMailbox,
}
impl Mailboxes {
fn split(&mut self) -> (Publishers<'_>, DataCollector<'_>) {
let (imu_producer, imu) = self.imu.split();
let (range_producer, range) = self.range.split();
let (flow_producer, optical_flow) = self.optical_flow.split();
(
Publishers {
imu: imu_producer,
range: range_producer,
optical_flow: flow_producer,
},
DataCollector::new(DataSources {
imu,
range,
optical_flow,
}),
)
}
}
struct Publishers<'a> {
imu: Producer<'a, ImuSnapshot>,
range: Producer<'a, RangeSnapshot>,
optical_flow: Producer<'a, OpticalFlowSnapshot>,
}
impl Publishers<'_> {
fn publish(&mut self, samples: TelemetrySamples) {
if let Some(imu) = samples.imu {
self.imu.publish(imu);
}
if let Some(range) = samples.range {
self.range.publish(range);
}
if let Some(flow) = samples.optical_flow {
self.optical_flow.publish(flow);
}
}
}
fn samples_at(microseconds: u64) -> TelemetrySamples {
let timestamp = Timestamp::from_microseconds(microseconds);
TelemetrySamples {
imu: Some(ImuSnapshot::new(
timestamp,
[-1_001, 2, 3],
[4, -5_006, 7],
2_501,
)),
range: Some(RangeSnapshot::new(timestamp, 2_913)),
optical_flow: Some(OpticalFlowSnapshot::new(timestamp, [-123, 456], 2_917, 201)),
}
}
fn decode(pending: &PendingFrame<MavlinkFrame>) -> MavMessage {
assert!(pending.frame.has_valid_crc::<MavMessage>());
let mut receiver = IntegrityMavlinkReceiver::<1>::new();
for byte in pending.frame.raw_bytes() {
receiver.receive_byte(*byte);
}
receiver.take_received().unwrap().decode().unwrap()
}
fn assert_sample_frame(pending: &PendingFrame<MavlinkFrame>, samples: &TelemetrySamples) -> usize {
let index = match decode(pending) {
MavMessage::IMU(data) => {
let imu = samples.imu.unwrap();
assert_eq!(data.time_us, imu.timestamp.as_microseconds());
assert_eq!(
data.acceleration_milligravity,
imu.acceleration_milligravity
);
assert_eq!(
data.angular_rate_milliradians_per_second,
imu.angular_rate_milliradians_per_second
);
assert_eq!(
data.temperature_centidegrees_celsius,
imu.temperature_centidegrees_celsius
);
0
}
MavMessage::RANGE(data) => {
let range = samples.range.unwrap();
assert_eq!(data.time_us, range.timestamp.as_microseconds());
assert_eq!(data.distance_millimetres, range.distance_millimetres);
1
}
MavMessage::OPTICAL_FLOW(data) => {
let flow = samples.optical_flow.unwrap();
assert_eq!(data.time_us, flow.timestamp.as_microseconds());
assert_eq!(
data.velocity_millimetres_per_second,
flow.velocity_millimetres_per_second
);
assert_eq!(
data.ground_distance_millimetres,
flow.ground_distance_millimetres
);
assert_eq!(data.quality, flow.quality);
2
}
MavMessage::HEARTBEAT(data) => {
assert_eq!(data.state, HEARTBEAT.state);
3
}
_ => panic!("unexpected telemetry message"),
};
assert_eq!(
pending.class,
if index == 3 {
FrameClass::Vital
} else {
FrameClass::BestEffort
}
);
index
}
#[test]
fn collector_retains_latest_samples_between_publications() {
let mut mailboxes = Mailboxes::default();
let (mut publishers, mut collector) = mailboxes.split();
assert_eq!(collector.snapshot(), TelemetrySamples::default());
publishers.publish(samples_at(1));
publishers.publish(samples_at(2));
let first = collector.snapshot();
assert_eq!(first, samples_at(2));
assert_eq!(collector.snapshot(), first);
let range = RangeSnapshot::new(Timestamp::from_microseconds(3), 4_567);
publishers.range.publish(range);
let updated = TelemetrySamples {
range: Some(range),
..first
};
assert_eq!(collector.snapshot(), updated);
assert_eq!(collector.snapshot(), updated);
assert_eq!(first, samples_at(2));
}
#[test]
fn owns_rate_selection_encoding_and_classification() {
let mut mailboxes = Mailboxes::default();
let (mut publishers, mut collector) = mailboxes.split();
let mut channel = DataChannel::<100>::new(1, 1, RATES, 100_000).unwrap();
let mut counts = [0; 4];
let mut sequence = 0;
for tick in 0..100 {
let now = Timestamp::from_microseconds(tick * 10_000);
publishers.publish(samples_at(now.as_microseconds()));
let samples = collector.snapshot();
if let Some(frame) = channel.on_service_tick(&samples, now) {
assert_eq!(frame.frame.sequence(), sequence);
sequence += 1;
counts[assert_sample_frame(&frame, &samples)] += 1;
}
}
assert_eq!(counts, [20, 20, 20, 2]);
}
#[test]
fn links_share_one_snapshot_with_independent_rates_and_sequences() {
let mut mailboxes = Mailboxes::default();
let (mut publishers, mut collector) = mailboxes.split();
let mut fast = DataChannel::<100>::new(1, 2, RATES, 100_000).unwrap();
let mut slow = DataChannel::<100>::new(
3,
4,
DataChannelRates {
imu_hz: 10,
range_hz: Some(5),
optical_flow_hz: None,
heartbeat_hz: 1,
},
100_000,
)
.unwrap();
let external = slow.encode_message(FrameClass::Critical, &HEARTBEAT);
assert_eq!(external.class, FrameClass::Critical);
assert_eq!(external.frame.sequence(), 0);
assert!(matches!(decode(&external), MavMessage::HEARTBEAT(_)));
let mut sequences = [0, 1];
let mut counts = [[0; 4]; 2];
for tick in 0..100 {
let now = Timestamp::from_microseconds(tick * 10_000);
publishers.publish(samples_at(now.as_microseconds()));
let samples = collector.snapshot();
for (link, channel) in [&mut fast, &mut slow].into_iter().enumerate() {
if let Some(frame) = channel.on_service_tick(&samples, now) {
assert_eq!(frame.frame.sequence(), sequences[link]);
assert_eq!(
&frame.frame.raw_bytes()[5..7],
if link == 0 { &[1, 2] } else { &[3, 4] }
);
sequences[link] += 1;
counts[link][assert_sample_frame(&frame, &samples)] += 1;
}
}
}
assert_eq!(counts, [[20, 20, 20, 2], [10, 5, 0, 1]]);
assert_eq!(sequences, [62, 17]);
}
#[test]
fn stalled_and_backpressured_link_does_not_consume_another_links_data() {
let mut mailboxes = Mailboxes::default();
let (mut publishers, mut collector) = mailboxes.split();
let mut healthy = DataChannel::<4>::new(1, 1, ALL_STREAMS, 100_000).unwrap();
let mut blocked = DataChannel::<4>::new(1, 2, ALL_STREAMS, 100_000).unwrap();
let mut queue = TransmitQueue::<_, 1>::new();
assert_eq!(
queue.enqueue(blocked.encode_message(FrameClass::Vital, &HEARTBEAT)),
EnqueueResult::Enqueued
);
let mut counts = [[0; 4]; 2];
for tick in 0..8 {
let now = Timestamp::from_microseconds(tick * 250_000);
publishers.publish(samples_at(now.as_microseconds()));
let samples = collector.snapshot();
if tick >= 4 {
let frame = blocked.on_service_tick(&samples, now).unwrap();
assert_eq!(frame.frame.sequence(), (tick - 3) as u8);
counts[1][assert_sample_frame(&frame, &samples)] += 1;
assert_eq!(
queue.enqueue(frame),
if frame.class == FrameClass::Vital {
EnqueueResult::VitalQueueFull
} else {
EnqueueResult::BestEffortDropped
}
);
}
let frame = healthy.on_service_tick(&samples, now).unwrap();
assert_eq!(frame.frame.sequence(), tick as u8);
counts[0][assert_sample_frame(&frame, &samples)] += 1;
}
assert_eq!(counts, [[2; 4], [1; 4]]);
}
#[test]
fn imu_requires_a_strictly_newer_timestamp_even_after_clock_regression() {
let mut channel = DataChannel::<2>::new(
1,
1,
DataChannelRates {
range_hz: None,
optical_flow_hz: None,
..ALL_STREAMS
},
100_000,
)
.unwrap();
let mut sequence = 0;
for (timestamp, expected) in [
(Some(0), true),
(Some(0), false),
(None, false),
(Some(0), false),
(Some(1), true),
(Some(0), false),
(Some(2), true),
] {
let now = Timestamp::from_microseconds(timestamp.unwrap_or(0));
let mut samples = samples_at(now.as_microseconds());
samples.imu = timestamp.map(|_| ImuSnapshot::new(now, [sequence as i16; 3], [0; 3], 0));
let frame = channel.on_service_tick(&samples, now);
assert_eq!(frame.is_some(), expected);
if let Some(frame) = frame {
assert_eq!(frame.frame.sequence(), sequence);
sequence += 1;
assert_eq!(assert_sample_frame(&frame, &samples), 0);
}
let heartbeat = channel.on_service_tick(&samples, now).unwrap();
assert_eq!(heartbeat.frame.sequence(), sequence);
sequence += 1;
assert_eq!(assert_sample_frame(&heartbeat, &samples), 3);
}
}
fn assert_vertical_cycle(
channel: &mut DataChannel<4>,
samples: &TelemetrySamples,
now: Timestamp,
expected: [bool; 2],
) {
channel.on_service_tick(samples, now);
let heartbeat = channel.on_service_tick(samples, now).unwrap();
assert_eq!(assert_sample_frame(&heartbeat, samples), 3);
for (index, expected) in expected.into_iter().enumerate() {
let frame = channel.on_service_tick(samples, now);
assert_eq!(frame.is_some(), expected);
if let Some(frame) = frame {
assert_eq!(assert_sample_frame(&frame, samples), index + 1);
}
}
}
#[test]
fn vertical_freshness_uses_sample_time_on_first_collection() {
for (sample_time, now, maximum_age, expected) in [
(0, 100_001, 100_000, false),
(100_001, 100_000, 100_000, false),
(u64::MAX, 0, u64::MAX, false),
(0, 100_000, 100_000, true),
(42, 42, 0, true),
(42, 43, 0, false),
] {
let mut mailboxes = Mailboxes::default();
let (mut publishers, mut collector) = mailboxes.split();
publishers.publish(samples_at(sample_time));
let samples = collector.snapshot();
let mut channel = DataChannel::<4>::new(1, 1, ALL_STREAMS, maximum_age).unwrap();
assert_vertical_cycle(
&mut channel,
&samples,
Timestamp::from_microseconds(now),
[expected; 2],
);
}
}
#[test]
fn repeated_ticks_and_republication_do_not_change_sample_age() {
let mut mailboxes = Mailboxes::default();
let (mut publishers, mut collector) = mailboxes.split();
let mut channel = DataChannel::<4>::new(1, 1, ALL_STREAMS, 100_000).unwrap();
for (now, expected, repetitions) in [
(110_000, true, 64),
(110_001, false, 1),
(1_000_000, false, 1),
] {
for _ in 0..repetitions {
publishers.publish(samples_at(10_000));
let samples = collector.snapshot();
assert_vertical_cycle(
&mut channel,
&samples,
Timestamp::from_microseconds(now),
[expected; 2],
);
}
}
}
#[test]
fn vertical_streams_use_their_own_sample_timestamps() {
for (range_time, flow_time, expected) in [
(99_999, 100_000, [false, true]),
(200_000, 200_001, [true, false]),
] {
let samples = TelemetrySamples {
range: samples_at(range_time).range,
optical_flow: samples_at(flow_time).optical_flow,
..TelemetrySamples::default()
};
let mut channel = DataChannel::<4>::new(1, 1, ALL_STREAMS, 100_000).unwrap();
assert_vertical_cycle(
&mut channel,
&samples,
Timestamp::from_microseconds(200_000),
expected,
);
}
}
#[test]
fn invalid_rates_keep_existing_errors() {
for (rates, expected) in [
(
DataChannelRates { imu_hz: 0, ..RATES },
DataChannelError::ZeroRate,
),
(
DataChannelRates {
range_hz: Some(0),
..RATES
},
DataChannelError::ZeroRate,
),
(
DataChannelRates {
imu_hz: 101,
..RATES
},
DataChannelError::RateExceedsServiceFrequency,
),
(
DataChannelRates {
imu_hz: 100,
..RATES
},
DataChannelError::ScheduleFull,
),
] {
assert_eq!(
DataChannel::<100>::new(1, 1, rates, 100_000).err(),
Some(expected)
);
}
}