mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 08:31:10 +00:00
288e7f8c46
ci/woodpecker/push/build-arm Pipeline failed
Implements the vendor wire protocol for the A40 gimbal (XOR-8 checksum, not CRC16 — task spec corrected against ArduPilot AP_Mount_Viewpro.h): frame encode/decode, typed FrameId/CameraCommand/ImageSensor, A1 angles, C1 camera, C2 set-zoom command builders, and a tokio UdpSocket transport with bounded retry, per-command deadline, and atomic vendor-fault counters surfaced via faults()/health(). GimbalControllerHandle::set_pose and zoom now ride the transport when wired; remain disabled when no transport is bound. 32/32 gimbal_controller tests green; workspace test suite green except for a pre-existing flake in mission_executor::state_machine::ac3_bounded_retry_then_success that reproduces only under parallel workspace test load (passes 5/5 in isolation; flagged in batch 8 report, unrelated to this batch). Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
12 KiB
Rust
335 lines
12 KiB
Rust
//! Frame encoder / decoder for the ViewPro A40 vendor protocol.
|
|
//!
|
|
//! Wire format reminder (see module docs): `0x55 0xAA 0xDC` header,
|
|
//! length+counter byte, frame id, data, XOR checksum. We expose two
|
|
//! pure functions — [`encode_frame`] (Frame → bytes) and
|
|
//! [`decode_frame`] (bytes → Frame or [`FrameDecodeError`]).
|
|
|
|
use super::checksum::xor_checksum;
|
|
|
|
/// Vendor-fixed maximum packet size, including header (3) + length (1)
|
|
/// + frame id (1) + data + checksum (1). Anything larger is a protocol error.
|
|
pub const MAX_PACKET_LEN: usize = 63;
|
|
|
|
const HEADER_0: u8 = 0x55;
|
|
const HEADER_1: u8 = 0xAA;
|
|
const HEADER_2: u8 = 0xDC;
|
|
const HEADER_LEN: usize = 3;
|
|
|
|
/// Length-byte body-bits mask (bits 0..5).
|
|
const LENGTH_BODY_MASK: u8 = 0x3F;
|
|
/// Length-byte counter-bits shift (bits 6..7).
|
|
const LENGTH_COUNTER_SHIFT: u8 = 6;
|
|
/// Minimum body length (length byte + frame id + at least one data
|
|
/// byte + checksum = 4). Vendor SDK spec.
|
|
pub const MIN_BODY_LEN: u8 = 4;
|
|
/// Maximum body length (vendor SDK spec).
|
|
pub const MAX_BODY_LEN: u8 = 63;
|
|
|
|
/// Frame identifiers we use. Values are vendor-assigned and MUST NOT
|
|
/// be renumbered. See `AP_Mount_Viewpro.h::FrameId`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[repr(u8)]
|
|
pub enum FrameId {
|
|
/// Handshake (sent to gimbal). Gimbal replies with T1_F1_B1_D1.
|
|
Handshake = 0x00,
|
|
/// Communication-config control (sent).
|
|
U = 0x01,
|
|
/// Communication-config status (received reply to U).
|
|
V = 0x02,
|
|
/// Heartbeat (received from gimbal).
|
|
Heartbeat = 0x10,
|
|
/// Target angles — yaw + pitch (sent).
|
|
A1 = 0x1A,
|
|
/// Camera controls, common (sent) — zoom in / zoom out / start
|
|
/// record / stop record / take picture.
|
|
C1 = 0x1C,
|
|
/// Camera controls, less common (sent) — including absolute zoom
|
|
/// (`CameraCommand2::SET_EO_ZOOM`).
|
|
C2 = 0x2C,
|
|
/// Tracking controls, common (sent).
|
|
E1 = 0x1E,
|
|
/// Tracking controls, less common (sent).
|
|
E2 = 0x2E,
|
|
/// Actual roll/pitch/yaw + recording/tracking status (received).
|
|
T1F1B1D1 = 0x40,
|
|
/// Vehicle attitude and position envelope (sent).
|
|
Mahrs = 0xB1,
|
|
}
|
|
|
|
impl FrameId {
|
|
pub fn from_u8(byte: u8) -> Option<Self> {
|
|
match byte {
|
|
0x00 => Some(Self::Handshake),
|
|
0x01 => Some(Self::U),
|
|
0x02 => Some(Self::V),
|
|
0x10 => Some(Self::Heartbeat),
|
|
0x1A => Some(Self::A1),
|
|
0x1C => Some(Self::C1),
|
|
0x2C => Some(Self::C2),
|
|
0x1E => Some(Self::E1),
|
|
0x2E => Some(Self::E2),
|
|
0x40 => Some(Self::T1F1B1D1),
|
|
0xB1 => Some(Self::Mahrs),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decoded frame. The frame-id field is canonicalised to the enum;
|
|
/// the data payload is the raw bytes that followed it in the wire
|
|
/// packet (excluding the length byte and the checksum).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Frame {
|
|
pub frame_id: FrameId,
|
|
pub data: Vec<u8>,
|
|
/// Frame counter the sender stamped into bits 6..7 of the length
|
|
/// byte. Echoed back so callers can correlate request/reply when
|
|
/// the vendor protocol does not provide a separate sequence
|
|
/// number. Range: 0..=3.
|
|
pub frame_counter: u8,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
pub enum FrameDecodeError {
|
|
#[error("buffer too small ({len} bytes; need at least 6)")]
|
|
TooShort { len: usize },
|
|
#[error("buffer too large ({len} bytes; max {max})")]
|
|
TooLong { len: usize, max: usize },
|
|
#[error("bad header bytes [{0:#04x} {1:#04x} {2:#04x}]; expected 55 AA DC")]
|
|
BadHeader(u8, u8, u8),
|
|
#[error("declared body length {declared} mismatches frame size {actual}")]
|
|
BodyLengthMismatch { declared: u8, actual: usize },
|
|
#[error("declared body length {0} out of range {min}..={max}", min = MIN_BODY_LEN, max = MAX_BODY_LEN)]
|
|
BodyLengthOutOfRange(u8),
|
|
#[error("unknown frame id {0:#04x}")]
|
|
UnknownFrameId(u8),
|
|
#[error("checksum mismatch: expected {expected:#04x}, got {actual:#04x}")]
|
|
BadChecksum { expected: u8, actual: u8 },
|
|
}
|
|
|
|
/// Encode a frame for the wire.
|
|
///
|
|
/// `frame_counter` is masked to bits 0..1 and packed into bits 6..7
|
|
/// of the length byte (callers normally use a wrapping 0..=3 counter
|
|
/// owned by the transport).
|
|
///
|
|
/// Returns `None` if the resulting body length would exceed
|
|
/// [`MAX_BODY_LEN`] (the vendor's hard upper bound).
|
|
pub fn encode_frame(frame_id: FrameId, data: &[u8], frame_counter: u8) -> Option<Vec<u8>> {
|
|
// Body length = length byte (1) + frame id (1) + data + checksum (1).
|
|
let body_len_usize = 1 + 1 + data.len() + 1;
|
|
if body_len_usize < MIN_BODY_LEN as usize || body_len_usize > MAX_BODY_LEN as usize {
|
|
return None;
|
|
}
|
|
let body_len = body_len_usize as u8;
|
|
|
|
let counter_bits = (frame_counter & 0b11) << LENGTH_COUNTER_SHIFT;
|
|
let length_byte = (body_len & LENGTH_BODY_MASK) | counter_bits;
|
|
|
|
let mut out = Vec::with_capacity(HEADER_LEN + body_len_usize);
|
|
out.extend_from_slice(&[HEADER_0, HEADER_1, HEADER_2]);
|
|
out.push(length_byte);
|
|
out.push(frame_id as u8);
|
|
out.extend_from_slice(data);
|
|
|
|
// Checksum covers bytes 3..end-of-data. We have not pushed the
|
|
// checksum yet, so the slice is exactly the bytes we want.
|
|
let cs = xor_checksum(&out[HEADER_LEN..]);
|
|
out.push(cs);
|
|
Some(out)
|
|
}
|
|
|
|
/// Decode a frame from the wire. Returns `Err` for any header,
|
|
/// length, frame-id, or checksum violation — the caller (transport)
|
|
/// is responsible for counting these as `vendor_faults_total` and
|
|
/// dropping the frame.
|
|
pub fn decode_frame(buf: &[u8]) -> Result<Frame, FrameDecodeError> {
|
|
if buf.len() < HEADER_LEN + MIN_BODY_LEN as usize {
|
|
return Err(FrameDecodeError::TooShort { len: buf.len() });
|
|
}
|
|
if buf.len() > MAX_PACKET_LEN {
|
|
return Err(FrameDecodeError::TooLong {
|
|
len: buf.len(),
|
|
max: MAX_PACKET_LEN,
|
|
});
|
|
}
|
|
if buf[0] != HEADER_0 || buf[1] != HEADER_1 || buf[2] != HEADER_2 {
|
|
return Err(FrameDecodeError::BadHeader(buf[0], buf[1], buf[2]));
|
|
}
|
|
let length_byte = buf[3];
|
|
let body_len = length_byte & LENGTH_BODY_MASK;
|
|
let frame_counter = length_byte >> LENGTH_COUNTER_SHIFT;
|
|
if !(MIN_BODY_LEN..=MAX_BODY_LEN).contains(&body_len) {
|
|
return Err(FrameDecodeError::BodyLengthOutOfRange(body_len));
|
|
}
|
|
// Body spans buf[3..3+body_len]. The total packet length is
|
|
// header (3) + body_len.
|
|
let expected_total = HEADER_LEN + body_len as usize;
|
|
if buf.len() != expected_total {
|
|
return Err(FrameDecodeError::BodyLengthMismatch {
|
|
declared: body_len,
|
|
actual: buf.len(),
|
|
});
|
|
}
|
|
let frame_id_byte = buf[4];
|
|
let frame_id =
|
|
FrameId::from_u8(frame_id_byte).ok_or(FrameDecodeError::UnknownFrameId(frame_id_byte))?;
|
|
let data_end = buf.len() - 1;
|
|
let data = buf[5..data_end].to_vec();
|
|
let actual_cs = buf[data_end];
|
|
let expected_cs = xor_checksum(&buf[HEADER_LEN..data_end]);
|
|
if expected_cs != actual_cs {
|
|
return Err(FrameDecodeError::BadChecksum {
|
|
expected: expected_cs,
|
|
actual: actual_cs,
|
|
});
|
|
}
|
|
Ok(Frame {
|
|
frame_id,
|
|
data,
|
|
frame_counter,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn round_trip_a1_yaw_command() {
|
|
// Arrange — A1 (target angles) payload:
|
|
// 1 byte ServoStatus + 2 bytes yaw BE + 2 bytes pitch BE + 4 bytes unused = 9 bytes data.
|
|
// Yaw = 30° -> raw = 30/360 * 65536 ≈ 5461.
|
|
let data = vec![0x0B, 0x15, 0x55, 0x00, 0x00, 0, 0, 0, 0];
|
|
|
|
// Act
|
|
let bytes = encode_frame(FrameId::A1, &data, 0).expect("encode");
|
|
let decoded = decode_frame(&bytes).expect("decode");
|
|
|
|
// Assert
|
|
assert_eq!(decoded.frame_id, FrameId::A1);
|
|
assert_eq!(decoded.data, data);
|
|
assert_eq!(decoded.frame_counter, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_c1_zoom_in() {
|
|
// Arrange — C1 (camera command) payload: 2 BE bytes
|
|
// (sensor_zoom_cmd_be). EO1 sensor (0x01) + CameraCommand::ZOOM_IN (0x09)
|
|
// packs as one u16 BE; for this test we just check round-trip.
|
|
let data = vec![0x01, 0x09];
|
|
|
|
// Act
|
|
let bytes = encode_frame(FrameId::C1, &data, 1).expect("encode");
|
|
let decoded = decode_frame(&bytes).expect("decode");
|
|
|
|
// Assert
|
|
assert_eq!(decoded.frame_id, FrameId::C1);
|
|
assert_eq!(decoded.data, data);
|
|
assert_eq!(decoded.frame_counter, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn frame_counter_packs_and_unpacks() {
|
|
// Arrange
|
|
let data = vec![0xAA];
|
|
|
|
// Act + Assert — counter wraps mod 4
|
|
for counter in 0..4u8 {
|
|
let bytes = encode_frame(FrameId::C1, &data, counter).unwrap();
|
|
let decoded = decode_frame(&bytes).unwrap();
|
|
assert_eq!(decoded.frame_counter, counter, "counter={counter}");
|
|
}
|
|
// High bits of the counter argument are masked off
|
|
let bytes = encode_frame(FrameId::C1, &data, 0xFF).unwrap();
|
|
let decoded = decode_frame(&bytes).unwrap();
|
|
assert_eq!(decoded.frame_counter, 0b11);
|
|
}
|
|
|
|
#[test]
|
|
fn corrupted_checksum_is_detected() {
|
|
// Arrange
|
|
let data = vec![0x01, 0x09];
|
|
let mut bytes = encode_frame(FrameId::C1, &data, 0).unwrap();
|
|
let last = bytes.len() - 1;
|
|
bytes[last] ^= 0x01; // flip one bit
|
|
|
|
// Act
|
|
let err = decode_frame(&bytes).unwrap_err();
|
|
|
|
// Assert
|
|
assert!(matches!(err, FrameDecodeError::BadChecksum { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_header_rejected() {
|
|
// Arrange — replace the magic header with 00 00 00
|
|
let mut bytes = encode_frame(FrameId::C1, &[0x01, 0x09], 0).unwrap();
|
|
bytes[0] = 0x00;
|
|
bytes[1] = 0x00;
|
|
bytes[2] = 0x00;
|
|
|
|
// Act
|
|
let err = decode_frame(&bytes).unwrap_err();
|
|
|
|
// Assert
|
|
assert!(matches!(err, FrameDecodeError::BadHeader(0, 0, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_frame_rejected() {
|
|
// Arrange
|
|
let bytes = encode_frame(FrameId::C1, &[0x01, 0x09], 0).unwrap();
|
|
let truncated = &bytes[..bytes.len() - 1];
|
|
|
|
// Act
|
|
let err = decode_frame(truncated).unwrap_err();
|
|
|
|
// Assert
|
|
assert!(matches!(err, FrameDecodeError::BodyLengthMismatch { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_data_falls_under_min_body_len() {
|
|
// Arrange — empty data would mean body_len = 3 (length + frame id + checksum)
|
|
// which is below MIN_BODY_LEN (4). encode_frame rejects.
|
|
|
|
// Act
|
|
let result = encode_frame(FrameId::C1, &[], 0);
|
|
|
|
// Assert
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn oversize_data_rejected_by_encoder() {
|
|
// Arrange — data large enough to overflow MAX_BODY_LEN
|
|
let data = vec![0; MAX_BODY_LEN as usize];
|
|
|
|
// Act
|
|
let result = encode_frame(FrameId::C1, &data, 0);
|
|
|
|
// Assert
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_frame_id_rejected() {
|
|
// Arrange — manually craft a frame with frame_id = 0x99
|
|
let data = vec![0x01, 0x09];
|
|
let bytes_ok = encode_frame(FrameId::C1, &data, 0).unwrap();
|
|
let mut bytes = bytes_ok.clone();
|
|
bytes[4] = 0x99; // overwrite frame id
|
|
// Recompute checksum so the decoder gets to the frame-id check
|
|
let cs_idx = bytes.len() - 1;
|
|
bytes[cs_idx] = xor_checksum(&bytes[3..cs_idx]);
|
|
|
|
// Act
|
|
let err = decode_frame(&bytes).unwrap_err();
|
|
|
|
// Assert
|
|
assert!(matches!(err, FrameDecodeError::UnknownFrameId(0x99)));
|
|
}
|
|
}
|