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

-4
View File
@@ -9,14 +9,10 @@ version.workspace = true
workspace = true
[features]
# Sensor-domain adapters are physical telemetry only.
sensor-telemetry = ["dep:kaptan-sensor"]
[dependencies]
kaptan-err-reportable = { path = "../kaptan-err-reportable" }
kaptan-dma = { path = "../kaptan-dma" }
kaptan-sensor = { path = "../kaptan-sensor", optional = true }
kaptan-tx = { path = "../kaptan-tx" }
mavlink-core = { version = "0.18", default-features = false, features = [
"embedded",
] }
+2 -6
View File
@@ -5,9 +5,7 @@ fn main() {
let definitions = manifest_directory.join("definitions");
let dialect = definitions.join("kaptan.xml");
println!("cargo::rerun-if-changed={}", definitions.display());
// The upstream generator conditionally emits derives for these optional
// ecosystem crates. Kaptan deliberately does not expose those incomplete
// features, so tell rustc the generated inactive cfgs are intentional.
// The generator emits these feature gates, but this crate leaves them disabled.
println!("cargo::rustc-check-cfg=cfg(feature, values(\"serde\", \"arbitrary\", \"ts-rs\"))");
let output_directory = std::env::var("OUT_DIR").unwrap();
mavlink_bindgen::generate(
@@ -15,9 +13,7 @@ fn main() {
&output_directory,
)
.unwrap();
// Bindgen's index feature-gates every dialect module. Kaptan has exactly
// one unconditional dialect, so load its generated file as a normal Rust
// module instead of exposing a redundant Cargo feature.
// Bypass bindgen's feature-gated index to load the Kaptan dialect unconditionally.
std::fs::write(
PathBuf::from(output_directory).join("kaptan_module.rs"),
"#[allow(non_camel_case_types, non_snake_case)]\n\
@@ -1,7 +1,6 @@
<?xml version="1.0"?>
<mavlink>
<!-- The root is the only bindgen input. Every current message has one leaf
under its interaction contract. exchange/ intentionally has no message. -->
<!-- The root is the only generator input. Each message has one private leaf. -->
<include>publish/log.xml</include>
<include>publish/error.xml</include>
<include>publish/heartbeat.xml</include>
@@ -10,4 +9,5 @@
<include>publish/optical_flow.xml</include>
<include>transfer/file_control.xml</include>
<include>transfer/file_data.xml</include>
<messages/>
</mavlink>
@@ -4,7 +4,7 @@
<dialect>0</dialect>
<messages>
<message id="51021" name="ERROR">
<description>Reportable firmware error emitted when an Err leaves an annotated reporting point. This is distinct from every logging macro, which emits LOG. The stable ID is explicitly assigned to the Rust error variant. Protocol failures remain authoritative in their owning response.</description>
<description>Reportable firmware error emitted when an Err leaves an annotated function. Logging macros emit LOG instead. Protocol failures are reported in their operation responses.</description>
<field type="uint16_t" name="error_id">Stable explicitly assigned error catalog ID. Zero is reserved.</field>
<field type="uint8_t[96]" name="raised_at">Zero-padded compile-time file:line of the reporting point.</field>
</message>
@@ -11,7 +11,7 @@
</enums>
<messages>
<message id="51027" name="HEARTBEAT">
<description>Periodic liveness and discovery publication for the initialized output-inhibited firmware composition.</description>
<description>Liveness and discovery while initialized with outputs inhibited.</description>
<field type="uint8_t" name="state" enum="HEARTBEAT_STATE"/>
</message>
</messages>
@@ -4,10 +4,10 @@
<dialect>0</dialect>
<messages>
<message id="51028" name="IMU">
<description>One accepted body-frame inertial observation in the native bounded sensor representation.</description>
<description>Body-frame inertial observation. Payload offsets are time_us 0, angular_rate 8, acceleration 20 and temperature 26. Payload length is 28 bytes, extra CRC 22.</description>
<field type="uint64_t" name="time_us">Flight-controller monotonic acquisition time in microseconds.</field>
<field type="int16_t[3]" name="acceleration_milligravity">Body-frame acceleration in milligravity.</field>
<field type="int16_t[3]" name="angular_rate_milliradians_per_second">Body-frame angular rate in milliradians per second.</field>
<field type="int32_t[3]" name="angular_rate_milliradians_per_second">Body-frame angular rate in milliradians per second. Preserves the full MPU6000 plus or minus 2000 degrees per second decoded range.</field>
<field type="int16_t" name="temperature_centidegrees_celsius">Sensor temperature in centidegrees Celsius.</field>
</message>
</messages>
@@ -21,7 +21,7 @@
</enums>
<messages>
<message id="51020" name="LOG">
<description>Bounded human-readable runtime log emitted by every Kaptan logging macro, including error. The receiver needs only level, flags, and the valid UTF-8 text prefix. MAVLink framing, bounded admission, DMA, and retries remain platform-owned.</description>
<description>Runtime log emitted by Kaptan logging macros, including error.</description>
<field type="uint8_t" name="level" enum="LOG_LEVEL">Kaptan wire level.</field>
<field type="uint8_t" name="flags" enum="LOG_FLAGS">TRUNCATED=1. Receivers ignore unrecognized bits.</field>
<field type="uint8_t" name="message_length">Number of valid UTF-8 bytes in message; at most 160.</field>
@@ -5,7 +5,7 @@
<messages>
<message id="51029" name="RANGE">
<description>One accepted downward range observation from the board's selected vertical sensor route.</description>
<field type="uint64_t" name="time_us">Flight-controller monotonic measurement-request time in microseconds.</field>
<field type="uint64_t" name="time_us">Producer timestamp in flight-controller monotonic microseconds: measurement request for LIDAR-Lite, frame reception for MTF-01.</field>
<field type="uint32_t" name="distance_millimetres">Measured downward distance in millimetres.</field>
</message>
</messages>
+41 -7
View File
@@ -15,7 +15,7 @@ use crate::{
generated::kaptan::{HEARTBEAT_DATA, HeartbeatState, MavMessage},
};
/// Serializes FC-domain data with MAVLink sender identity and sequence state.
/// Encodes Kaptan messages with one endpoint's sender identity and packet sequence.
pub struct MavlinkEncoder {
system_id: u8,
component_id: u8,
@@ -46,8 +46,7 @@ impl MavlinkEncoder {
header
}
/// Encodes one concrete generated Kaptan record through this endpoint's
/// sole header-sequence owner.
/// Encodes a Kaptan record using this endpoint's packet sequence.
pub fn encode<M: MessageData<Message = MavMessage>>(&mut self, data: &M) -> MavlinkFrame {
let mut raw = MavlinkFrame::new();
raw.serialize_message_data(self.header(), data);
@@ -155,7 +154,7 @@ mod tests {
let snapshot = ImuSnapshot::new(
Timestamp::from_microseconds(123),
[1, 2, 3],
[4, 5, 6],
[-34_871, 34_870, -8_731],
2_500,
);
@@ -166,7 +165,40 @@ mod tests {
Some(MavMessage::IMU(data))
if data.time_us == 123
&& data.acceleration_milligravity == [1, 2, 3]
&& data.angular_rate_milliradians_per_second == [4, 5, 6]
&& data.angular_rate_milliradians_per_second == [-34_871, 34_870, -8_731]
&& data.temperature_centidegrees_celsius == 2_500
));
}
#[cfg(feature = "sensor-telemetry")]
#[test]
fn imu_wire_layout_preserves_the_full_signed_gyro_representation() {
let mut encoder = MavlinkEncoder::new(1, 1);
let snapshot = ImuSnapshot::new(
Timestamp::from_microseconds(123),
[1, -2, 3],
[i32::MIN, i32::MAX, -34_871],
2_500,
);
let frame = encoder.imu_snapshot(&snapshot);
assert_eq!(frame.raw_bytes()[1], 28);
// MAVLink orders fields by scalar width. Widening gyro moves it ahead
// of acceleration. Literal little-endian bytes pin that new contract.
assert_eq!(
&frame.raw_bytes()[10..38],
&[
123, 0, 0, 0, 0, 0, 0, 0, // time_us: u64
0, 0, 0, 0x80, 0xff, 0xff, 0xff, 0x7f, 0xc9, 0x77, 0xff, 0xff, // gyro: i32[3]
1, 0, 0xfe, 0xff, 3, 0, // acceleration: i16[3]
0xc4, 0x09, // temperature: i16
]
);
assert!(matches!(
parse_test_message(&frame),
Some(MavMessage::IMU(data))
if data.time_us == 123
&& data.angular_rate_milliradians_per_second == [i32::MIN, i32::MAX, -34_871]
&& data.acceleration_milligravity == [1, -2, 3]
&& data.temperature_centidegrees_celsius == 2_500
));
}
@@ -219,8 +251,10 @@ mod tests {
assert_eq!(HEARTBEAT_DATA::ENCODED_LEN, 1);
assert_eq!(IMU_DATA::NAME, "IMU");
assert_eq!(IMU_DATA::ID, 51_028);
assert_eq!(IMU_DATA::EXTRA_CRC, 194);
assert_eq!(IMU_DATA::ENCODED_LEN, 22);
// Independently calculated X.25 extra CRC over IMU's wire-ordered
// field types/names/array lengths: u64, i32[3], i16[3], i16.
assert_eq!(IMU_DATA::EXTRA_CRC, 22);
assert_eq!(IMU_DATA::ENCODED_LEN, 28);
assert_eq!(RANGE_DATA::NAME, "RANGE");
assert_eq!(RANGE_DATA::ID, 51_029);
assert_eq!(RANGE_DATA::EXTRA_CRC, 44);
+6 -5
View File
@@ -2,16 +2,17 @@
#![no_std]
/// MAVLink frame encoding API for boards.
/// MAVLink frame encoding API.
pub mod encoder;
/// Serial-byte MAVLink receive API for boards.
/// Incremental MAVLink byte-stream receive API.
pub mod receiver;
/// UART DMA MAVLink frame transmission API for boards.
pub mod transmitter;
/// An owned MAVLink 2 frame suitable for UART transmission.
/// An owned MAVLink 2 frame.
pub type MavlinkFrame = mavlink_core::MAVLinkV2MessageRaw;
/// Generated-message metadata and serialization contract.
pub use mavlink_core::MessageData;
/// Generated wire types for Kaptan's sole MAVLink dialect.
pub mod generated {
include!(concat!(env!("OUT_DIR"), "/kaptan_module.rs"));
@@ -1,313 +0,0 @@
//! MAVLink frame encoding and UART transmission API.
use kaptan_dma::{DmaTxBufferFactory, DmaTxEngine, TxCompletion};
use kaptan_err_reportable::KaptanError;
use kaptan_tx::{PendingFrame, TransmitQueue};
use crate::MavlinkFrame;
/// Delivery importance used for bounded MAVLink transmission admission.
pub use kaptan_tx::{EnqueueResult, FrameClass};
/// Failure while preparing or starting a MAVLink frame transfer.
#[derive(Debug, KaptanError)]
pub enum MavlinkTransmitFault<MemoryError, EngineError> {
/// DMA-visible frame preparation failed.
#[kaptan_error(id = 10001)]
Memory(MemoryError),
/// The UART DMA route rejected a transfer start.
#[kaptan_error(id = 10002)]
Engine(EngineError),
}
/// Combined bounded-admission and DMA-start outcome.
///
/// The outcomes stay distinct because admission decides whether the source may
/// acknowledge its frame, while a failed start keeps reserved work for retry.
#[must_use]
pub struct AdmissionStart<MemoryError, EngineError> {
/// Queue admission for the source frame.
pub admission: EnqueueResult,
/// One bounded attempt to start pending DMA work.
pub start: Result<(), MavlinkTransmitFault<MemoryError, EngineError>>,
}
/// Owns MAVLink frame admission and UART DMA lifecycle.
///
/// Exactly one worker invokes this type. It accepts local encoded frames and
/// received frames alike; the caller owns the policy that decides which frames
/// are transmitted.
pub struct MavlinkTransmitter<Memory, Engine, const N: usize> {
transmit: TransmitQueue<MavlinkFrame, N>,
memory: Memory,
engine: Engine,
}
impl<Memory, Engine, const N: usize> MavlinkTransmitter<Memory, Engine, N> {
/// Creates an empty MAVLink transmitter.
pub const fn new(memory: Memory, engine: Engine) -> Self {
Self {
transmit: TransmitQueue::new(),
memory,
engine,
}
}
}
impl<Memory, Engine, const N: usize> MavlinkTransmitter<Memory, Engine, N>
where
Memory: DmaTxBufferFactory,
Engine: DmaTxEngine,
{
/// Admits one frame and attempts one bounded DMA start while preserving
/// both outcomes. A start failure retains the selected frame in the queue
/// reservation for a later service-turn retry; it does not change whether
/// the newly submitted source frame was admitted.
pub fn admit_and_start(
&mut self,
class: FrameClass,
frame: MavlinkFrame,
) -> AdmissionStart<Memory::Error, Engine::StartError> {
let admission = self.transmit.enqueue(PendingFrame { class, frame });
let start = self.start_next();
AdmissionStart { admission, start }
}
/// Releases a terminal DMA frame and opportunistically starts the next.
///
/// The returned class is the frame that reached a terminal DMA result,
/// not a class selected from newly pending work. A policy can therefore
/// distinguish completion classes without inferring them from newly
/// pending work.
pub fn on_dma_complete(
&mut self,
completion: TxCompletion,
) -> Result<Option<FrameClass>, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
let completed = self.complete_dma(completion)?;
if completion == TxCompletion::Complete {
self.start_next()?;
}
Ok(completed)
}
fn complete_dma(
&mut self,
completion: TxCompletion,
) -> Result<Option<FrameClass>, MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
match completion {
TxCompletion::NotPending => Ok(None),
TxCompletion::Complete => Ok(self.transmit.complete().map(|frame| frame.class)),
TxCompletion::TransferFault | TxCompletion::Halted => {
Ok(self.transmit.fail().map(|frame| frame.class))
}
}
}
fn start_next(
&mut self,
) -> Result<(), MavlinkTransmitFault<Memory::Error, Engine::StartError>> {
let (transmit, memory, engine) = (&mut self.transmit, &mut self.memory, &mut self.engine);
transmit
.start_with(|frame| {
let buffer = memory
.prepare(frame.raw_bytes())
.map_err(MavlinkTransmitFault::Memory)?;
engine.start(buffer).map_err(MavlinkTransmitFault::Engine)
})
.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::{
EnqueueResult, FrameClass, MavlinkTransmitFault, MavlinkTransmitter, PendingFrame,
};
use crate::encoder::MavlinkEncoder;
use kaptan_dma::{DmaReadableBuffer, DmaTxBufferFactory, DmaTxEngine, TxCompletion};
struct Memory;
impl DmaTxBufferFactory for Memory {
type Error = core::convert::Infallible;
fn prepare<'a>(&mut self, bytes: &'a [u8]) -> Result<DmaReadableBuffer<'a>, Self::Error> {
// SAFETY: this host-only test keeps the queue's active frame alive.
Ok(unsafe { DmaReadableBuffer::new_unchecked(bytes) })
}
}
struct Engine;
impl DmaTxEngine for Engine {
type StartError = core::convert::Infallible;
fn start(&mut self, _: DmaReadableBuffer<'_>) -> Result<(), Self::StartError> {
Ok(())
}
}
struct RecordingEngine {
started_sequences: [u8; 4],
starts: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RetryError {
Busy,
}
struct BusyOnceEngine {
busy_once: bool,
starts: usize,
}
impl DmaTxEngine for BusyOnceEngine {
type StartError = RetryError;
fn start(&mut self, _: DmaReadableBuffer<'_>) -> Result<(), Self::StartError> {
if self.busy_once {
self.busy_once = false;
return Err(RetryError::Busy);
}
self.starts += 1;
Ok(())
}
}
impl DmaTxEngine for RecordingEngine {
type StartError = core::convert::Infallible;
fn start(&mut self, buffer: DmaReadableBuffer<'_>) -> Result<(), Self::StartError> {
self.started_sequences[self.starts] = buffer.bytes()[4];
self.starts += 1;
Ok(())
}
}
#[test]
fn transfer_fault_drops_only_the_active_frame() {
let mut encoder = MavlinkEncoder::new(1, 1);
let mut transmitter = MavlinkTransmitter::<Memory, Engine, 2>::new(Memory, Engine);
assert!(
transmitter
.admit_and_start(FrameClass::BestEffort, encoder.heartbeat())
.start
.is_ok()
);
assert!(
transmitter
.admit_and_start(FrameClass::BestEffort, encoder.heartbeat())
.start
.is_ok()
);
assert!(transmitter.transmit.is_active());
transmitter
.on_dma_complete(TxCompletion::TransferFault)
.unwrap();
assert!(!transmitter.transmit.is_active());
}
#[test]
fn reports_critical_queue_saturation_to_the_policy_owner() {
let mut encoder = MavlinkEncoder::new(1, 1);
let mut transmitter = MavlinkTransmitter::<Memory, Engine, 1>::new(Memory, Engine);
assert_eq!(
transmitter
.admit_and_start(FrameClass::Critical, encoder.heartbeat())
.admission,
EnqueueResult::Enqueued
);
assert_eq!(
transmitter
.admit_and_start(FrameClass::Critical, encoder.heartbeat())
.admission,
EnqueueResult::Enqueued
);
assert_eq!(
transmitter
.admit_and_start(FrameClass::Critical, encoder.heartbeat())
.admission,
EnqueueResult::CriticalQueueFull
);
}
#[test]
fn terminal_completion_can_defer_a_transient_engine_busy_without_losing_frame_order() {
let mut encoder = MavlinkEncoder::new(1, 1);
let mut transmitter = MavlinkTransmitter::<Memory, BusyOnceEngine, 2>::new(
Memory,
BusyOnceEngine {
busy_once: false,
starts: 0,
},
);
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
transmitter.start_next().unwrap();
assert_eq!(transmitter.engine.starts, 1);
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
// Model the STM32 case: TCIF is acknowledged but CR.EN still reports
// busy for the first attempt to start the successor. `complete_dma`
// deliberately does not start it in the terminal IRQ path.
assert_eq!(
transmitter.complete_dma(TxCompletion::Complete).unwrap(),
Some(FrameClass::Critical)
);
assert_eq!(transmitter.engine.starts, 1);
transmitter.engine.busy_once = true;
assert!(matches!(
transmitter.start_next(),
Err(MavlinkTransmitFault::Engine(RetryError::Busy))
));
assert!(!transmitter.transmit.is_active());
transmitter.start_next().unwrap();
assert!(transmitter.transmit.is_active());
assert_eq!(transmitter.engine.starts, 2);
}
#[test]
fn completion_retry_starts_pending_best_effort_before_later_critical_frame() {
let mut encoder = MavlinkEncoder::new(1, 1);
let mut transmitter = MavlinkTransmitter::<Memory, RecordingEngine, 3>::new(
Memory,
RecordingEngine {
started_sequences: [0; 4],
starts: 0,
},
);
// A critical frame is active. A best-effort sample arrives behind it
// before DMA completion.
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
transmitter.start_next().unwrap();
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::BestEffort,
frame: encoder.heartbeat(),
});
// The completion worker performs its bounded retry before the next
// periodic sender can enqueue fresh critical traffic. The existing
// best-effort frame therefore cannot starve behind later traffic.
transmitter.complete_dma(TxCompletion::Complete).unwrap();
transmitter.start_next().unwrap();
transmitter.transmit.enqueue(PendingFrame {
class: FrameClass::Critical,
frame: encoder.heartbeat(),
});
assert_eq!(transmitter.engine.starts, 2);
assert_eq!(&transmitter.engine.started_sequences[..2], &[0, 1]);
}
}