Skip to main content

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}