ados_protocol/mavlink.rs
1//! MAVLink v2 codec for the ardupilotmega dialect.
2//!
3//! Behind the `mavlink` feature because the dialect is a large, slow-to-compile
4//! generated enum and most consumers do not need it. This wraps `rust-mavlink`
5//! with parse/serialize helpers over byte slices, which is what flows on the
6//! MAVLink socket. The router (which owns the serial link and fans frames out
7//! to the socket) builds on this; per the migration plan the decode is
8//! validated against the ground station's decoder before cutover, since
9//! `rust-mavlink` is not assumed to be bit-identical to the audited decoder.
10
11use std::io::Cursor;
12
13use thiserror::Error;
14
15pub use rust_mavlink::dialects::ardupilotmega::MavMessage;
16pub use rust_mavlink::{MavHeader, MavlinkVersion, Message};
17
18// Re-export the dialect module so services built on this crate (the router)
19// can construct and match the concrete message payloads and enums without
20// declaring their own copy of the dialect dependency.
21pub use rust_mavlink::dialects::ardupilotmega;
22
23#[derive(Debug, Error)]
24pub enum MavlinkError {
25 #[error("failed to read MAVLink v2 frame: {0}")]
26 Read(String),
27 #[error("failed to write MAVLink v2 frame: {0}")]
28 Write(String),
29}
30
31/// Parse one MAVLink v2 frame from a byte slice into its header and message.
32pub fn parse_v2(bytes: &[u8]) -> Result<(MavHeader, MavMessage), MavlinkError> {
33 let mut reader = rust_mavlink::peek_reader::PeekReader::new(Cursor::new(bytes));
34 rust_mavlink::read_v2_msg::<MavMessage, _>(&mut reader)
35 .map_err(|e| MavlinkError::Read(e.to_string()))
36}
37
38/// Parse one MAVLink v1 frame (STX `0xFE`) from a byte slice into its header and
39/// message. A v1 frame has a 6-byte header (STX, payload length, sequence,
40/// system id, component id, message id), the payload, and a 2-byte checksum.
41/// There are no incompat/compat flags and no signature block.
42pub fn parse_v1(bytes: &[u8]) -> Result<(MavHeader, MavMessage), MavlinkError> {
43 let mut reader = rust_mavlink::peek_reader::PeekReader::new(Cursor::new(bytes));
44 rust_mavlink::read_v1_msg::<MavMessage, _>(&mut reader)
45 .map_err(|e| MavlinkError::Read(e.to_string()))
46}
47
48/// Parse one MAVLink frame of either protocol version, selected by the leading
49/// start-of-frame magic byte (`0xFD` for v2, `0xFE` for v1). The original frame
50/// bytes are unchanged and are not re-encoded.
51pub fn parse_any(bytes: &[u8]) -> Result<(MavHeader, MavMessage), MavlinkError> {
52 match bytes.first() {
53 Some(&0xFD) => parse_v2(bytes),
54 Some(&0xFE) => parse_v1(bytes),
55 Some(other) => Err(MavlinkError::Read(format!(
56 "unknown MAVLink start-of-frame byte 0x{other:02X}"
57 ))),
58 None => Err(MavlinkError::Read("empty MAVLink frame".to_string())),
59 }
60}
61
62/// The message id the ardupilotmega dialect assigns to `name` (e.g.
63/// `"GLOBAL_POSITION_INT"` → 33), or `None` for a name the dialect does not
64/// define.
65#[must_use]
66pub fn message_id_from_name(name: &str) -> Option<u32> {
67 <MavMessage as rust_mavlink::Message>::message_id_from_name(name)
68}
69
70/// The one incompat flag MAVLink 2 defines (`MAVLINK_IFLAG_SIGNED`).
71const IFLAG_SIGNED: u8 = 0x01;
72
73/// Decode `frame` as exactly one whole MAVLink frame, with no resynchronisation.
74///
75/// [`parse_any`] is a stream reader: on a bad checksum it steps one byte and
76/// scans on for the next start byte, so it can return a message that begins
77/// INSIDE the bytes it was handed. That is the wrong question for a gate
78/// deciding what a buffer carries. This answers "is this buffer one frame a
79/// receiver consumes whole", and refuses everything a receiving parser would
80/// drop partway and rescan from:
81///
82/// * a v2 frame carrying an incompat flag other than the signing bit (the
83/// reference parser drops it at byte 2 and rescans the rest);
84/// * a declared length that does not match the buffer;
85/// * a message id the dialect does not define;
86/// * a payload longer than the message's full encoded length (a parser that
87/// checks message length drops it at the header and rescans);
88/// * a checksum that does not match with the message's CRC_EXTRA;
89/// * a payload that does not parse as the message.
90pub fn decode_exact_frame(frame: &[u8]) -> Result<MavMessage, MavlinkError> {
91 let bad = |why: &str| MavlinkError::Read(why.to_string());
92 let (version, header_len, msg_id, signature_len) = match frame.first() {
93 Some(&0xFD) => {
94 if frame.len() < 10 {
95 return Err(bad("truncated MAVLink v2 header"));
96 }
97 let incompat = frame[2];
98 if incompat & !IFLAG_SIGNED != 0 {
99 return Err(bad("unsupported MAVLink v2 incompat flag"));
100 }
101 let signature = if incompat & IFLAG_SIGNED != 0 { 13 } else { 0 };
102 let id = u32::from_le_bytes([frame[7], frame[8], frame[9], 0]);
103 (MavlinkVersion::V2, 10usize, id, signature)
104 }
105 Some(&0xFE) => {
106 if frame.len() < 6 {
107 return Err(bad("truncated MAVLink v1 header"));
108 }
109 (MavlinkVersion::V1, 6usize, u32::from(frame[5]), 0usize)
110 }
111 Some(_) => return Err(bad("unknown MAVLink start-of-frame byte")),
112 None => return Err(bad("empty MAVLink frame")),
113 };
114 let payload_len = frame[1] as usize;
115 let crc_at = header_len + payload_len;
116 if frame.len() != crc_at + 2 + signature_len {
117 return Err(bad("declared MAVLink length does not match the frame"));
118 }
119 let Some(template) = <MavMessage as rust_mavlink::Message>::default_message_from_id(msg_id)
120 else {
121 return Err(bad("message id is not in the dialect"));
122 };
123 // A v1 serialize never truncates, so it reports the full encoded length.
124 // The dialect is generated without extension fields, so a MAVLink 2
125 // payload may legitimately run past that base length up to the
126 // message's extended length; parse reads the base fields and ignores the
127 // rest, and CRC_EXTRA covers base fields only.
128 let mut scratch = [0u8; 255];
129 let base_len = rust_mavlink::Message::ser(&template, MavlinkVersion::V1, &mut scratch);
130 let max_len = match version {
131 MavlinkVersion::V2 => v2_extended_len(msg_id).map_or(base_len, |ext| ext.max(base_len)),
132 MavlinkVersion::V1 => base_len,
133 };
134 if payload_len > max_len {
135 return Err(bad("payload is longer than the message"));
136 }
137 let mut crc = X25_INIT;
138 for &b in &frame[1..crc_at] {
139 crc = x25_accumulate(b, crc);
140 }
141 crc = x25_accumulate(
142 <MavMessage as rust_mavlink::Message>::extra_crc(msg_id),
143 crc,
144 );
145 if frame[crc_at..crc_at + 2] != crc.to_le_bytes() {
146 return Err(bad("MAVLink checksum mismatch"));
147 }
148 <MavMessage as rust_mavlink::Message>::parse(version, msg_id, &frame[header_len..crc_at])
149 .map_err(|e| MavlinkError::Read(e.to_string()))
150}
151
152/// Full MAVLink 2 payload length (base plus extension fields) for messages the
153/// agent receives with extensions. The generated dialect omits extension
154/// fields, so their length is not derivable from it.
155fn v2_extended_len(msg_id: u32) -> Option<usize> {
156 match msg_id {
157 // OPTICAL_FLOW: 26 base bytes + flow_rate_x/flow_rate_y (2 x f32).
158 100 => Some(34),
159 _ => None,
160 }
161}
162
163/// Whether a complete MAVLink v1/v2 frame carries a valid checksum: the X.25
164/// over every byte after the start-of-frame through the payload, folded with
165/// the message's CRC_EXTRA. `false` for a truncated frame and for a message id
166/// outside the dialect, whose CRC_EXTRA is unknown: its checksum cannot be
167/// checked, and a byte-stream framer resyncing after line noise must not take
168/// an unverifiable candidate as a frame.
169pub fn frame_checksum_ok(frame: &[u8]) -> bool {
170 let (header_len, msg_id) = match frame.first() {
171 Some(&0xFD) if frame.len() >= 10 => (
172 10usize,
173 u32::from_le_bytes([frame[7], frame[8], frame[9], 0]),
174 ),
175 Some(&0xFE) if frame.len() >= 6 => (6usize, u32::from(frame[5])),
176 _ => return false,
177 };
178 let crc_at = header_len + frame[1] as usize;
179 if frame.len() < crc_at + 2 {
180 return false;
181 }
182 if <MavMessage as rust_mavlink::Message>::default_message_from_id(msg_id).is_none() {
183 return false;
184 }
185 let mut crc = X25_INIT;
186 for &b in &frame[1..crc_at] {
187 crc = x25_accumulate(b, crc);
188 }
189 crc = x25_accumulate(
190 <MavMessage as rust_mavlink::Message>::extra_crc(msg_id),
191 crc,
192 );
193 frame[crc_at..crc_at + 2] == crc.to_le_bytes()
194}
195
196/// Serialize a message into a complete MAVLink v2 frame.
197pub fn serialize_v2(header: MavHeader, msg: &MavMessage) -> Result<Vec<u8>, MavlinkError> {
198 let mut buf = Vec::new();
199 rust_mavlink::write_v2_msg(&mut buf, header, msg)
200 .map_err(|e| MavlinkError::Write(e.to_string()))?;
201 Ok(buf)
202}
203
204/// Serialize a message into a complete MAVLink v1 frame (STX `0xFE`).
205pub fn serialize_v1(header: MavHeader, msg: &MavMessage) -> Result<Vec<u8>, MavlinkError> {
206 let mut buf = Vec::new();
207 rust_mavlink::write_v1_msg(&mut buf, header, msg)
208 .map_err(|e| MavlinkError::Write(e.to_string()))?;
209 Ok(buf)
210}
211
212// ---------------------------------------------------------------------------
213// Raw COMMAND_LONG builder for an arbitrary command id.
214// ---------------------------------------------------------------------------
215
216/// MAVLink message id of `COMMAND_LONG` (`0x4C`).
217pub const MSG_ID_COMMAND_LONG: u32 = 76;
218
219/// The `COMMAND_LONG` CRC_EXTRA, the message-definition seed the X.25 checksum
220/// folds in last. This is the value the canonical dialect carries for the message
221/// (the same value the generated codec and the reference Python encoder use);
222/// it is NOT the message id.
223pub const COMMAND_LONG_CRC_EXTRA: u8 = 152;
224
225/// Build a complete MAVLink v2 `COMMAND_LONG` frame for an ARBITRARY `command`
226/// id, returning the raw frame bytes ready to write to the MAVLink socket.
227///
228/// The generated dialect enum only carries named command ids, so a `COMMAND_LONG`
229/// for a command the enum does not name cannot be built through the typed
230/// `MavMessage::COMMAND_LONG` path. This serializes the wire frame directly: the
231/// `COMMAND_LONG` payload (the seven `param`s as little-endian f32, then the
232/// `command` u16, then `target_system` / `target_component` / `confirmation` as
233/// u8, in wire order), MAVLink2 trailing-zero truncation, the v2 header (both the
234/// incompat and compat flags are 0), and the X.25 checksum folded with
235/// [`COMMAND_LONG_CRC_EXTRA`]. Identical on the wire to a `COMMAND_LONG` for a
236/// named id; the only difference is this accepts an id the enum has no variant
237/// for.
238///
239/// `confirmation` is fixed at 0 (the command surface is fire-and-forget). The
240/// frame is unsigned (no MAVLink2 signature; incompat flags = 0).
241#[allow(clippy::too_many_arguments)]
242pub fn build_command_long_v2(
243 header: MavHeader,
244 command: u16,
245 target_system: u8,
246 target_component: u8,
247 params: [f32; 7],
248) -> Vec<u8> {
249 // The COMMAND_LONG payload in wire (field-id) order: the seven f32 params
250 // first (28 bytes), then the u16 command (2), then target_system,
251 // target_component, confirmation (1 each) → 33 bytes max.
252 let mut payload = Vec::with_capacity(33);
253 for p in params {
254 payload.extend_from_slice(&p.to_le_bytes());
255 }
256 payload.extend_from_slice(&command.to_le_bytes());
257 payload.push(target_system);
258 payload.push(target_component);
259 payload.push(0u8); // confirmation
260
261 // MAVLink2 truncates trailing zero bytes off the payload, keeping at least
262 // one byte. The CRC is computed over the truncated payload.
263 truncate_trailing_zeros(&mut payload);
264
265 let mut frame = Vec::with_capacity(10 + payload.len() + 2);
266 frame.push(0xFD); // v2 start-of-frame
267 frame.push(payload.len() as u8); // payload length
268 frame.push(0x00); // incompat flags (unsigned)
269 frame.push(0x00); // compat flags
270 frame.push(header.sequence);
271 frame.push(header.system_id);
272 frame.push(header.component_id);
273 // 3-byte little-endian message id.
274 frame.push((MSG_ID_COMMAND_LONG & 0xFF) as u8);
275 frame.push(((MSG_ID_COMMAND_LONG >> 8) & 0xFF) as u8);
276 frame.push(((MSG_ID_COMMAND_LONG >> 16) & 0xFF) as u8);
277 frame.extend_from_slice(&payload);
278
279 // X.25 checksum over every byte after the start-of-frame, then the CRC_EXTRA.
280 let mut crc = X25_INIT;
281 for &b in &frame[1..] {
282 crc = x25_accumulate(b, crc);
283 }
284 crc = x25_accumulate(COMMAND_LONG_CRC_EXTRA, crc);
285 frame.push((crc & 0xFF) as u8);
286 frame.push((crc >> 8) as u8);
287
288 frame
289}
290
291/// Drop trailing zero bytes off a MAVLink2 payload, keeping at least one byte
292/// (an all-zero payload truncates to a single zero byte, never empty).
293fn truncate_trailing_zeros(payload: &mut Vec<u8>) {
294 while payload.len() > 1 && *payload.last().unwrap() == 0 {
295 payload.pop();
296 }
297}
298
299/// X.25 / CRC-16-MCRF4XX initial value (the MAVLink checksum seed).
300const X25_INIT: u16 = 0xFFFF;
301
302/// Accumulate one byte into the running X.25 checksum, the same per-byte fold the
303/// MAVLink checksum uses (and rust-mavlink's own CRC), so a frame this builder
304/// emits is byte-identical to one the typed serializer would for a named id.
305fn x25_accumulate(byte: u8, crc: u16) -> u16 {
306 let mut tmp = byte ^ (crc & 0xFF) as u8;
307 tmp ^= tmp << 4;
308 let tmp = tmp as u16;
309 (crc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4)
310}
311
312// ---------------------------------------------------------------------------
313// Guided position/velocity setpoint builder (SET_POSITION_TARGET 84 / 86).
314// ---------------------------------------------------------------------------
315
316/// MAVLink message id of `SET_POSITION_TARGET_LOCAL_NED`.
317pub const MSG_ID_SET_POSITION_TARGET_LOCAL_NED: u32 = 84;
318
319/// MAVLink message id of `SET_POSITION_TARGET_GLOBAL_INT`.
320pub const MSG_ID_SET_POSITION_TARGET_GLOBAL_INT: u32 = 86;
321
322/// Which of the two position-target messages a setpoint targets. The local
323/// message carries x/y/z metres in a local/body frame; the global message
324/// carries scaled lat/lon (1e7) plus an altitude in metres in a global frame.
325/// A single-shot send picks one and fills the fields the type mask does not
326/// ignore.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub enum SetpointKind {
329 /// `SET_POSITION_TARGET_LOCAL_NED` (id 84): positions in metres in a
330 /// local/body coordinate frame.
331 LocalNed,
332 /// `SET_POSITION_TARGET_GLOBAL_INT` (id 86): lat/lon scaled by 1e7 and an
333 /// altitude in metres in a global coordinate frame.
334 GlobalInt,
335}
336
337/// A single guided-mode setpoint to send to the autopilot.
338///
339/// This is a transport-agnostic data value, NOT a controller: building one and
340/// sending it is one fire-and-forget MAVLink frame. It owns no flight state,
341/// mode, or schedule. The caller is responsible for the policy around it:
342///
343/// * The autopilot accepts these only while it is in its guided/offboard mode;
344/// the caller must put the vehicle there and confirm it. Building a setpoint
345/// does not change the mode and does not check it.
346/// * A guided setpoint stream is a heartbeat: most autopilots brake (and then
347/// fail-safe, e.g. RTL) roughly three seconds after the last setpoint, so the
348/// caller must re-send at well above that rate (about 3 Hz or faster) to hold
349/// a commanded velocity. One frame on its own decays.
350///
351/// The `*_value` numeric fields are interpreted only where the corresponding
352/// `type_mask` ignore bit is clear; an ignored axis still serializes (as its
353/// stored value) but the autopilot disregards it. The builder validates that
354/// every field that the mask does NOT ignore is finite, so a NaN/inf can never
355/// reach the wire on an active axis.
356#[derive(Debug, Clone, Copy, PartialEq)]
357pub struct GuidedSetpoint {
358 /// Which message (local vs global) to build.
359 pub kind: SetpointKind,
360 /// The coordinate frame (`MAV_FRAME_*`). Validated against the small set of
361 /// frames that are meaningful for the chosen message kind.
362 pub coordinate_frame: u8,
363 /// The `type_mask` ignore bits (`POSITION_TARGET_TYPEMASK_*`). A set bit
364 /// ignores that axis. The high bits above the defined field are rejected so
365 /// a malformed mask cannot smuggle unknown semantics to the autopilot.
366 pub type_mask: u16,
367 /// Position: x/y/z metres for [`SetpointKind::LocalNed`]. For
368 /// [`SetpointKind::GlobalInt`] the x field carries the latitude and y the
369 /// longitude, both already scaled by 1e7 (the wire integer); z is the
370 /// altitude in metres. The builder maps them to the correct wire fields.
371 pub x: f64,
372 pub y: f64,
373 pub z: f64,
374 /// Velocity m/s in the chosen frame.
375 pub vx: f32,
376 pub vy: f32,
377 pub vz: f32,
378 /// Acceleration / force m/s^2 (or N when the force bit is set) in the frame.
379 pub afx: f32,
380 pub afy: f32,
381 pub afz: f32,
382 /// Yaw setpoint (rad) and yaw rate (rad/s).
383 pub yaw: f32,
384 pub yaw_rate: f32,
385}
386
387/// A guided-setpoint validation failure. The message is a stable, human-readable
388/// string a caller can surface or log; it is not localized.
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct SetpointError(pub String);
391
392impl std::fmt::Display for SetpointError {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 f.write_str(&self.0)
395 }
396}
397
398impl std::error::Error for SetpointError {}
399
400/// The full set of `type_mask` bits the position-target messages define. A mask
401/// with any bit outside this set is rejected: only the X/Y/Z, VX/VY/VZ,
402/// AX/AY/AZ, FORCE_SET, YAW, and YAW_RATE bits are meaningful, so an unknown
403/// high bit means a malformed request rather than a new semantic.
404const POSITION_TARGET_TYPEMASK_KNOWN_BITS: u16 = 0x0FFF;
405
406/// Ignore bit: x position. The local message reads x as a coordinate, the global
407/// message reads it as the latitude, so the same bit governs both.
408const TYPEMASK_X_IGNORE: u16 = 1;
409/// Ignore bit: y position (longitude on the global message).
410const TYPEMASK_Y_IGNORE: u16 = 2;
411/// Ignore bit: z position (altitude on the global message).
412const TYPEMASK_Z_IGNORE: u16 = 4;
413const TYPEMASK_VX_IGNORE: u16 = 8;
414const TYPEMASK_VY_IGNORE: u16 = 16;
415const TYPEMASK_VZ_IGNORE: u16 = 32;
416const TYPEMASK_AX_IGNORE: u16 = 64;
417const TYPEMASK_AY_IGNORE: u16 = 128;
418const TYPEMASK_AZ_IGNORE: u16 = 256;
419const TYPEMASK_YAW_IGNORE: u16 = 1024;
420const TYPEMASK_YAW_RATE_IGNORE: u16 = 2048;
421
422/// The coordinate frames valid on `SET_POSITION_TARGET_LOCAL_NED`: a local NED
423/// frame, its offset variant, and the body frames. (`MAV_FRAME_LOCAL_NED` 1,
424/// `MAV_FRAME_LOCAL_OFFSET_NED` 7, `MAV_FRAME_BODY_NED` 8,
425/// `MAV_FRAME_BODY_OFFSET_NED` 9, `MAV_FRAME_BODY_FRD` 12.)
426const LOCAL_FRAMES: &[u8] = &[1, 7, 8, 9, 12];
427
428/// The coordinate frames valid on `SET_POSITION_TARGET_GLOBAL_INT`: the global
429/// frames (`MAV_FRAME_GLOBAL` 0, `MAV_FRAME_GLOBAL_RELATIVE_ALT` 3,
430/// `MAV_FRAME_GLOBAL_INT` 5, `MAV_FRAME_GLOBAL_RELATIVE_ALT_INT` 6).
431const GLOBAL_FRAMES: &[u8] = &[0, 3, 5, 6];
432
433impl GuidedSetpoint {
434 /// Validate the setpoint, returning the first problem found. Checks:
435 /// * the `type_mask` carries no bit outside the defined field, and
436 /// * the coordinate frame is one of the frames meaningful for the message
437 /// kind (local frames for the local message, global frames for the global
438 /// message), and
439 /// * every numeric field on an axis the mask does NOT ignore is finite.
440 ///
441 /// An ignored axis is not range-checked (the autopilot disregards it), so a
442 /// pure-velocity setpoint with a NaN left in an ignored position field is
443 /// accepted — but a NaN on an active axis is rejected.
444 pub fn validate(&self) -> Result<(), SetpointError> {
445 if self.type_mask & !POSITION_TARGET_TYPEMASK_KNOWN_BITS != 0 {
446 return Err(SetpointError(format!(
447 "type_mask 0x{:04X} sets a bit outside the defined position-target field",
448 self.type_mask
449 )));
450 }
451
452 let valid_frames = match self.kind {
453 SetpointKind::LocalNed => LOCAL_FRAMES,
454 SetpointKind::GlobalInt => GLOBAL_FRAMES,
455 };
456 if !valid_frames.contains(&self.coordinate_frame) {
457 return Err(SetpointError(format!(
458 "coordinate_frame {} is not valid for this setpoint kind",
459 self.coordinate_frame
460 )));
461 }
462
463 // Every axis the mask does NOT ignore must be finite. The position
464 // fields are f64 (the global message scales lat/lon as integers, which
465 // an f64 holds exactly); the velocity / accel / yaw fields are f32.
466 let active = |ignore_bit: u16| self.type_mask & ignore_bit == 0;
467 let check_f64 = |v: f64, name: &str| -> Result<(), SetpointError> {
468 if v.is_finite() {
469 Ok(())
470 } else {
471 Err(SetpointError(format!("{name} must be a finite number")))
472 }
473 };
474 // Some position axes narrow to f32 on the wire (the local message's
475 // x/y/z and the global message's altitude). A finite f64 whose
476 // magnitude exceeds f32::MAX would narrow to f32::INFINITY, defeating
477 // the finiteness invariant on the wire, so such an axis is rejected
478 // here when it is active. (Axes the mask ignores tolerate any value;
479 // the wire field is disregarded by the autopilot.)
480 let check_f64_narrows_to_f32 = |v: f64, name: &str| -> Result<(), SetpointError> {
481 if v.is_finite() && (v as f32).is_finite() {
482 Ok(())
483 } else {
484 Err(SetpointError(format!("{name} must be a finite number")))
485 }
486 };
487 let check_f32 = |v: f32, name: &str| -> Result<(), SetpointError> {
488 if v.is_finite() {
489 Ok(())
490 } else {
491 Err(SetpointError(format!("{name} must be a finite number")))
492 }
493 };
494
495 // x/y on the local message narrow to f32; on the global message they
496 // carry scaled lat/lon clamped into i32 (a finite f64 saturates safely),
497 // so the over-f32-MAX rejection applies only to the local kind.
498 let position_narrows_to_f32 = matches!(self.kind, SetpointKind::LocalNed);
499 if active(TYPEMASK_X_IGNORE) {
500 if position_narrows_to_f32 {
501 check_f64_narrows_to_f32(self.x, "x")?;
502 } else {
503 check_f64(self.x, "x")?;
504 }
505 }
506 if active(TYPEMASK_Y_IGNORE) {
507 if position_narrows_to_f32 {
508 check_f64_narrows_to_f32(self.y, "y")?;
509 } else {
510 check_f64(self.y, "y")?;
511 }
512 }
513 if active(TYPEMASK_Z_IGNORE) {
514 // z narrows to f32 on both kinds (local z metres, global altitude).
515 check_f64_narrows_to_f32(self.z, "z")?;
516 }
517 if active(TYPEMASK_VX_IGNORE) {
518 check_f32(self.vx, "vx")?;
519 }
520 if active(TYPEMASK_VY_IGNORE) {
521 check_f32(self.vy, "vy")?;
522 }
523 if active(TYPEMASK_VZ_IGNORE) {
524 check_f32(self.vz, "vz")?;
525 }
526 if active(TYPEMASK_AX_IGNORE) {
527 check_f32(self.afx, "afx")?;
528 }
529 if active(TYPEMASK_AY_IGNORE) {
530 check_f32(self.afy, "afy")?;
531 }
532 if active(TYPEMASK_AZ_IGNORE) {
533 check_f32(self.afz, "afz")?;
534 }
535 if active(TYPEMASK_YAW_IGNORE) {
536 check_f32(self.yaw, "yaw")?;
537 }
538 if active(TYPEMASK_YAW_RATE_IGNORE) {
539 check_f32(self.yaw_rate, "yaw_rate")?;
540 }
541 Ok(())
542 }
543
544 /// Validate, then build the typed [`MavMessage`] for this setpoint. The
545 /// `target_system` / `target_component` address the vehicle; the global
546 /// integer fields are clamped from the f64 inputs into i32 range so an
547 /// out-of-range coordinate saturates rather than wrapping. The
548 /// `time_boot_ms` is fixed at 0 (the autopilot does not require a monotonic
549 /// stamp on a setpoint; ArduPilot ignores it on the inbound path).
550 pub fn build_message(
551 &self,
552 target_system: u8,
553 target_component: u8,
554 ) -> Result<MavMessage, SetpointError> {
555 self.validate()?;
556 let type_mask = ardupilotmega::PositionTargetTypemask::from_bits_truncate(self.type_mask);
557 Ok(match self.kind {
558 SetpointKind::LocalNed => MavMessage::SET_POSITION_TARGET_LOCAL_NED(
559 ardupilotmega::SET_POSITION_TARGET_LOCAL_NED_DATA {
560 time_boot_ms: 0,
561 x: self.x as f32,
562 y: self.y as f32,
563 z: self.z as f32,
564 vx: self.vx,
565 vy: self.vy,
566 vz: self.vz,
567 afx: self.afx,
568 afy: self.afy,
569 afz: self.afz,
570 yaw: self.yaw,
571 yaw_rate: self.yaw_rate,
572 type_mask,
573 target_system,
574 target_component,
575 coordinate_frame: mav_frame_from_u8(self.coordinate_frame),
576 },
577 ),
578 SetpointKind::GlobalInt => {
579 MavMessage::SET_POSITION_TARGET_GLOBAL_INT(
580 ardupilotmega::SET_POSITION_TARGET_GLOBAL_INT_DATA {
581 time_boot_ms: 0,
582 // x carries scaled latitude, y scaled longitude (already
583 // multiplied by 1e7 by the caller); clamp into i32 so an
584 // out-of-range value saturates rather than wraps.
585 lat_int: clamp_to_i32(self.x),
586 lon_int: clamp_to_i32(self.y),
587 alt: self.z as f32,
588 vx: self.vx,
589 vy: self.vy,
590 vz: self.vz,
591 afx: self.afx,
592 afy: self.afy,
593 afz: self.afz,
594 yaw: self.yaw,
595 yaw_rate: self.yaw_rate,
596 type_mask,
597 target_system,
598 target_component,
599 coordinate_frame: mav_frame_from_u8(self.coordinate_frame),
600 },
601 )
602 }
603 })
604 }
605}
606
607/// Clamp an f64 into the i32 range without wrapping (used for the global
608/// message's scaled lat/lon). A non-finite value is validated out before this is
609/// reached on an active axis; an ignored-axis non-finite value clamps to 0.
610fn clamp_to_i32(v: f64) -> i32 {
611 if v.is_nan() {
612 return 0;
613 }
614 v.clamp(i32::MIN as f64, i32::MAX as f64) as i32
615}
616
617/// Map a numeric coordinate-frame id to the dialect enum. An id outside the
618/// validated frame sets never reaches here (the builder validates first), so an
619/// unexpected value falls back to the enum default rather than panicking.
620///
621/// `#[allow(deprecated)]`: the dialect marks MAV_FRAME_GLOBAL_INT,
622/// _GLOBAL_RELATIVE_ALT_INT, _LOCAL_OFFSET_NED, _BODY_NED and _BODY_OFFSET_NED
623/// superseded, but supersession is a spec-authoring statement, not a wire
624/// change: ids 5..=9 are still what a flight controller puts on the link, and
625/// dropping the arms would silently map live frames onto the enum default.
626#[allow(deprecated)]
627fn mav_frame_from_u8(frame: u8) -> ardupilotmega::MavFrame {
628 use ardupilotmega::MavFrame::*;
629 match frame {
630 0 => MAV_FRAME_GLOBAL,
631 1 => MAV_FRAME_LOCAL_NED,
632 3 => MAV_FRAME_GLOBAL_RELATIVE_ALT,
633 5 => MAV_FRAME_GLOBAL_INT,
634 6 => MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
635 7 => MAV_FRAME_LOCAL_OFFSET_NED,
636 8 => MAV_FRAME_BODY_NED,
637 9 => MAV_FRAME_BODY_OFFSET_NED,
638 12 => MAV_FRAME_BODY_FRD,
639 _ => ardupilotmega::MavFrame::DEFAULT,
640 }
641}
642
643// ---------------------------------------------------------------------------
644// SET_ATTITUDE_TARGET attitude setpoint builder (message id 82).
645// ---------------------------------------------------------------------------
646
647/// MAVLink message id of `SET_ATTITUDE_TARGET`.
648pub const MSG_ID_SET_ATTITUDE_TARGET: u32 = 82;
649
650/// The `SET_ATTITUDE_TARGET` CRC_EXTRA, the message-definition seed the X.25
651/// checksum folds in last (the value the canonical dialect carries for the
652/// message; not the id). This is a distinct constant for this message — do not
653/// confuse it with the `CRC_EXTRA` of any other id.
654pub const ATTITUDE_TARGET_CRC_EXTRA: u8 = 49;
655
656// ATTITUDE_TARGET_TYPEMASK bits (the values the ardupilotmega dialect carries
657// for the message's `type_mask` bitfield). A set bit ignores that axis, exactly
658// like the position-target mask semantics — an ignored axis serializes its
659// stored value but the autopilot disregards it.
660const ATTMASK_BODY_ROLL_RATE_IGNORE: u8 = 1;
661const ATTMASK_BODY_PITCH_RATE_IGNORE: u8 = 2;
662const ATTMASK_BODY_YAW_RATE_IGNORE: u8 = 4;
663/// `THRUST_BODY_SET`: the (extension) body-frame thrust vector is set. This
664/// lane never commands it; the bit is accepted as known solely so a mask that
665/// carries it is not misread as malformed.
666const ATTMASK_THRUST_BODY_SET: u8 = 32;
667const ATTMASK_THROTTLE_IGNORE: u8 = 64;
668/// `ATTITUDE_IGNORE`: an unset bit here means the quaternion `q` is a commanded
669/// attitude; when set, only the body rates (and possibly thrust) are meaningful.
670const ATTMASK_ATTITUDE_IGNORE: u8 = 128;
671
672/// The full set of `type_mask` bits the attitude-target message defines. A mask
673/// with any bit outside this set is rejected: only the rate/attitude/thrust
674/// bits are meaningful, so an unknown high bit means a malformed request rather
675/// than a new semantic.
676const ATTITUDE_TARGET_TYPEMASK_KNOWN_BITS: u8 = ATTMASK_BODY_ROLL_RATE_IGNORE
677 | ATTMASK_BODY_PITCH_RATE_IGNORE
678 | ATTMASK_BODY_YAW_RATE_IGNORE
679 | ATTMASK_THRUST_BODY_SET
680 | ATTMASK_THROTTLE_IGNORE
681 | ATTMASK_ATTITUDE_IGNORE;
682
683/// How far from unit length a commanded quaternion may be and still pass
684/// validation. `SET_ATTITUDE_TARGET` consumes `q` as a unit quaternion; a
685/// caller that hands it an unnormalized `q` would let the autopilot interpolate
686/// garbage attitude. This is a strict tolerance: a control law that saturates
687/// or accumulates drift is caught here, on this side of the wire.
688const QUAT_NORM_TOLERANCE: f32 = 1e-3;
689
690/// A single `SET_ATTITUDE_TARGET` payload, frame-independent.
691///
692/// This is a transport-agnostic data value, NOT a controller: building one and
693/// sending it is one fire-and-forget MAVLink frame. It owns no flight state,
694/// mode, or schedule — the caller owns the policy around it (the autopilot
695/// accepts these only in its attitude/offboard mode, and a command stream is a
696/// heartbeat the caller must re-send well above the decay rate). The builder
697/// validates that every field the `type_mask` does NOT ignore is finite, that a
698/// commanded attitude is a unit quaternion, and that a commanded thrust is
699/// within the normalized range, so a NaN / out-of-range command can never reach
700/// the wire on an active axis.
701#[derive(Debug, Clone, Copy, PartialEq)]
702pub struct AttitudeSetpoint {
703 /// The `type_mask` ignore bits (`ATTITUDE_TARGET_TYPEMASK_*`). A set bit
704 /// ignores that axis. The high bits above the defined field are rejected so
705 /// a malformed mask cannot smuggle unknown semantics to the autopilot.
706 pub type_mask: u8,
707 /// Attitude quaternion `(w, x, y, z)`, MAVLink order; zero rotation is
708 /// `(1, 0, 0, 0)`. Meaningful only while `ATTITUDE_IGNORE` is clear.
709 pub q: [f32; 4],
710 /// Body roll rate, rad/s. Ignored when `BODY_ROLL_RATE_IGNORE` is set.
711 pub body_roll_rate: f32,
712 /// Body pitch rate, rad/s. Ignored when `BODY_PITCH_RATE_IGNORE` is set.
713 pub body_pitch_rate: f32,
714 /// Body yaw rate, rad/s. Ignored when `BODY_YAW_RATE_IGNORE` is set.
715 pub body_yaw_rate: f32,
716 /// Collective thrust, normalized `0..1` (`-1..1` for reverse-thrust
717 /// vehicles). Ignored when `THROTTLE_IGNORE` is set.
718 pub thrust: f32,
719}
720
721impl AttitudeSetpoint {
722 /// Validate the setpoint, returning the first problem found. Mirrors
723 /// [`GuidedSetpoint::validate`] so an out-of-range command is refused at
724 /// construction:
725 /// * the `type_mask` carries no bit outside the defined field, and
726 /// * every numeric field on an axis the mask does NOT ignore is finite, and
727 /// * a commanded attitude is a unit quaternion, and
728 /// * a commanded thrust is within the normalized `-1..=1` range.
729 ///
730 /// An ignored axis is not checked (the autopilot disregards it), so a pure
731 /// body-rate command with a NaN left in the (ignored) quaternion is
732 /// accepted — but a NaN or out-of-range value on an active axis is rejected.
733 pub fn validate(&self) -> Result<(), SetpointError> {
734 if self.type_mask & !ATTITUDE_TARGET_TYPEMASK_KNOWN_BITS != 0 {
735 return Err(SetpointError(format!(
736 "type_mask 0x{:02X} sets a bit outside the defined attitude-target field",
737 self.type_mask
738 )));
739 }
740
741 let active = |bit: u8| self.type_mask & bit == 0;
742 let check_f32 = |v: f32, name: &str| -> Result<(), SetpointError> {
743 if v.is_finite() {
744 Ok(())
745 } else {
746 Err(SetpointError(format!("{name} must be a finite number")))
747 }
748 };
749
750 if active(ATTMASK_ATTITUDE_IGNORE) {
751 for (i, c) in self.q.iter().enumerate() {
752 check_f32(*c, &format!("q[{i}]"))?;
753 }
754 let norm = (self.q[0] * self.q[0]
755 + self.q[1] * self.q[1]
756 + self.q[2] * self.q[2]
757 + self.q[3] * self.q[3])
758 .sqrt();
759 if (norm - 1.0).abs() > QUAT_NORM_TOLERANCE {
760 return Err(SetpointError(format!(
761 "q must be a unit quaternion (norm {norm})"
762 )));
763 }
764 }
765 if active(ATTMASK_BODY_ROLL_RATE_IGNORE) {
766 check_f32(self.body_roll_rate, "body_roll_rate")?;
767 }
768 if active(ATTMASK_BODY_PITCH_RATE_IGNORE) {
769 check_f32(self.body_pitch_rate, "body_pitch_rate")?;
770 }
771 if active(ATTMASK_BODY_YAW_RATE_IGNORE) {
772 check_f32(self.body_yaw_rate, "body_yaw_rate")?;
773 }
774 if active(ATTMASK_THROTTLE_IGNORE) {
775 check_f32(self.thrust, "thrust")?;
776 if !(-1.0..=1.0).contains(&self.thrust) {
777 return Err(SetpointError(format!(
778 "thrust {} must be in -1..=1",
779 self.thrust
780 )));
781 }
782 }
783 Ok(())
784 }
785
786 /// Validate, then build the typed [`MavMessage`] for this setpoint. The
787 /// `target_system` / `target_component` address the vehicle; `time_boot_ms`
788 /// is fixed at 0 (the autopilot does not require a monotonic stamp on an
789 /// attitude setpoint; ArduPilot ignores it on the inbound path). The actual
790 /// X.25 CRC is folded with [`ATTITUDE_TARGET_CRC_EXTRA`] (49) by the typed
791 /// serializer, identical to what this builder's own encoder would emit.
792 pub fn build_message(
793 &self,
794 target_system: u8,
795 target_component: u8,
796 ) -> Result<MavMessage, SetpointError> {
797 self.validate()?;
798 let type_mask = ardupilotmega::AttitudeTargetTypemask::from_bits_truncate(self.type_mask);
799 Ok(MavMessage::SET_ATTITUDE_TARGET(
800 ardupilotmega::SET_ATTITUDE_TARGET_DATA {
801 time_boot_ms: 0,
802 q: self.q,
803 body_roll_rate: self.body_roll_rate,
804 body_pitch_rate: self.body_pitch_rate,
805 body_yaw_rate: self.body_yaw_rate,
806 thrust: self.thrust,
807 target_system,
808 target_component,
809 type_mask,
810 },
811 ))
812 }
813}
814
815// ---------------------------------------------------------------------------
816// TUNNEL transparent application-payload pipe (message id 385).
817// ---------------------------------------------------------------------------
818
819/// MAVLink message id of `TUNNEL`.
820pub const MSG_ID_TUNNEL: u32 = 385;
821
822/// The `TUNNEL` CRC_EXTRA, the message-definition seed the X.25 checksum folds in
823/// last (the value the canonical dialect carries for the message; not the id).
824pub const TUNNEL_CRC_EXTRA: u8 = 147;
825
826/// Maximum bytes a single TUNNEL frame can carry: the fixed-size payload array.
827pub const TUNNEL_MAX_PAYLOAD: usize = 128;
828
829/// The highest `payload_type` value reserved by the standard registry. Values at
830/// or below this are reserved; an application uses a value strictly above it so
831/// it can never collide with a registered type, and so an unrelated peer that
832/// does not recognize the value ignores the frame. The numeric field on the wire
833/// is a u16, so the usable application range is `32768..=65535`.
834pub const TUNNEL_RESERVED_PAYLOAD_TYPE_MAX: u16 = 32767;
835
836/// A TUNNEL build/parse failure with a stable, human-readable message (not localized).
837#[derive(Debug, Clone, PartialEq, Eq)]
838pub struct TunnelError(pub String);
839
840impl std::fmt::Display for TunnelError {
841 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
842 f.write_str(&self.0)
843 }
844}
845
846impl std::error::Error for TunnelError {}
847
848/// Build a complete MAVLink v2 `TUNNEL` frame carrying an opaque application
849/// `payload` tagged with a private `payload_type`, returning the raw frame bytes
850/// ready to write to the MAVLink socket.
851///
852/// The generated dialect models `payload_type` as a named enum, so a private
853/// (unregistered) value cannot ride the typed `MavMessage::TUNNEL` path — the
854/// same constraint [`build_command_long_v2`] works around for an unnamed command
855/// id. This serializes the wire frame directly: the `TUNNEL` payload in field
856/// order (`payload_type` u16-LE, `target_system` u8, `target_component` u8,
857/// `payload_length` u8, then the 128-byte payload, the unused tail zero-padded),
858/// MAVLink2 trailing-zero truncation, the v2 header, and the X.25 checksum folded
859/// with [`TUNNEL_CRC_EXTRA`]. The result is byte-identical to a `TUNNEL` the
860/// typed serializer would emit for the same values; the only difference is this
861/// accepts a `payload_type` the enum has no variant for.
862///
863/// The frame is unsigned (no MAVLink2 signature; incompat/compat flags = 0).
864/// The tunnel is a transparent opaque pipe: this owns no application semantics,
865/// so any per-payload integrity (an HMAC, a replay counter) lives inside
866/// `payload`, not here.
867///
868/// Rejects a `payload_type` at or below [`TUNNEL_RESERVED_PAYLOAD_TYPE_MAX`] (a
869/// registered/reserved value, never to be minted by an application) and a
870/// `payload` longer than [`TUNNEL_MAX_PAYLOAD`].
871pub fn build_tunnel_v2(
872 header: MavHeader,
873 payload_type: u16,
874 target_system: u8,
875 target_component: u8,
876 payload: &[u8],
877) -> Result<Vec<u8>, TunnelError> {
878 if payload_type <= TUNNEL_RESERVED_PAYLOAD_TYPE_MAX {
879 return Err(TunnelError(format!(
880 "payload_type {payload_type} must be a private type greater than {TUNNEL_RESERVED_PAYLOAD_TYPE_MAX}"
881 )));
882 }
883 if payload.len() > TUNNEL_MAX_PAYLOAD {
884 return Err(TunnelError(format!(
885 "payload is {} bytes, exceeds the {TUNNEL_MAX_PAYLOAD}-byte TUNNEL limit",
886 payload.len()
887 )));
888 }
889
890 // The TUNNEL payload in wire (field-id) order: payload_type (u16-LE),
891 // target_system, target_component, payload_length, then the fixed 128-byte
892 // payload array (the unused tail stays zero). payload_length records the
893 // application's used count; the array itself is always the full width before
894 // MAVLink2 trailing-zero truncation.
895 let mut body = Vec::with_capacity(5 + TUNNEL_MAX_PAYLOAD);
896 body.extend_from_slice(&payload_type.to_le_bytes());
897 body.push(target_system);
898 body.push(target_component);
899 body.push(payload.len() as u8);
900 body.extend_from_slice(payload);
901 // Zero-pad the fixed-width payload array out to its full size.
902 body.resize(5 + TUNNEL_MAX_PAYLOAD, 0);
903
904 // MAVLink2 truncates trailing zero bytes off the payload, keeping at least
905 // one byte; the CRC is computed over the truncated payload.
906 truncate_trailing_zeros(&mut body);
907
908 let mut frame = Vec::with_capacity(10 + body.len() + 2);
909 frame.push(0xFD); // v2 start-of-frame
910 frame.push(body.len() as u8); // payload length
911 frame.push(0x00); // incompat flags (unsigned)
912 frame.push(0x00); // compat flags
913 frame.push(header.sequence);
914 frame.push(header.system_id);
915 frame.push(header.component_id);
916 // 3-byte little-endian message id.
917 frame.push((MSG_ID_TUNNEL & 0xFF) as u8);
918 frame.push(((MSG_ID_TUNNEL >> 8) & 0xFF) as u8);
919 frame.push(((MSG_ID_TUNNEL >> 16) & 0xFF) as u8);
920 frame.extend_from_slice(&body);
921
922 // X.25 checksum over every byte after the start-of-frame, then the CRC_EXTRA.
923 let mut crc = X25_INIT;
924 for &b in &frame[1..] {
925 crc = x25_accumulate(b, crc);
926 }
927 crc = x25_accumulate(TUNNEL_CRC_EXTRA, crc);
928 frame.push((crc & 0xFF) as u8);
929 frame.push((crc >> 8) as u8);
930
931 Ok(frame)
932}
933
934/// The `payload_type` of a raw MAVLink frame if it is a `TUNNEL` (id 385),
935/// else `None`.
936///
937/// Reads the field straight off the wire bytes rather than going through the
938/// typed parser, because the typed parser rejects a private (unregistered)
939/// `payload_type` as an unknown enum value — exactly the values this pipe
940/// carries. Handles a v2 frame; the standard dialect only emits TUNNEL as v2.
941/// Returns `None` when the frame is not a v2 TUNNEL or is too short to hold the
942/// `payload_type` field.
943pub fn tunnel_payload_type(frame: &[u8]) -> Option<u16> {
944 // v2 frame: STX 0xFD, then a 9-byte header (len, incompat, compat, seq,
945 // sysid, compid, 3-byte msgid), so the payload begins at byte 10. The msgid
946 // is the little-endian 24-bit field at bytes 7..10.
947 if frame.first() != Some(&0xFD) || frame.len() < 10 {
948 return None;
949 }
950 let mut id = [0u8; 4];
951 id[..3].copy_from_slice(&frame[7..10]);
952 if u32::from_le_bytes(id) != MSG_ID_TUNNEL {
953 return None;
954 }
955 // payload_type is the first payload field (u16-LE) at bytes 10..12. A frame
956 // truncated below that (an empty/short payload) is not a usable TUNNEL.
957 if frame.len() < 12 {
958 return None;
959 }
960 Some(u16::from_le_bytes([frame[10], frame[11]]))
961}
962
963/// The opaque application `payload` carried by a `TUNNEL` frame, reconstructed
964/// to its declared length. The symmetric decode of [`build_tunnel_v2`]: it
965/// reads the `payload_length` field and zero-pads the (MAVLink2
966/// trailing-zero-truncated) wire bytes back to that length, so a caller gets
967/// exactly the `payload` slice that was built, even when the tail of that
968/// payload was zeros the transport truncated. Returns `None` when the frame is
969/// not a well-formed v2 TUNNEL.
970///
971/// The TUNNEL payload begins after the v2 header (10 bytes) and the fixed
972/// fields `payload_type`(u16) + `target_system`(u8) + `target_component`(u8) +
973/// `payload_length`(u8), i.e. at body offset 5. The declared length is the
974/// `payload_length` byte; the present wire bytes are however many survived
975/// trailing-zero truncation, and the rest are zeros.
976#[must_use]
977pub fn tunnel_payload(frame: &[u8]) -> Option<Vec<u8>> {
978 tunnel_payload_type(frame)?;
979 // frame = 10-byte v2 header, then `body_len` body bytes, then a 2-byte CRC.
980 let body_len = frame[1] as usize;
981 let body_end = 10usize.checked_add(body_len)?;
982 if frame.len() < body_end + 2 {
983 return None;
984 }
985 let body = &frame[10..body_end];
986 // body = payload_type(2) + target_system(1) + target_component(1) +
987 // payload_length(1) + payload… — the payload starts at body offset 5. When
988 // the payload (and its length byte) are all zeros, MAVLink2 trailing-zero
989 // truncation drops them, so an absent length byte reads as a declared 0.
990 let declared = body.get(4).copied().unwrap_or(0) as usize;
991 let present = body.get(5..).unwrap_or(&[]);
992 if present.len() > declared {
993 // A payload longer on the wire than its own declared length is
994 // malformed for this builder's frames.
995 return None;
996 }
997 let mut payload = present.to_vec();
998 payload.resize(declared, 0);
999 Some(payload)
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use rust_mavlink::dialects::ardupilotmega::HEARTBEAT_DATA;
1006
1007 fn heartbeat() -> MavMessage {
1008 MavMessage::HEARTBEAT(HEARTBEAT_DATA {
1009 custom_mode: 0,
1010 mavtype: ardupilotmega::MavType::MAV_TYPE_QUADROTOR,
1011 autopilot: ardupilotmega::MavAutopilot::MAV_AUTOPILOT_ARDUPILOTMEGA,
1012 base_mode: ardupilotmega::MavModeFlag::empty(),
1013 system_status: ardupilotmega::MavState::MAV_STATE_STANDBY,
1014 mavlink_version: 3,
1015 })
1016 }
1017
1018 #[test]
1019 fn heartbeat_round_trips_through_v2_frame() {
1020 let header = MavHeader {
1021 system_id: 1,
1022 component_id: 1,
1023 sequence: 42,
1024 };
1025 let frame = serialize_v2(header, &heartbeat()).unwrap();
1026 // A v2 frame starts with the 0xFD magic byte.
1027 assert_eq!(frame[0], 0xFD);
1028
1029 let (got_header, got_msg) = parse_v2(&frame).unwrap();
1030 assert_eq!(got_header.system_id, 1);
1031 assert_eq!(got_header.component_id, 1);
1032 assert_eq!(got_header.sequence, 42);
1033 match got_msg {
1034 MavMessage::HEARTBEAT(hb) => {
1035 assert_eq!(hb.mavtype, ardupilotmega::MavType::MAV_TYPE_QUADROTOR);
1036 assert_eq!(hb.mavlink_version, 3);
1037 }
1038 other => panic!("expected HEARTBEAT, got {other:?}"),
1039 }
1040 }
1041
1042 #[test]
1043 fn truncated_frame_is_a_read_error() {
1044 let header = MavHeader::default();
1045 let frame = serialize_v2(header, &heartbeat()).unwrap();
1046 // Drop the last few bytes so the frame is incomplete.
1047 assert!(matches!(
1048 parse_v2(&frame[..frame.len() - 3]),
1049 Err(MavlinkError::Read(_))
1050 ));
1051 }
1052
1053 #[test]
1054 fn heartbeat_round_trips_through_v1_frame() {
1055 let header = MavHeader {
1056 system_id: 1,
1057 component_id: 1,
1058 sequence: 7,
1059 };
1060 let frame = serialize_v1(header, &heartbeat()).unwrap();
1061 // A v1 frame starts with the 0xFE magic byte.
1062 assert_eq!(frame[0], 0xFE);
1063
1064 let (got_header, got_msg) = parse_v1(&frame).unwrap();
1065 assert_eq!(got_header.system_id, 1);
1066 assert_eq!(got_header.component_id, 1);
1067 assert_eq!(got_header.sequence, 7);
1068 assert!(matches!(got_msg, MavMessage::HEARTBEAT(_)));
1069 }
1070
1071 #[test]
1072 fn outbound_frame_is_length_prefixed_and_decode_recovers_it() {
1073 // The MAVLink socket contract frames each outbound raw MAVLink frame
1074 // with a 4-byte big-endian length prefix. A consumer reads the prefix
1075 // with decode_len and then exactly that many payload bytes, recovering
1076 // the original frame verbatim.
1077 use crate::frame::{decode_len, encode_frame, HEADER_SIZE, MAVLINK_MAX_FRAME};
1078 let header = MavHeader {
1079 system_id: 1,
1080 component_id: 1,
1081 sequence: 5,
1082 };
1083 let raw = serialize_v2(header, &heartbeat()).unwrap();
1084
1085 let framed = encode_frame(&raw, MAVLINK_MAX_FRAME).unwrap();
1086 // The prefix is the big-endian length of the raw frame.
1087 let prefix: [u8; HEADER_SIZE] = framed[..HEADER_SIZE].try_into().unwrap();
1088 let len = decode_len(prefix, MAVLINK_MAX_FRAME, false).unwrap();
1089 assert_eq!(len, raw.len());
1090 // The payload after the prefix is the original frame, unchanged.
1091 assert_eq!(&framed[HEADER_SIZE..], raw.as_slice());
1092 // And it still parses as the heartbeat it started as.
1093 let (_, msg) = parse_any(&framed[HEADER_SIZE..]).unwrap();
1094 assert!(matches!(msg, MavMessage::HEARTBEAT(_)));
1095 }
1096
1097 #[test]
1098 fn parse_any_dispatches_on_start_of_frame_byte() {
1099 let header = MavHeader {
1100 system_id: 1,
1101 component_id: 1,
1102 sequence: 3,
1103 };
1104 let v2_frame = serialize_v2(header, &heartbeat()).unwrap();
1105 let v1_frame = serialize_v1(header, &heartbeat()).unwrap();
1106 assert_eq!(v2_frame[0], 0xFD);
1107 assert_eq!(v1_frame[0], 0xFE);
1108
1109 let (_, m2) = parse_any(&v2_frame).unwrap();
1110 let (_, m1) = parse_any(&v1_frame).unwrap();
1111 assert!(matches!(m2, MavMessage::HEARTBEAT(_)));
1112 assert!(matches!(m1, MavMessage::HEARTBEAT(_)));
1113
1114 // An unknown start byte is rejected, not silently parsed.
1115 assert!(matches!(
1116 parse_any(&[0x00, 0x01, 0x02]),
1117 Err(MavlinkError::Read(_))
1118 ));
1119 assert!(matches!(parse_any(&[]), Err(MavlinkError::Read(_))));
1120 }
1121
1122 // ── build_command_long_v2 ────────────────────────────────────────────────
1123
1124 #[test]
1125 fn command_long_builder_matches_the_golden_set_camera_source_frame() {
1126 // The exact 44-byte frame the reference encoder produces for
1127 // SET_CAMERA_SOURCE (command 534) with camera_index=2, source
1128 // system/component 255/190, target 1/1, sequence 0. param2 carries the
1129 // index (2.0); every other param is 0.
1130 let header = MavHeader {
1131 system_id: 255,
1132 component_id: 190,
1133 sequence: 0,
1134 };
1135 let frame = build_command_long_v2(header, 534, 1, 1, [0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1136 let golden = hex_to_bytes(
1137 "fd20000000ffbe4c000000000000000000400000000000000000000000000000000000000000160201019b45",
1138 );
1139 assert_eq!(
1140 frame, golden,
1141 "the built frame must be byte-identical to the golden SET_CAMERA_SOURCE frame"
1142 );
1143 }
1144
1145 #[test]
1146 fn command_long_builder_truncates_trailing_zeros_and_is_44_bytes() {
1147 let header = MavHeader {
1148 system_id: 255,
1149 component_id: 190,
1150 sequence: 0,
1151 };
1152 let frame = build_command_long_v2(header, 534, 1, 1, [0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1153 // 10-byte v2 header + 32-byte truncated payload + 2-byte CRC = 44.
1154 assert_eq!(frame.len(), 44);
1155 assert_eq!(frame[0], 0xFD); // v2 start-of-frame
1156 assert_eq!(frame[1], 32); // truncated payload length
1157 assert_eq!(frame[2], 0x00); // incompat flags (unsigned)
1158 assert_eq!(frame[3], 0x00); // compat flags
1159 // 3-byte LE message id == 76.
1160 assert_eq!(frame[7], 76);
1161 assert_eq!(frame[8], 0);
1162 assert_eq!(frame[9], 0);
1163 }
1164
1165 #[test]
1166 fn command_long_builder_crc_matches_rust_mavlink_for_a_named_id() {
1167 // For a command the dialect DOES name (ARM_DISARM, 400), the raw builder
1168 // must produce the byte-identical frame the typed serializer produces —
1169 // proving the X.25 + CRC_EXTRA fold here is consistent with the codec the
1170 // rest of the agent uses. ARM_DISARM's CRC_EXTRA differs from
1171 // COMMAND_LONG's, but the message id + payload layout are the same, so we
1172 // compare the frame bytes up to (but excluding) the 2-byte CRC, then assert
1173 // both CRCs are well-formed 2-byte tails.
1174 use rust_mavlink::dialects::ardupilotmega::{MavCmd, COMMAND_LONG_DATA};
1175 let header = MavHeader {
1176 system_id: 1,
1177 component_id: 191,
1178 sequence: 7,
1179 };
1180 let typed = MavMessage::COMMAND_LONG(COMMAND_LONG_DATA {
1181 target_system: 1,
1182 target_component: 1,
1183 command: MavCmd::MAV_CMD_COMPONENT_ARM_DISARM,
1184 confirmation: 0,
1185 param1: 1.0,
1186 param2: 0.0,
1187 param3: 0.0,
1188 param4: 0.0,
1189 param5: 0.0,
1190 param6: 0.0,
1191 param7: 0.0,
1192 });
1193 let typed_frame = serialize_v2(header, &typed).unwrap();
1194 // The raw builder for the same command id + params.
1195 let raw_frame =
1196 build_command_long_v2(header, 400, 1, 1, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
1197 // The header + payload (everything but the trailing 2-byte CRC) is
1198 // identical: the raw builder reproduces the typed serializer's framing.
1199 assert_eq!(
1200 &raw_frame[..raw_frame.len() - 2],
1201 &typed_frame[..typed_frame.len() - 2],
1202 "header + payload must match the typed serializer for the same command"
1203 );
1204 }
1205
1206 #[test]
1207 fn command_long_builder_zero_truncation_keeps_one_byte() {
1208 // An all-zero payload truncates to a single byte (never empty), so the
1209 // frame is still a valid, parseable v2 frame.
1210 let mut payload = vec![0u8, 0, 0, 0];
1211 truncate_trailing_zeros(&mut payload);
1212 assert_eq!(payload, vec![0u8]);
1213 }
1214
1215 /// Decode a lowercase hex string into bytes for the golden-frame assertions.
1216 fn hex_to_bytes(s: &str) -> Vec<u8> {
1217 (0..s.len())
1218 .step_by(2)
1219 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
1220 .collect()
1221 }
1222
1223 // ---- guided setpoint builder ----------------------------------------
1224
1225 fn velocity_setpoint(kind: SetpointKind, frame: u8) -> GuidedSetpoint {
1226 // Pure-velocity setpoint: ignore position, accel, yaw; command vx/vy/vz.
1227 let ignore = TYPEMASK_X_IGNORE
1228 | TYPEMASK_Y_IGNORE
1229 | TYPEMASK_Z_IGNORE
1230 | TYPEMASK_AX_IGNORE
1231 | TYPEMASK_AY_IGNORE
1232 | TYPEMASK_AZ_IGNORE
1233 | TYPEMASK_YAW_IGNORE
1234 | TYPEMASK_YAW_RATE_IGNORE;
1235 GuidedSetpoint {
1236 kind,
1237 coordinate_frame: frame,
1238 type_mask: ignore,
1239 x: 0.0,
1240 y: 0.0,
1241 z: 0.0,
1242 vx: 2.5,
1243 vy: -1.0,
1244 vz: 0.5,
1245 afx: 0.0,
1246 afy: 0.0,
1247 afz: 0.0,
1248 yaw: 0.0,
1249 yaw_rate: 0.0,
1250 }
1251 }
1252
1253 #[test]
1254 // Asserts on MAV_FRAME_BODY_NED, superseded in the spec but still the frame
1255 // id this builder is asked for; see `mav_frame_from_u8`.
1256 #[allow(deprecated)]
1257 fn local_ned_setpoint_builds_and_round_trips() {
1258 // A pure-velocity local setpoint in the body frame builds, serializes to
1259 // a real v2 frame, and decodes back to the SAME message id + fields.
1260 let sp = velocity_setpoint(SetpointKind::LocalNed, 8); // MAV_FRAME_BODY_NED
1261 let msg = sp.build_message(1, 1).expect("valid setpoint builds");
1262 let header = MavHeader {
1263 system_id: 1,
1264 component_id: 191,
1265 sequence: 0,
1266 };
1267 let frame = serialize_v2(header, &msg).expect("serialize succeeds");
1268 // The 3-byte v2 message id (bytes 7..10) is 84.
1269 assert_eq!(frame[7], 84, "message id low byte is 84 (LOCAL_NED)");
1270 assert_eq!(frame[8], 0);
1271 assert_eq!(frame[9], 0);
1272 let (_h, decoded) = parse_v2(&frame).expect("decode succeeds");
1273 match decoded {
1274 MavMessage::SET_POSITION_TARGET_LOCAL_NED(d) => {
1275 assert_eq!(d.vx, 2.5);
1276 assert_eq!(d.vy, -1.0);
1277 assert_eq!(d.vz, 0.5);
1278 assert_eq!(d.target_system, 1);
1279 assert_eq!(d.target_component, 1);
1280 assert_eq!(
1281 d.coordinate_frame,
1282 ardupilotmega::MavFrame::MAV_FRAME_BODY_NED
1283 );
1284 // The position-ignore bits round-tripped.
1285 assert!(d.type_mask.contains(
1286 ardupilotmega::PositionTargetTypemask::POSITION_TARGET_TYPEMASK_X_IGNORE
1287 ));
1288 }
1289 other => panic!("expected SET_POSITION_TARGET_LOCAL_NED, got {other:?}"),
1290 }
1291 }
1292
1293 #[test]
1294 // Asserts on MAV_FRAME_GLOBAL_RELATIVE_ALT_INT: see `mav_frame_from_u8`.
1295 #[allow(deprecated)]
1296 fn global_int_setpoint_builds_and_round_trips() {
1297 // A pure-velocity global-int setpoint builds, serializes, and decodes back
1298 // to message id 86 with the velocity fields intact.
1299 let sp = velocity_setpoint(SetpointKind::GlobalInt, 6); // GLOBAL_RELATIVE_ALT_INT
1300 let msg = sp.build_message(1, 1).expect("valid setpoint builds");
1301 let header = MavHeader {
1302 system_id: 1,
1303 component_id: 191,
1304 sequence: 7,
1305 };
1306 let frame = serialize_v2(header, &msg).expect("serialize succeeds");
1307 assert_eq!(frame[7], 86, "message id low byte is 86 (GLOBAL_INT)");
1308 assert_eq!(frame[8], 0);
1309 assert_eq!(frame[9], 0);
1310 let (_h, decoded) = parse_v2(&frame).expect("decode succeeds");
1311 match decoded {
1312 MavMessage::SET_POSITION_TARGET_GLOBAL_INT(d) => {
1313 assert_eq!(d.vx, 2.5);
1314 assert_eq!(d.vy, -1.0);
1315 assert_eq!(d.vz, 0.5);
1316 assert_eq!(
1317 d.coordinate_frame,
1318 ardupilotmega::MavFrame::MAV_FRAME_GLOBAL_RELATIVE_ALT_INT
1319 );
1320 }
1321 other => panic!("expected SET_POSITION_TARGET_GLOBAL_INT, got {other:?}"),
1322 }
1323 }
1324
1325 #[test]
1326 fn global_int_carries_scaled_lat_lon_and_clamps() {
1327 // x/y carry scaled lat/lon (already *1e7); they map to lat_int/lon_int.
1328 let mut sp = velocity_setpoint(SetpointKind::GlobalInt, 5); // GLOBAL_INT
1329 // Command a position too: clear the position-ignore bits.
1330 sp.type_mask &= !(TYPEMASK_X_IGNORE | TYPEMASK_Y_IGNORE | TYPEMASK_Z_IGNORE);
1331 sp.x = 37.422_408 * 1e7; // ~374224080
1332 sp.y = -122.084_270 * 1e7; // ~-1220842700
1333 sp.z = 30.0; // altitude metres
1334 let msg = sp.build_message(1, 1).expect("valid setpoint builds");
1335 match msg {
1336 MavMessage::SET_POSITION_TARGET_GLOBAL_INT(d) => {
1337 assert_eq!(d.lat_int, 374_224_080);
1338 assert_eq!(d.lon_int, -1_220_842_700);
1339 assert_eq!(d.alt, 30.0);
1340 }
1341 other => panic!("expected GLOBAL_INT, got {other:?}"),
1342 }
1343 // A latitude well past i32 range clamps to i32::MAX rather than wrapping.
1344 let mut huge = velocity_setpoint(SetpointKind::GlobalInt, 5);
1345 huge.type_mask &= !TYPEMASK_X_IGNORE;
1346 huge.x = 5e10;
1347 match huge.build_message(1, 1).unwrap() {
1348 MavMessage::SET_POSITION_TARGET_GLOBAL_INT(d) => assert_eq!(d.lat_int, i32::MAX),
1349 other => panic!("expected GLOBAL_INT, got {other:?}"),
1350 }
1351 }
1352
1353 #[test]
1354 fn setpoint_rejects_a_nan_on_an_active_axis() {
1355 let mut sp = velocity_setpoint(SetpointKind::LocalNed, 1);
1356 sp.vx = f32::NAN;
1357 let err = sp.build_message(1, 1).unwrap_err();
1358 assert_eq!(err.0, "vx must be a finite number");
1359 // An infinite commanded velocity is rejected too.
1360 let mut inf = velocity_setpoint(SetpointKind::LocalNed, 1);
1361 inf.vz = f32::INFINITY;
1362 assert_eq!(
1363 inf.build_message(1, 1).unwrap_err().0,
1364 "vz must be a finite number"
1365 );
1366 }
1367
1368 #[test]
1369 fn setpoint_rejects_a_finite_f64_over_f32_range_on_an_active_position_axis() {
1370 // A finite f64 whose magnitude is past f32::MAX would narrow to
1371 // f32::INFINITY on the wire. An active local position axis must reject
1372 // it rather than silently send infinity.
1373 let over = (f32::MAX as f64) * 2.0; // finite f64, but > f32::MAX
1374 assert!(over.is_finite());
1375 assert!((over as f32).is_infinite());
1376
1377 let mut sp = velocity_setpoint(SetpointKind::LocalNed, 1);
1378 sp.type_mask &= !TYPEMASK_X_IGNORE; // command an x position
1379 sp.x = over;
1380 let err = sp.build_message(1, 1).unwrap_err();
1381 assert_eq!(err.0, "x must be a finite number");
1382
1383 // The altitude axis narrows to f32 on both kinds, including global.
1384 let mut alt = velocity_setpoint(SetpointKind::GlobalInt, 5);
1385 alt.type_mask &= !TYPEMASK_Z_IGNORE;
1386 alt.z = over;
1387 assert_eq!(
1388 alt.build_message(1, 1).unwrap_err().0,
1389 "z must be a finite number"
1390 );
1391 }
1392
1393 #[test]
1394 fn setpoint_ignores_a_nan_on_an_ignored_axis() {
1395 // A NaN left in an IGNORED position field is fine: the autopilot
1396 // disregards that axis, and the global clamp turns the NaN into 0.
1397 let mut sp = velocity_setpoint(SetpointKind::GlobalInt, 5);
1398 sp.x = f64::NAN; // x is ignored in the pure-velocity mask
1399 let msg = sp
1400 .build_message(1, 1)
1401 .expect("ignored-axis NaN is accepted");
1402 match msg {
1403 MavMessage::SET_POSITION_TARGET_GLOBAL_INT(d) => assert_eq!(d.lat_int, 0),
1404 other => panic!("expected GLOBAL_INT, got {other:?}"),
1405 }
1406 }
1407
1408 #[test]
1409 fn setpoint_rejects_an_unknown_type_mask_bit() {
1410 let mut sp = velocity_setpoint(SetpointKind::LocalNed, 1);
1411 sp.type_mask |= 0x8000; // a bit above the defined field
1412 let err = sp.build_message(1, 1).unwrap_err();
1413 assert!(
1414 err.0.contains("outside the defined position-target field"),
1415 "got: {}",
1416 err.0
1417 );
1418 }
1419
1420 #[test]
1421 fn setpoint_rejects_a_frame_wrong_for_the_kind() {
1422 // A global frame on a local message is rejected, and vice versa.
1423 let mut local = velocity_setpoint(SetpointKind::LocalNed, 1);
1424 local.coordinate_frame = 5; // MAV_FRAME_GLOBAL_INT, a global frame
1425 assert!(local
1426 .build_message(1, 1)
1427 .unwrap_err()
1428 .0
1429 .contains("not valid for this setpoint kind"));
1430 let mut global = velocity_setpoint(SetpointKind::GlobalInt, 5);
1431 global.coordinate_frame = 1; // MAV_FRAME_LOCAL_NED, a local frame
1432 assert!(global
1433 .build_message(1, 1)
1434 .unwrap_err()
1435 .0
1436 .contains("not valid for this setpoint kind"));
1437 }
1438
1439 // ---- attitude setpoint builder ----------------------------------------
1440
1441 fn body_rate_command() -> AttitudeSetpoint {
1442 // Pure body-rate command: ignore attitude, command rates + thrust.
1443 AttitudeSetpoint {
1444 type_mask: ATTMASK_ATTITUDE_IGNORE,
1445 q: [1.0, 0.0, 0.0, 0.0],
1446 body_roll_rate: 0.5,
1447 body_pitch_rate: -0.2,
1448 body_yaw_rate: 0.1,
1449 thrust: 0.6,
1450 }
1451 }
1452
1453 #[test]
1454 fn attitude_setpoint_builds_and_round_trips() {
1455 let sp = body_rate_command();
1456 let msg = sp.build_message(1, 1).expect("valid setpoint builds");
1457 let header = MavHeader {
1458 system_id: 1,
1459 component_id: 191,
1460 sequence: 0,
1461 };
1462 let frame = serialize_v2(header, &msg).expect("serialize succeeds");
1463 // The 3-byte v2 message id (bytes 7..10) is 82.
1464 assert_eq!(
1465 frame[7], 82,
1466 "message id low byte is 82 (SET_ATTITUDE_TARGET)"
1467 );
1468 assert_eq!(frame[8], 0);
1469 assert_eq!(frame[9], 0);
1470 let (_h, decoded) = parse_v2(&frame).expect("decode succeeds");
1471 match decoded {
1472 MavMessage::SET_ATTITUDE_TARGET(d) => {
1473 assert_eq!(d.body_roll_rate, 0.5);
1474 assert_eq!(d.body_pitch_rate, -0.2);
1475 assert_eq!(d.body_yaw_rate, 0.1);
1476 assert_eq!(d.thrust, 0.6);
1477 assert_eq!(d.target_system, 1);
1478 assert_eq!(d.target_component, 1);
1479 // The attitude-ignore bit round-tripped.
1480 assert!(d.type_mask.contains(
1481 ardupilotmega::AttitudeTargetTypemask::ATTITUDE_TARGET_TYPEMASK_ATTITUDE_IGNORE
1482 ));
1483 }
1484 other => panic!("expected SET_ATTITUDE_TARGET, got {other:?}"),
1485 }
1486 }
1487
1488 #[test]
1489 fn attitude_setpoint_commands_a_quaternion_and_round_trips() {
1490 // Command a full attitude: clear ATTITUDE_IGNORE, set a unit quaternion.
1491 let mut sp = body_rate_command();
1492 sp.type_mask &= !ATTMASK_ATTITUDE_IGNORE;
1493 sp.q = [0.923_879_5, 0.0, 0.0, 0.382_683_43]; // 45° yaw, unit norm
1494 let msg = sp.build_message(1, 1).expect("valid setpoint builds");
1495 let header = MavHeader::default();
1496 let frame = serialize_v2(header, &msg).expect("serialize succeeds");
1497 let (_h, decoded) = parse_v2(&frame).expect("decode succeeds");
1498 match decoded {
1499 MavMessage::SET_ATTITUDE_TARGET(d) => {
1500 assert_eq!(d.q, sp.q);
1501 assert!(!d.type_mask.contains(
1502 ardupilotmega::AttitudeTargetTypemask::ATTITUDE_TARGET_TYPEMASK_ATTITUDE_IGNORE
1503 ));
1504 }
1505 other => panic!("expected SET_ATTITUDE_TARGET, got {other:?}"),
1506 }
1507 }
1508
1509 #[test]
1510 fn attitude_setpoint_rejects_a_non_unit_quaternion() {
1511 let mut sp = body_rate_command();
1512 sp.type_mask &= !ATTMASK_ATTITUDE_IGNORE;
1513 sp.q = [1.0, 0.0, 0.0, 0.0];
1514 sp.q[3] = 0.5; // norm sqrt(1 + 0.25) ≈ 1.118 > tolerance
1515 let err = sp.build_message(1, 1).unwrap_err();
1516 assert!(err.0.contains("unit quaternion"), "got: {}", err.0);
1517 }
1518
1519 #[test]
1520 fn attitude_setpoint_rejects_a_nan_on_an_active_rate_axis() {
1521 let mut sp = body_rate_command();
1522 sp.body_pitch_rate = f32::NAN;
1523 let err = sp.build_message(1, 1).unwrap_err();
1524 assert_eq!(err.0, "body_pitch_rate must be a finite number");
1525 }
1526
1527 #[test]
1528 fn attitude_setpoint_rejects_thrust_out_of_range() {
1529 let mut sp = body_rate_command();
1530 sp.thrust = 1.5;
1531 let err = sp.build_message(1, 1).unwrap_err();
1532 assert!(err.0.contains("must be in -1..=1"), "got: {}", err.0);
1533 // A negative value within the reverse-thrust range is accepted.
1534 sp.thrust = -0.9;
1535 assert!(sp.build_message(1, 1).is_ok());
1536 }
1537
1538 #[test]
1539 fn attitude_setpoint_ignores_a_nan_on_an_ignored_axis() {
1540 let mut sp = body_rate_command();
1541 sp.body_roll_rate = f32::NAN; // ignored (BODY_ROLL_RATE_IGNORE is clear? no)
1542 // The active rate axes are roll/pitch/yaw + thrust; directly ignore roll.
1543 sp.type_mask |= ATTMASK_BODY_ROLL_RATE_IGNORE;
1544 assert!(
1545 sp.build_message(1, 1).is_ok(),
1546 "ignored-axis NaN is accepted"
1547 );
1548 }
1549
1550 #[test]
1551 fn attitude_setpoint_rejects_an_unknown_type_mask_bit() {
1552 let mut sp = body_rate_command();
1553 sp.type_mask |= 0x10; // 16 is not a defined attitude-target bit
1554 let err = sp.build_message(1, 1).unwrap_err();
1555 assert!(
1556 err.0.contains("outside the defined attitude-target field"),
1557 "got: {}",
1558 err.0
1559 );
1560 }
1561
1562 #[test]
1563 fn opponent_attitude_changes_not_rejected_but_crc_const_is_49() {
1564 // Pin the two authoritative constants for the message: id 82, CRC_EXTRA
1565 // 49, payload 39 — so a future dialect bump cannot silently drift them.
1566 assert_eq!(MSG_ID_SET_ATTITUDE_TARGET, 82);
1567 assert_eq!(ATTITUDE_TARGET_CRC_EXTRA, 49);
1568 assert_eq!(ardupilotmega::SET_ATTITUDE_TARGET_DATA::ENCODED_LEN, 39);
1569 }
1570
1571 #[test]
1572 fn tunnel_frame_round_trips_the_payload_and_private_type() {
1573 // Build a TUNNEL with a private payload_type carrying an opaque app
1574 // payload; classify it as TUNNEL, recover the payload_type off the wire,
1575 // and recover the exact payload bytes the application sent.
1576 let header = MavHeader {
1577 system_id: 42,
1578 component_id: 191,
1579 sequence: 3,
1580 };
1581 let payload_type: u16 = 40001;
1582 let app_payload = b"hello-from-a-plugin";
1583 let frame =
1584 build_tunnel_v2(header, payload_type, 1, 1, app_payload).expect("private type builds");
1585
1586 // The 3-byte v2 message id (bytes 7..10) is 385 (0x181) little-endian.
1587 let mut id = [0u8; 4];
1588 id[..3].copy_from_slice(&frame[7..10]);
1589 assert_eq!(u32::from_le_bytes(id), MSG_ID_TUNNEL);
1590
1591 // The classifier sees a TUNNEL and reads the private type straight off
1592 // the wire (the typed parser would reject the unregistered enum value).
1593 assert_eq!(tunnel_payload_type(&frame), Some(payload_type));
1594
1595 // Recover the application payload from the wire. After the 10-byte v2
1596 // header the TUNNEL fields are payload_type (bytes 10..12), target_system
1597 // (12), target_component (13), payload_length (14), then the payload at
1598 // byte 15. Confirm payload_length and the bytes round-trip.
1599 assert_eq!(frame[14] as usize, app_payload.len());
1600 assert_eq!(&frame[15..15 + app_payload.len()], app_payload);
1601 }
1602
1603 #[test]
1604 fn tunnel_payload_recovers_the_exact_bytes_including_trailing_zeros() {
1605 // The decode half must reconstruct a payload whose tail is zeros that
1606 // MAVLink2 trailing-zero truncation stripped off the wire.
1607 let header = MavHeader::default();
1608 for app in [
1609 &b"chunk-header-and-body"[..],
1610 &b""[..],
1611 &[0xAD, 0x03, 1, 0, 9, 0, 0, 0, 0, 0, 0, 0][..], // ends in zeros
1612 &vec![0u8; TUNNEL_MAX_PAYLOAD][..], // all zeros, max width
1613 ] {
1614 let frame = build_tunnel_v2(header, 44480, 7, 1, app).expect("builds");
1615 assert_eq!(tunnel_payload(&frame).as_deref(), Some(app));
1616 }
1617 // A non-TUNNEL frame yields None.
1618 let hb = serialize_v2(header, &heartbeat()).unwrap();
1619 assert_eq!(tunnel_payload(&hb), None);
1620 }
1621
1622 #[test]
1623 fn tunnel_rejects_a_registered_payload_type() {
1624 // A value at or below the private floor is reserved by the registry and
1625 // must be refused, so an application cannot mint a colliding type.
1626 let header = MavHeader::default();
1627 let err = build_tunnel_v2(header, TUNNEL_RESERVED_PAYLOAD_TYPE_MAX, 1, 1, b"x")
1628 .expect_err("a registered type is refused");
1629 assert!(err.0.contains("private type"));
1630 // The very next value up is accepted.
1631 assert!(build_tunnel_v2(header, TUNNEL_RESERVED_PAYLOAD_TYPE_MAX + 1, 1, 1, b"x").is_ok());
1632 }
1633
1634 #[test]
1635 fn tunnel_rejects_an_oversized_payload() {
1636 let header = MavHeader::default();
1637 // One byte over the fixed-array width.
1638 let big = vec![0xABu8; TUNNEL_MAX_PAYLOAD + 1];
1639 let err = build_tunnel_v2(header, 40002, 1, 1, &big)
1640 .expect_err("an oversized payload is refused");
1641 assert!(err.0.contains("exceeds"));
1642 // The maximum width is accepted.
1643 let exact = vec![0xCDu8; TUNNEL_MAX_PAYLOAD];
1644 assert!(build_tunnel_v2(header, 40002, 1, 1, &exact).is_ok());
1645 }
1646
1647 #[test]
1648 fn tunnel_classifier_ignores_a_non_tunnel_frame() {
1649 // A heartbeat frame is not a TUNNEL, so the classifier returns None.
1650 let header = MavHeader::default();
1651 let frame = serialize_v2(header, &heartbeat()).unwrap();
1652 assert_eq!(tunnel_payload_type(&frame), None);
1653 }
1654
1655 /// A v2 frame for `msg_id` over `payload` with a checksum computed the way
1656 /// a sender would (the dialect's CRC_EXTRA for that id).
1657 fn v2_frame_with(msg_id: u32, payload: &[u8], incompat: u8) -> Vec<u8> {
1658 let id = msg_id.to_le_bytes();
1659 let mut frame = vec![
1660 0xFD,
1661 payload.len() as u8,
1662 incompat,
1663 0,
1664 0,
1665 1,
1666 1,
1667 id[0],
1668 id[1],
1669 id[2],
1670 ];
1671 frame.extend_from_slice(payload);
1672 let mut crc = X25_INIT;
1673 for &b in &frame[1..] {
1674 crc = x25_accumulate(b, crc);
1675 }
1676 crc = x25_accumulate(
1677 <MavMessage as rust_mavlink::Message>::extra_crc(msg_id),
1678 crc,
1679 );
1680 frame.extend_from_slice(&crc.to_le_bytes());
1681 frame
1682 }
1683
1684 #[test]
1685 fn exact_decode_accepts_whole_v1_and_v2_frames() {
1686 let header = MavHeader::default();
1687 let v2 = serialize_v2(header, &heartbeat()).unwrap();
1688 assert!(matches!(
1689 decode_exact_frame(&v2),
1690 Ok(MavMessage::HEARTBEAT(_))
1691 ));
1692 let v1 = serialize_v1(header, &heartbeat()).unwrap();
1693 assert!(matches!(
1694 decode_exact_frame(&v1),
1695 Ok(MavMessage::HEARTBEAT(_))
1696 ));
1697 // A hand-built frame with the sender's checksum decodes too.
1698 assert!(decode_exact_frame(&v2_frame_with(0, &[0; 9], 0)).is_ok());
1699 }
1700
1701 #[test]
1702 fn exact_decode_refuses_an_unsupported_incompat_flag() {
1703 // Checksum and length are right; only the flag is foreign. A receiver
1704 // drops this at byte 2 and rescans the payload for a new frame.
1705 let frame = v2_frame_with(0, &[0; 9], 0x02);
1706 assert!(decode_exact_frame(&frame).is_err());
1707 }
1708
1709 #[test]
1710 fn exact_decode_refuses_a_bad_checksum_without_resyncing() {
1711 // A whole ODOMETRY frame hides inside a heartbeat-length buffer whose own
1712 // checksum is wrong. The stream reader resyncs onto it; this must not.
1713 let odometry = <MavMessage as rust_mavlink::Message>::default_message_from_id(331).unwrap();
1714 let inner = serialize_v2(MavHeader::default(), &odometry).unwrap();
1715 let mut outer = v2_frame_with(0, &[0; 9], 0);
1716 let last = outer.len() - 1;
1717 outer[last] ^= 0xFF;
1718 outer.extend_from_slice(&inner);
1719 assert!(parse_v2(&outer).is_ok(), "the stream reader resyncs");
1720 assert!(decode_exact_frame(&outer).is_err());
1721 let mut bad_crc = v2_frame_with(0, &[0; 9], 0);
1722 bad_crc[last] ^= 0xFF;
1723 assert!(decode_exact_frame(&bad_crc).is_err());
1724 }
1725
1726 #[test]
1727 fn exact_decode_refuses_unknown_ids_overlong_payloads_and_length_mismatch() {
1728 assert!(decode_exact_frame(&v2_frame_with(0x00AB_CDEF, &[1, 2, 3], 0)).is_err());
1729 // HEARTBEAT encodes in 9 bytes; a tenth is past the message.
1730 assert!(decode_exact_frame(&v2_frame_with(0, &[0; 10], 0)).is_err());
1731 let whole = v2_frame_with(0, &[0; 9], 0);
1732 assert!(decode_exact_frame(&whole[..whole.len() - 1]).is_err());
1733 let mut trailing = whole.clone();
1734 trailing.push(0);
1735 assert!(decode_exact_frame(&trailing).is_err());
1736 }
1737
1738 #[test]
1739 fn exact_decode_accepts_v2_extension_length_only_up_to_extended_size() {
1740 // OPTICAL_FLOW #100: 26 base bytes, 34 with its extension fields.
1741 assert!(matches!(
1742 decode_exact_frame(&v2_frame_with(100, &[0; 34], 0)),
1743 Ok(MavMessage::OPTICAL_FLOW(_))
1744 ));
1745 assert!(decode_exact_frame(&v2_frame_with(100, &[0; 35], 0)).is_err());
1746 }
1747
1748 #[test]
1749 fn message_names_resolve_to_dialect_ids() {
1750 assert_eq!(message_id_from_name("HEARTBEAT"), Some(0));
1751 assert_eq!(message_id_from_name("GLOBAL_POSITION_INT"), Some(33));
1752 assert_eq!(message_id_from_name("ODOMETRY"), Some(331));
1753 assert_eq!(message_id_from_name("NOT_A_MESSAGE"), None);
1754 }
1755}