mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-22 13:21:09 +00:00
[AZ-643] [AZ-665] [AZ-672] mavlink+mapobjects+vlm batch 4
ci/woodpecker/push/build-arm Pipeline failed
ci/woodpecker/push/build-arm Pipeline failed
AZ-643 mavlink_layer:
- ack demux on COMMAND_LONG/COMMAND_ACK with oneshot dispatch and
configurable deadline; MavlinkHandle::send_command + SendCommandError
- MAVLink-2 signing: Signer/Verifier built on SHA-256, key + timestamp
source, incompat-flag wiring in encoder, reject + counter in decoder
- new tests: tests/ack_demux.rs (3) + tests/signing.rs (5)
AZ-665 mapobjects_store:
- internal/h3_index.rs (h3o wrapper, cell_of, grid_disk, haversine)
- internal/store.rs (in-memory (cell -> Vec<MapObject>) hashmap with
k-ring classify and class-group resolution)
- public API: MapObjectsStoreHandle::classify(ClassifyInput) ->
Classification {New|Moved|Existing}
- AC1-4 in tests/classify.rs; AC5 perf gate (#[ignore], passes in
--release)
AZ-672 vlm_client + autopilot:
- DisabledVlmProvider in shared::contracts; VlmProvider::name() for
composition-root diagnostics
- vlm_client::VlmClient gated behind feature = "vlm"; placeholder
until AZ-673 lands the real NanoLLM IPC
- autopilot: vlm_client is now optional = true under feature vlm;
Runtime::select_vlm_provider picks DisabledVlmProvider when feature
off OR config.vlm.enabled = false
Workspace deps: +sha2 (mavlink signing), +h3o (mapobjects index).
Batch report: _docs/03_implementation/batch_04_cycle1_report.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
//! AZ-643 — ack-demux integration tests (AC-1 happy path, AC-2 timeout).
|
||||
//!
|
||||
//! A fake UDP peer either acks immediately or stays silent; the autopilot side
|
||||
//! issues `send_command(...)` and asserts on the resolution.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use mavlink_layer::{
|
||||
CommandAck, CommandLong, Decoder, DecoderEvent, Encoder, Heartbeat, MavlinkConnection,
|
||||
MavlinkLayer, MavlinkLayerOptions, MavlinkMessage, SendCommandError,
|
||||
};
|
||||
|
||||
const MAV_CMD_NAV_RETURN_TO_LAUNCH: u16 = 20;
|
||||
const MAV_RESULT_ACCEPTED: u8 = 0;
|
||||
const SHORT_TIMEOUT_MS: u64 = 250;
|
||||
|
||||
fn options_for(uri: String, link_timeout_ms: u64) -> MavlinkLayerOptions {
|
||||
let mut o = MavlinkLayerOptions::new(MavlinkConnection::new(uri));
|
||||
o.link_timeout = Duration::from_millis(link_timeout_ms);
|
||||
o.reconnect_base = Duration::from_millis(50);
|
||||
o.reconnect_cap = Duration::from_millis(200);
|
||||
// Keep the ack deadline tight so AC-2 finishes fast.
|
||||
o.command_ack_deadline = Duration::from_millis(500);
|
||||
o
|
||||
}
|
||||
|
||||
async fn drain_first_heartbeat_addr(peer: &UdpSocket) -> std::net::SocketAddr {
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let (_, layer_addr) = timeout(Duration::from_secs(2), peer.recv_from(&mut buf))
|
||||
.await
|
||||
.expect("first heartbeat must arrive")
|
||||
.expect("udp recv_from");
|
||||
layer_addr
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac1_send_command_happy_path() {
|
||||
// Arrange: a peer that acks any inbound COMMAND_LONG promptly.
|
||||
let peer = UdpSocket::bind("127.0.0.1:0").await.expect("bind peer");
|
||||
let peer_addr = peer.local_addr().expect("peer addr").to_string();
|
||||
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let (layer, handle) =
|
||||
MavlinkLayer::new(options_for(format!("udp://{peer_addr}"), SHORT_TIMEOUT_MS));
|
||||
tokio::spawn(layer.run(shutdown_rx));
|
||||
|
||||
// Capture the layer's source address from its first heartbeat.
|
||||
let layer_addr = drain_first_heartbeat_addr(&peer).await;
|
||||
let peer_enc = Encoder::new(2, 1);
|
||||
|
||||
// Peer task: on every inbound COMMAND_LONG, reply with COMMAND_ACK.
|
||||
let peer_arc = std::sync::Arc::new(peer);
|
||||
let peer_for_task = peer_arc.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut dec = Decoder::new();
|
||||
let mut buf = vec![0u8; 2048];
|
||||
loop {
|
||||
let n = match peer_for_task.recv(&mut buf).await {
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
};
|
||||
for ev in dec.feed(&buf[..n]) {
|
||||
if let DecoderEvent::Message {
|
||||
message: MavlinkMessage::CommandLong(cl),
|
||||
..
|
||||
} = ev
|
||||
{
|
||||
let ack = peer_enc.encode(&MavlinkMessage::CommandAck(CommandAck {
|
||||
command: cl.command,
|
||||
result: MAV_RESULT_ACCEPTED,
|
||||
}));
|
||||
let _ = peer_for_task.send_to(&ack, layer_addr).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Act: call send_command and await resolution.
|
||||
let cmd = CommandLong {
|
||||
param1: 0.0,
|
||||
param2: 0.0,
|
||||
param3: 0.0,
|
||||
param4: 0.0,
|
||||
param5: 0.0,
|
||||
param6: 0.0,
|
||||
param7: 0.0,
|
||||
command: MAV_CMD_NAV_RETURN_TO_LAUNCH,
|
||||
target_system: 1,
|
||||
target_component: 1,
|
||||
confirmation: 0,
|
||||
};
|
||||
let ack = timeout(Duration::from_secs(2), handle.send_command(cmd, None))
|
||||
.await
|
||||
.expect("ack must arrive within 2 s")
|
||||
.expect("send_command must succeed");
|
||||
|
||||
// Assert: ack matches and in-flight map is clear.
|
||||
assert_eq!(ack.command, MAV_CMD_NAV_RETURN_TO_LAUNCH);
|
||||
assert_eq!(ack.result, MAV_RESULT_ACCEPTED);
|
||||
assert_eq!(
|
||||
handle.commands_in_flight(),
|
||||
0,
|
||||
"in-flight map must be drained"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ac2_send_command_timeout_returns_explicit_error() {
|
||||
// Arrange: a peer that NEVER acks.
|
||||
let peer = UdpSocket::bind("127.0.0.1:0").await.expect("bind peer");
|
||||
let peer_addr = peer.local_addr().expect("peer addr").to_string();
|
||||
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let (layer, handle) =
|
||||
MavlinkLayer::new(options_for(format!("udp://{peer_addr}"), SHORT_TIMEOUT_MS));
|
||||
tokio::spawn(layer.run(shutdown_rx));
|
||||
|
||||
// Pull the layer's first heartbeat just so the link is open.
|
||||
let _ = drain_first_heartbeat_addr(&peer).await;
|
||||
|
||||
let cmd = CommandLong {
|
||||
param1: 0.0,
|
||||
param2: 0.0,
|
||||
param3: 0.0,
|
||||
param4: 0.0,
|
||||
param5: 0.0,
|
||||
param6: 0.0,
|
||||
param7: 0.0,
|
||||
command: MAV_CMD_NAV_RETURN_TO_LAUNCH,
|
||||
target_system: 1,
|
||||
target_component: 1,
|
||||
confirmation: 0,
|
||||
};
|
||||
|
||||
// Act
|
||||
let result = handle
|
||||
.send_command(cmd, Some(Duration::from_millis(300)))
|
||||
.await;
|
||||
|
||||
// Assert
|
||||
match result {
|
||||
Err(SendCommandError::Timeout(d)) => {
|
||||
assert_eq!(d, Duration::from_millis(300));
|
||||
}
|
||||
other => panic!("expected Timeout, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
handle.commands_in_flight(),
|
||||
0,
|
||||
"in-flight map must be cleared on timeout (no leaks)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Defensive coverage: a stray COMMAND_ACK without a matching waiter must not
|
||||
/// crash the link or leak entries.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unmatched_ack_is_dropped_without_side_effect() {
|
||||
// Arrange
|
||||
let peer = UdpSocket::bind("127.0.0.1:0").await.expect("bind peer");
|
||||
let peer_addr = peer.local_addr().expect("peer addr").to_string();
|
||||
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let (layer, handle) =
|
||||
MavlinkLayer::new(options_for(format!("udp://{peer_addr}"), SHORT_TIMEOUT_MS));
|
||||
tokio::spawn(layer.run(shutdown_rx));
|
||||
let layer_addr = drain_first_heartbeat_addr(&peer).await;
|
||||
|
||||
// Act: send a HEARTBEAT (to keep watchdog happy) and a stray COMMAND_ACK.
|
||||
let peer_enc = Encoder::new(2, 1);
|
||||
let hb = peer_enc.encode(&MavlinkMessage::Heartbeat(Heartbeat {
|
||||
custom_mode: 0,
|
||||
mavtype: 2,
|
||||
autopilot: 3,
|
||||
base_mode: 0,
|
||||
system_status: 4,
|
||||
mavlink_version: 3,
|
||||
}));
|
||||
peer.send_to(&hb, layer_addr).await.unwrap();
|
||||
let stray = peer_enc.encode(&MavlinkMessage::CommandAck(CommandAck {
|
||||
command: MAV_CMD_NAV_RETURN_TO_LAUNCH,
|
||||
result: MAV_RESULT_ACCEPTED,
|
||||
}));
|
||||
peer.send_to(&stray, layer_addr).await.unwrap();
|
||||
|
||||
// Give the layer a beat to process.
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Assert
|
||||
assert_eq!(handle.commands_in_flight(), 0);
|
||||
let snap = handle.parse_errors();
|
||||
assert_eq!(snap.signing_mismatch, 0);
|
||||
assert_eq!(snap.crc, 0);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! AZ-643 — MAVLink-2 signing integration tests (AC-3 rejection, AC-4 disabled).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use mavlink_layer::{
|
||||
Decoder, DecoderEvent, Encoder, Heartbeat, MavlinkConnection, MavlinkLayer,
|
||||
MavlinkLayerOptions, MavlinkMessage, Signer, SigningKey, SigningReject, Verifier,
|
||||
};
|
||||
|
||||
fn options_for(uri: String) -> MavlinkLayerOptions {
|
||||
let mut o = MavlinkLayerOptions::new(MavlinkConnection::new(uri));
|
||||
o.link_timeout = Duration::from_millis(500);
|
||||
o.reconnect_base = Duration::from_millis(50);
|
||||
o.reconnect_cap = Duration::from_millis(200);
|
||||
o
|
||||
}
|
||||
|
||||
fn fixed_key(b: u8) -> SigningKey {
|
||||
let mut k = [0u8; 32];
|
||||
for (i, byte) in k.iter_mut().enumerate() {
|
||||
*byte = b.wrapping_add(i as u8);
|
||||
}
|
||||
SigningKey::new(k)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_decoder_rejects_bad_signature() {
|
||||
// Arrange: build a signed frame, then flip one bit in the signature trailer.
|
||||
let signer = Signer::new(fixed_key(0x10), 5);
|
||||
let encoder = Encoder::with_signer(1, 191, signer);
|
||||
let _ = encoder; // signing is exercised through encode()
|
||||
|
||||
// Use a separate signer-on-encoder to produce a signed frame for the test.
|
||||
let local_signer = Encoder::with_signer(1, 191, Signer::new(fixed_key(0x10), 5));
|
||||
let mut frame = local_signer.encode(&MavlinkMessage::Heartbeat(Heartbeat {
|
||||
custom_mode: 0,
|
||||
mavtype: 2,
|
||||
autopilot: 3,
|
||||
base_mode: 0,
|
||||
system_status: 4,
|
||||
mavlink_version: 3,
|
||||
}));
|
||||
let last = frame.len() - 1;
|
||||
frame[last] ^= 0x01;
|
||||
|
||||
// Act: feed it to a decoder with the matching verifier.
|
||||
let mut dec = Decoder::with_verifier(Verifier::new(fixed_key(0x10)));
|
||||
let events = dec.feed(&frame);
|
||||
|
||||
// Assert
|
||||
let rejected = events.iter().find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
DecoderEvent::SigningMismatch {
|
||||
reason: SigningReject::BadSignature,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
rejected.is_some(),
|
||||
"expected SigningMismatch event, got {events:?}"
|
||||
);
|
||||
assert_eq!(dec.errors.snapshot().signing_mismatch, 1);
|
||||
// The HEARTBEAT must NOT have been emitted as a Message.
|
||||
let emitted = events
|
||||
.iter()
|
||||
.any(|e| matches!(e, DecoderEvent::Message { .. }));
|
||||
assert!(!emitted, "rejected frame must not surface as Message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_signed_frame_with_matching_key_passes() {
|
||||
// Arrange
|
||||
let encoder = Encoder::with_signer(1, 191, Signer::new(fixed_key(0xAB), 9));
|
||||
let frame = encoder.encode(&MavlinkMessage::Heartbeat(Heartbeat {
|
||||
custom_mode: 0,
|
||||
mavtype: 2,
|
||||
autopilot: 3,
|
||||
base_mode: 0,
|
||||
system_status: 4,
|
||||
mavlink_version: 3,
|
||||
}));
|
||||
|
||||
// Act
|
||||
let mut dec = Decoder::with_verifier(Verifier::new(fixed_key(0xAB)));
|
||||
let events = dec.feed(&frame);
|
||||
|
||||
// Assert
|
||||
let mut got_message = false;
|
||||
let mut got_mismatch = false;
|
||||
for ev in &events {
|
||||
match ev {
|
||||
DecoderEvent::Message {
|
||||
message: MavlinkMessage::Heartbeat(_),
|
||||
..
|
||||
} => got_message = true,
|
||||
DecoderEvent::SigningMismatch { .. } => got_mismatch = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
got_message,
|
||||
"valid signed heartbeat must surface as Message"
|
||||
);
|
||||
assert!(!got_mismatch, "valid signature must not trigger mismatch");
|
||||
assert_eq!(dec.errors.snapshot().signing_mismatch, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac4_signing_disabled_ignores_signature_field() {
|
||||
// Arrange: build BOTH a signed frame and an unsigned frame.
|
||||
let signed_enc = Encoder::with_signer(1, 191, Signer::new(fixed_key(0x33), 1));
|
||||
let unsigned_enc = Encoder::new(1, 191);
|
||||
let hb = MavlinkMessage::Heartbeat(Heartbeat {
|
||||
custom_mode: 0,
|
||||
mavtype: 2,
|
||||
autopilot: 3,
|
||||
base_mode: 0,
|
||||
system_status: 4,
|
||||
mavlink_version: 3,
|
||||
});
|
||||
let signed_frame = signed_enc.encode(&hb);
|
||||
let unsigned_frame = unsigned_enc.encode(&hb);
|
||||
|
||||
// Act: feed both into a Decoder with NO verifier (signing disabled).
|
||||
let mut dec = Decoder::new();
|
||||
let signed_events = dec.feed(&signed_frame);
|
||||
let unsigned_events = dec.feed(&unsigned_frame);
|
||||
|
||||
// Assert: both surface as Message, signing_mismatch counter stays at 0.
|
||||
let signed_ok = signed_events.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
DecoderEvent::Message {
|
||||
message: MavlinkMessage::Heartbeat(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let unsigned_ok = unsigned_events.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
DecoderEvent::Message {
|
||||
message: MavlinkMessage::Heartbeat(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(signed_ok, "with verifier=None, signed frames must decode");
|
||||
assert!(
|
||||
unsigned_ok,
|
||||
"with verifier=None, unsigned frames must decode"
|
||||
);
|
||||
assert_eq!(
|
||||
dec.errors.snapshot().signing_mismatch,
|
||||
0,
|
||||
"signing_mismatch counter must stay at 0 in disabled mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsigned_frame_rejected_when_verifier_present() {
|
||||
// Defensive coverage: per the MAVLink spec, with signing enabled the
|
||||
// decoder rejects unsigned frames. AC-3 only specifies the bad-signature
|
||||
// case, but the spec-consistent behaviour is to reject both.
|
||||
let unsigned_enc = Encoder::new(1, 191);
|
||||
let frame = unsigned_enc.encode(&MavlinkMessage::Heartbeat(Heartbeat {
|
||||
custom_mode: 0,
|
||||
mavtype: 2,
|
||||
autopilot: 3,
|
||||
base_mode: 0,
|
||||
system_status: 4,
|
||||
mavlink_version: 3,
|
||||
}));
|
||||
|
||||
let mut dec = Decoder::with_verifier(Verifier::new(fixed_key(0x44)));
|
||||
let events = dec.feed(&frame);
|
||||
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
DecoderEvent::SigningMismatch {
|
||||
reason: SigningReject::Unsigned,
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(dec.errors.snapshot().signing_mismatch, 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn signing_enabled_layer_reports_via_health() {
|
||||
// Arrange: a layer with signing on, plus a peer that captures the frames.
|
||||
let peer = UdpSocket::bind("127.0.0.1:0").await.expect("bind peer");
|
||||
let peer_addr = peer.local_addr().expect("peer addr").to_string();
|
||||
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let mut opts = options_for(format!("udp://{peer_addr}"));
|
||||
opts.signing = Some(mavlink_layer::SigningOptions {
|
||||
key: fixed_key(0x55),
|
||||
link_id: 3,
|
||||
});
|
||||
let (layer, handle) = MavlinkLayer::new(opts);
|
||||
tokio::spawn(layer.run(shutdown_rx));
|
||||
|
||||
// Act: wait for one heartbeat so we have at least one signed frame.
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let n = timeout(Duration::from_secs(2), peer.recv(&mut buf))
|
||||
.await
|
||||
.expect("heartbeat must arrive within 2 s")
|
||||
.expect("udp recv");
|
||||
|
||||
// Assert: incompat_flags bit 0 (signed) is set on the outbound frame.
|
||||
assert!(n >= 10, "frame too short");
|
||||
assert!(
|
||||
handle.signing_enabled(),
|
||||
"signing_enabled() must reflect config"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[2] & 0x01,
|
||||
0x01,
|
||||
"outbound frame must have INCOMPAT_FLAG_SIGNED set when signing is enabled"
|
||||
);
|
||||
|
||||
let detail = handle.health().detail.unwrap_or_default();
|
||||
assert!(
|
||||
detail.contains("signing_enabled=true"),
|
||||
"health detail must surface signing_enabled=true; got {detail:?}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("commands_in_flight=0"),
|
||||
"health detail must surface commands_in_flight; got {detail:?}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user