diff --git a/libs/kaptan-mavlink/Cargo.toml b/libs/kaptan-mavlink/Cargo.toml
index 382f5e4..b902a37 100644
--- a/libs/kaptan-mavlink/Cargo.toml
+++ b/libs/kaptan-mavlink/Cargo.toml
@@ -10,12 +10,15 @@ version.workspace = true
workspace = true
[features]
-default = ["dialect-kaptan"]
+# Common MAVLink telemetry/arm acknowledgement is the default surface.
+default = ["dialect-kaptan", "sensor-telemetry"]
+# Sensor-domain adapters are physical telemetry only.
+sensor-telemetry = ["dep:kaptan-sensor"]
dialect-kaptan = []
[dependencies]
kaptan-dma = { path = "../kaptan-dma" }
-kaptan-sensor = { path = "../kaptan-sensor" }
+kaptan-sensor = { path = "../kaptan-sensor", optional = true }
kaptan-tx = { path = "../kaptan-tx" }
mavlink = { version = "0.18", default-features = false, features = ["embedded", "dialect-common"] }
mavlink-core = { version = "0.18", default-features = false, features = ["embedded"] }
diff --git a/libs/kaptan-mavlink/definitions/kaptan.xml b/libs/kaptan-mavlink/definitions/kaptan.xml
index 7e10b37..6353b4f 100644
--- a/libs/kaptan-mavlink/definitions/kaptan.xml
+++ b/libs/kaptan-mavlink/definitions/kaptan.xml
@@ -6,4 +6,4 @@
-
\ No newline at end of file
+
diff --git a/libs/kaptan-mavlink/src/encoder.rs b/libs/kaptan-mavlink/src/encoder.rs
index d4d558f..01a393a 100644
--- a/libs/kaptan-mavlink/src/encoder.rs
+++ b/libs/kaptan-mavlink/src/encoder.rs
@@ -1,13 +1,17 @@
//! MAVLink frame encoding API.
+#[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::imu::ImuSnapshot;
+#[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::optical_flow::OpticalFlowSnapshot;
+#[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::range::RangeSnapshot;
use mavlink::MessageData;
use crate::{MavlinkFrame, message};
/// Board-selected MAVLink metadata for a range sensor.
+#[cfg(feature = "sensor-telemetry")]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DistanceSensorConfig {
/// MAVLink sensor instance identifier.
@@ -52,12 +56,12 @@ impl MavlinkEncoder {
raw
}
- /// Encodes a Kaptan heartbeat with its current arming state.
+ /// Encodes a board-I/O heartbeat with its current arming state.
pub fn heartbeat(&mut self, system_status: message::MavState, armed: bool) -> MavlinkFrame {
let heartbeat = message::HEARTBEAT_DATA {
custom_mode: 0,
- mavtype: message::MavType::MAV_TYPE_QUADROTOR,
- autopilot: message::MavAutopilot::MAV_AUTOPILOT_GENERIC,
+ mavtype: message::MavType::MAV_TYPE_ONBOARD_CONTROLLER,
+ autopilot: message::MavAutopilot::MAV_AUTOPILOT_INVALID,
base_mode: if armed {
message::MavModeFlag::MAV_MODE_FLAG_SAFETY_ARMED
} else {
@@ -80,6 +84,7 @@ impl MavlinkEncoder {
}
/// Encodes a standard `RAW_IMU` frame.
+ #[cfg(feature = "sensor-telemetry")]
pub fn raw_imu_snapshot(&mut self, snapshot: &ImuSnapshot) -> MavlinkFrame {
let raw_imu = message::RAW_IMU_DATA {
time_usec: snapshot.timestamp.as_microseconds(),
@@ -101,6 +106,7 @@ impl MavlinkEncoder {
///
/// MAVLink represents distance in centimetres; the sensor domain keeps
/// millimetres so this conversion happens only at the protocol boundary.
+ #[cfg(feature = "sensor-telemetry")]
pub fn distance_sensor_snapshot(
&mut self,
snapshot: &RangeSnapshot,
@@ -126,6 +132,7 @@ impl MavlinkEncoder {
/// Encodes a standard `OPTICAL_FLOW` frame from a ground-referenced flow
/// observation.
+ #[cfg(feature = "sensor-telemetry")]
pub fn optical_flow_snapshot(
&mut self,
snapshot: &OpticalFlowSnapshot,
@@ -150,11 +157,17 @@ impl MavlinkEncoder {
#[cfg(test)]
mod tests {
- use super::{DistanceSensorConfig, MavlinkEncoder};
+ #[cfg(feature = "sensor-telemetry")]
+ use super::DistanceSensorConfig;
+ use super::MavlinkEncoder;
use crate::message::MavState;
+ #[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::imu::ImuSnapshot;
+ #[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::optical_flow::OpticalFlowSnapshot;
+ #[cfg(feature = "sensor-telemetry")]
use kaptan_sensor::range::RangeSnapshot;
+ #[cfg(feature = "sensor-telemetry")]
use kaptan_time::Timestamp;
#[test]
@@ -180,6 +193,7 @@ mod tests {
assert_eq!(frame.raw_bytes()[17], MavState::MAV_STATE_CRITICAL as u8);
}
+ #[cfg(feature = "sensor-telemetry")]
#[test]
fn snapshot_adapter_uses_flight_data_timestamp_and_units() {
let mut encoder = MavlinkEncoder::new(1, 1);
@@ -203,6 +217,7 @@ mod tests {
assert_ne!(frame.raw_bytes()[16] & 0x80, 0);
}
+ #[cfg(feature = "sensor-telemetry")]
#[test]
fn range_snapshot_uses_distance_sensor_centimetres() {
let mut encoder = MavlinkEncoder::new(1, 1);
@@ -225,6 +240,7 @@ mod tests {
assert_eq!(&bytes[18..20], &291u16.to_le_bytes());
}
+ #[cfg(feature = "sensor-telemetry")]
#[test]
fn optical_flow_snapshot_uses_the_optical_flow_message() {
let mut encoder = MavlinkEncoder::new(1, 1);
diff --git a/libs/kaptan-mavlink/src/receiver.rs b/libs/kaptan-mavlink/src/receiver.rs
index a3df6a5..d3d1e05 100644
--- a/libs/kaptan-mavlink/src/receiver.rs
+++ b/libs/kaptan-mavlink/src/receiver.rs
@@ -4,9 +4,9 @@ use crate::{MavlinkFrame, message};
/// Bounded MAVLink 2 receiver for a board's serial-byte interrupt.
///
-/// Boards pass UART bytes to [`Self::receive_byte`] and take complete,
-/// CRC-valid frames from [`Self::take_received`]. The API never decodes or
-/// dispatches received message payloads.
+/// Boards pass UART bytes to [`Self::receive_byte`] and take complete frames
+/// from [`Self::take_received`]. Production has no signing verifier or replay
+/// state, so it never exposes an inbound MAVLink frame as an authority record.
pub struct MavlinkReceiver {
frame: MavlinkFrame,
received: usize,
@@ -23,7 +23,12 @@ impl Default for MavlinkReceiver {
}
impl MavlinkReceiver {
- /// Creates a receiver with space for `N` complete MAVLink frames.
+ /// Creates a production receiver with space for `N` complete frames.
+ ///
+ /// TODO(signing): accept a frame only after verified MAVLink-v2 signing,
+ /// configured sender/target binding, persistent anti-replay state, and
+ /// provisioning/rotation policy exist together. A V2 CRC only detects
+ /// corruption; it is not authentication.
pub const fn new() -> Self {
Self {
frame: MavlinkFrame::new(),
@@ -38,8 +43,10 @@ impl MavlinkReceiver {
/// Receives one UART byte and returns whether it queued a complete frame.
///
/// Bytes outside MAVLink 2 frames and frames with invalid CRCs are
- /// discarded. If the completed-frame queue is full, the new frame is
- /// dropped without displacing an earlier received frame.
+ /// discarded. Signed frames are also discarded because this receiver has
+ /// no verifier or replay state. Until the TODO(signing) work is complete,
+ /// unsigned frames are deliberately not promoted to received records
+ /// either.
pub fn receive_byte(&mut self, byte: u8) -> bool {
if self.received == 0 {
if byte == 0xfd {
@@ -54,23 +61,22 @@ impl MavlinkReceiver {
if self.received == 10 {
let header = self.frame.as_slice();
- let incompatibility_flags = header[2];
- if incompatibility_flags & !1 != 0 {
+ // MAVLink's bit 0 means the packet carries a signature. Reject all
+ // nonzero incompatibility flags until a reviewed signature verifier
+ // and persistent replay policy exist.
+ if header[2] != 0 {
self.reset();
return false;
}
self.expected = 1 + 9 + usize::from(header[1]) + 2;
- if incompatibility_flags & 1 != 0 {
- self.expected += 13;
- }
}
if self.expected != 0 && self.received == self.expected {
let frame = core::mem::replace(&mut self.frame, MavlinkFrame::new());
self.received = 0;
self.expected = 0;
- if frame.has_valid_crc::() {
- return self.push(frame);
+ if !frame.has_valid_crc::() {
+ return false;
}
}
@@ -85,6 +91,9 @@ impl MavlinkReceiver {
}
/// Takes the oldest complete received frame, if one is available.
+ ///
+ /// The production receiver returns no frame until signing verification is
+ /// implemented, but retaining this API keeps dispatch ownership explicit.
pub fn take_received(&mut self) -> Option {
if self.length == 0 {
return None;
@@ -95,14 +104,9 @@ impl MavlinkReceiver {
frame
}
- fn push(&mut self, frame: MavlinkFrame) -> bool {
- if N == 0 || self.length == N {
- return false;
- }
- let index = (self.front + self.length) % N;
- self.frames[index] = Some(frame);
- self.length += 1;
- true
+ /// Returns whether at least one complete frame awaits dispatch.
+ pub const fn has_received(&self) -> bool {
+ self.length != 0
}
}
@@ -131,23 +135,64 @@ mod tests {
frame
}
- #[test]
- fn returns_complete_crc_valid_frames_unchanged() {
- let expected = heartbeat(0);
- let mut receiver = MavlinkReceiver::<1>::new();
-
- for byte in expected.raw_bytes() {
- receiver.receive_byte(*byte);
- }
-
- assert_eq!(
- receiver.take_received().unwrap().raw_bytes(),
- expected.raw_bytes()
+ fn heartbeat_v1(sequence: u8) -> mavlink::MAVLinkV1MessageRaw {
+ let mut frame = mavlink::MAVLinkV1MessageRaw::new();
+ frame.serialize_message_data(
+ message::MavHeader {
+ sequence,
+ system_id: 42,
+ component_id: 7,
+ },
+ &message::HEARTBEAT_DATA {
+ custom_mode: 0,
+ mavtype: message::MavType::MAV_TYPE_QUADROTOR,
+ autopilot: message::MavAutopilot::MAV_AUTOPILOT_GENERIC,
+ base_mode: message::MavModeFlag::empty(),
+ system_status: message::MavState::MAV_STATE_STANDBY,
+ mavlink_version: 3,
+ },
);
+ frame
}
#[test]
- fn discards_frames_with_an_invalid_crc() {
+ fn discards_a_crc_valid_mavlink_v1_frame() {
+ let mut receiver = MavlinkReceiver::<1>::new();
+ let v1 = heartbeat_v1(3);
+ assert_eq!(v1.raw_bytes()[0], 0xfe);
+
+ for byte in v1.raw_bytes() {
+ assert!(!receiver.receive_byte(*byte));
+ }
+
+ assert_eq!(receiver.take_received(), None);
+ }
+
+ #[test]
+ fn production_rejects_unsigned_frames_without_a_signing_verifier() {
+ let frame = heartbeat(0);
+ let mut receiver = MavlinkReceiver::<1>::new();
+ for byte in frame.raw_bytes() {
+ assert!(!receiver.receive_byte(*byte));
+ }
+ assert!(!receiver.has_received());
+ assert_eq!(receiver.take_received(), None);
+ }
+
+ #[test]
+ fn rejects_signed_v2_before_accepting_any_payload() {
+ let mut receiver = MavlinkReceiver::<1>::new();
+ // The first ten bytes contain the complete MAVLink 2 header. This
+ // frame advertises a signature (incompatibility bit 0), so rejection
+ // happens before its payload can be considered.
+ for byte in [0xfd, 0, 1, 0, 0, 1, 1, 0, 0, 0] {
+ assert!(!receiver.receive_byte(byte));
+ }
+ assert!(!receiver.has_received());
+ }
+
+ #[test]
+ fn a_crc_error_cannot_create_a_received_record() {
let frame = heartbeat(0);
let mut bytes = frame.into_inner();
bytes[10] ^= 1;
@@ -157,25 +202,6 @@ mod tests {
for byte in corrupt.raw_bytes() {
assert!(!receiver.receive_byte(*byte));
}
- }
-
- #[test]
- fn preserves_arrival_order() {
- let first = heartbeat(0);
- let second = heartbeat(1);
- let mut receiver = MavlinkReceiver::<2>::new();
-
- for byte in first.raw_bytes().iter().chain(second.raw_bytes()) {
- receiver.receive_byte(*byte);
- }
-
- assert_eq!(
- receiver.take_received().unwrap().raw_bytes(),
- first.raw_bytes()
- );
- assert_eq!(
- receiver.take_received().unwrap().raw_bytes(),
- second.raw_bytes()
- );
+ assert!(!receiver.has_received());
}
}
diff --git a/libs/kaptan-mavlink/src/transmitter.rs b/libs/kaptan-mavlink/src/transmitter.rs
index d27f6b9..08ae589 100644
--- a/libs/kaptan-mavlink/src/transmitter.rs
+++ b/libs/kaptan-mavlink/src/transmitter.rs
@@ -54,20 +54,66 @@ where
class: FrameClass,
frame: MavlinkFrame,
) -> Result> {
- let admission = self.transmit.enqueue(PendingFrame { class, frame });
- self.start_next().map(|()| admission)
+ let admission = self.enqueue_frame(class, frame);
+ self.start_pending().map(|()| admission)
+ }
+
+ /// Admits a frame without attempting to start the DMA engine.
+ ///
+ /// This split lifecycle is for hardware routes whose terminal DMA IRQ can
+ /// arrive a few bus cycles before the stream's `EN` bit reads clear. Such
+ /// a route may acknowledge a completion, retain the selected frame, and
+ /// retry [`Self::start_pending`] from its next bounded service turn rather
+ /// than treating that transient `Busy` observation as a transport fault.
+ /// Callers still own the policy for a queue-full result.
+ pub fn enqueue_frame(&mut self, class: FrameClass, frame: MavlinkFrame) -> EnqueueResult {
+ self.transmit.enqueue(PendingFrame { class, frame })
+ }
+
+ /// Attempts to start one pending DMA frame.
+ ///
+ /// A rejected engine start leaves the exact frame reserved for a later
+ /// retry. This method therefore does not discard or overwrite DMA source
+ /// storage on a transient hardware-not-ready result.
+ pub fn start_pending(
+ &mut self,
+ ) -> Result<(), MavlinkTransmitFault> {
+ self.start_next()
}
/// Releases a terminal DMA frame and opportunistically starts the next.
- pub fn on_dma_complete(&mut self, completion: TxCompletion) {
+ ///
+ /// 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