pamoja_mavlink/vehicle.rs
1//! A MAVLink vehicle modelled as a pamoja [`Device`].
2//!
3//! [`Vehicle`] wraps a [`Connection`] over any [`ByteLink`] and presents an autopilot through
4//! the core device traits, so a PX4 or ArduPilot vehicle drives like any other pamoja device
5//! from any language binding. It maps the three surfaces a ground station needs onto the
6//! device model:
7//!
8//! - [`Device`] - [`connect`](Device::connect) waits for the vehicle's first heartbeat (and
9//! learns its system and component id), and the vehicle's stable [`id`](Device::id) is its
10//! MAVLink address.
11//! - [`Telemetry`] - [`next_frame`](Telemetry::next_frame) yields the next decoded telemetry
12//! [`Report`].
13//! - [`Actuator`] - [`apply`](Actuator::apply) streams an offboard [`Setpoint`].
14//!
15//! On top of those it offers the mission and command surfaces as async methods that drive the
16//! sans-IO [`protocol`](crate::protocol) machines over the link, applying the mission
17//! protocol's timeout-and-retransmit rules: [`upload_mission`](Vehicle::upload_mission) and
18//! [`download_mission`](Vehicle::download_mission) run the plan transfer, and
19//! [`send_command`](Vehicle::send_command) (with [`arm`](Vehicle::arm),
20//! [`set_mode`](Vehicle::set_mode), [`takeoff`](Vehicle::takeoff), and friends) run the command
21//! protocol.
22//!
23//! This layer is available with the default `std` feature; the wire core and the protocol
24//! machines below it are `no_std`.
25
26use std::time::Duration;
27
28use pamoja_core::{Actuator, Device, Error as CoreError, Result as CoreResult, Telemetry};
29use tokio::time::timeout;
30
31use crate::dialect::{
32 mav_autopilot, mav_cmd, mav_mission_result, mav_mission_type, mav_state, mav_type, Attitude,
33 BatteryStatus, CommandAck, CommandLong, GlobalPositionInt, GpsRawInt, Heartbeat, Message,
34 MissionAck, MissionCount, MissionItemInt, MissionRequest, MissionRequestInt,
35 SetPositionTargetGlobalInt, SetPositionTargetLocalNed, Statustext, SysStatus, VfrHud,
36};
37use crate::frame::Frame;
38use crate::link::{ByteLink, Connection};
39use crate::protocol::command::{AckOutcome, CommandProtocol};
40use crate::protocol::mission::{MissionReceiver, MissionSender, ReceiverAction};
41use crate::protocol::MAX_RETRIES;
42use crate::signing::{Signer, Verifier};
43use crate::MavlinkError;
44
45/// How long to wait for a response before retransmitting, as the mission and command protocols
46/// recommend for their request/response messages.
47const RESPONSE_TIMEOUT: Duration = Duration::from_millis(1500);
48
49/// How long [`connect`](Vehicle::connect) waits for the vehicle's first heartbeat.
50const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
51
52/// The conventional ground-station component id.
53pub const GCS_COMPONENT: u8 = 190;
54
55/// A decoded telemetry report from a [`Vehicle`].
56///
57/// The common messages a ground station displays are decoded into typed variants; anything
58/// else is carried as a [`Report::Other`] raw frame so no traffic is lost.
59#[derive(Clone, Debug)]
60#[non_exhaustive]
61pub enum Report {
62 /// The periodic heartbeat announcing the vehicle's type, autopilot, and status.
63 Heartbeat(Heartbeat),
64 /// Onboard sensor health, load, and battery state.
65 SysStatus(SysStatus),
66 /// The raw GPS fix.
67 GpsRawInt(GpsRawInt),
68 /// Orientation and angular rates.
69 Attitude(Attitude),
70 /// The fused global position, altitude, and velocity.
71 GlobalPositionInt(GlobalPositionInt),
72 /// The heads-up flight summary.
73 VfrHud(VfrHud),
74 /// Battery charge, current, and per-cell voltages.
75 BatteryStatus(BatteryStatus),
76 /// A human-readable status message.
77 Statustext(Statustext),
78 /// Any other message, carried as the raw frame. Boxed because a [`Frame`] is far larger
79 /// than a decoded message, so the common typed reports stay small to move around.
80 Other(Box<Frame>),
81}
82
83impl Report {
84 /// Decodes a frame into a typed report, falling back to [`Report::Other`].
85 ///
86 /// # Arguments
87 ///
88 /// * `frame` - the received frame.
89 ///
90 /// # Returns
91 ///
92 /// The decoded report.
93 fn from_frame(frame: &Frame) -> Report {
94 // A telemetry decode of a well-formed frame does not fail (a short payload is
95 // zero-extended), so a decode error falls back to the raw frame rather than dropping it.
96 // `unwrap_or_else` keeps the success path from boxing the frame it does not need.
97 let raw = || Report::Other(Box::new(*frame));
98 match frame.message_id() {
99 Heartbeat::ID => Heartbeat::decode(frame.payload())
100 .map(Report::Heartbeat)
101 .unwrap_or_else(|_| raw()),
102 SysStatus::ID => SysStatus::decode(frame.payload())
103 .map(Report::SysStatus)
104 .unwrap_or_else(|_| raw()),
105 GpsRawInt::ID => GpsRawInt::decode(frame.payload())
106 .map(Report::GpsRawInt)
107 .unwrap_or_else(|_| raw()),
108 Attitude::ID => Attitude::decode(frame.payload())
109 .map(Report::Attitude)
110 .unwrap_or_else(|_| raw()),
111 GlobalPositionInt::ID => GlobalPositionInt::decode(frame.payload())
112 .map(Report::GlobalPositionInt)
113 .unwrap_or_else(|_| raw()),
114 VfrHud::ID => VfrHud::decode(frame.payload())
115 .map(Report::VfrHud)
116 .unwrap_or_else(|_| raw()),
117 BatteryStatus::ID => BatteryStatus::decode(frame.payload())
118 .map(Report::BatteryStatus)
119 .unwrap_or_else(|_| raw()),
120 Statustext::ID => Statustext::decode(frame.payload())
121 .map(Report::Statustext)
122 .unwrap_or_else(|_| raw()),
123 _ => raw(),
124 }
125 }
126}
127
128/// An offboard control setpoint, in the local or global frame.
129///
130/// Build one with the constructors on [`SetPositionTargetLocalNed`] and
131/// [`SetPositionTargetGlobalInt`] (in [`protocol::offboard`](crate::protocol::offboard)), then
132/// stream it with [`Actuator::apply`].
133#[derive(Clone, Copy, Debug, PartialEq)]
134pub enum Setpoint {
135 /// A setpoint in the local NED frame.
136 Local(SetPositionTargetLocalNed),
137 /// A setpoint in the global frame.
138 Global(SetPositionTargetGlobalInt),
139}
140
141/// A MAVLink vehicle over a [`ByteLink`], exposed through the pamoja device model.
142///
143/// A vehicle sends as a ground station (its own system and component id) and addresses a target
144/// vehicle. The target is learned from the first heartbeat unless it is pinned with
145/// [`with_target`](Vehicle::with_target). Attach signing with
146/// [`with_signer`](Vehicle::with_signer) and [`with_verifier`](Vehicle::with_verifier).
147pub struct Vehicle<L> {
148 connection: Connection<L>,
149 id: String,
150 target_system: u8,
151 target_component: u8,
152 autodetect_target: bool,
153}
154
155impl<L: ByteLink> Vehicle<L> {
156 /// Creates a vehicle client sending as the given ground-station identity.
157 ///
158 /// The target vehicle defaults to system 1, component 1, and is updated from the first
159 /// heartbeat seen during [`connect`](Device::connect).
160 ///
161 /// # Arguments
162 ///
163 /// * `link` - the byte link to the vehicle.
164 /// * `system_id` - this ground station's system id.
165 /// * `component_id` - this ground station's component id.
166 ///
167 /// # Returns
168 ///
169 /// The vehicle client, with signing off.
170 pub fn new(link: L, system_id: u8, component_id: u8) -> Self {
171 Vehicle {
172 connection: Connection::new(link, system_id, component_id),
173 id: Self::format_id(1, 1),
174 target_system: 1,
175 target_component: 1,
176 autodetect_target: true,
177 }
178 }
179
180 /// Pins the target vehicle's system and component id instead of learning them.
181 ///
182 /// # Arguments
183 ///
184 /// * `system_id` - the target vehicle's system id.
185 /// * `component_id` - the target vehicle's component id.
186 ///
187 /// # Returns
188 ///
189 /// The vehicle, for chaining.
190 pub fn with_target(mut self, system_id: u8, component_id: u8) -> Self {
191 self.target_system = system_id;
192 self.target_component = component_id;
193 self.autodetect_target = false;
194 self.id = Self::format_id(system_id, component_id);
195 self
196 }
197
198 /// Signs every outgoing frame with `signer`.
199 ///
200 /// # Arguments
201 ///
202 /// * `signer` - the signer to stamp outgoing frames with.
203 ///
204 /// # Returns
205 ///
206 /// The vehicle, for chaining.
207 pub fn with_signer(mut self, signer: Signer) -> Self {
208 self.connection = self.connection.with_signer(signer);
209 self
210 }
211
212 /// Verifies signed incoming frames with `verifier`, and rejects unsigned ones.
213 ///
214 /// # Arguments
215 ///
216 /// * `verifier` - the verifier to check incoming signed frames with.
217 ///
218 /// # Returns
219 ///
220 /// The vehicle, for chaining.
221 pub fn with_verifier(mut self, verifier: Verifier) -> Self {
222 self.connection = self.connection.with_verifier(verifier);
223 self
224 }
225
226 /// Returns the target vehicle's system id.
227 ///
228 /// # Returns
229 ///
230 /// The target system id.
231 pub fn target_system(&self) -> u8 {
232 self.target_system
233 }
234
235 /// Returns the target vehicle's component id.
236 ///
237 /// # Returns
238 ///
239 /// The target component id.
240 pub fn target_component(&self) -> u8 {
241 self.target_component
242 }
243
244 /// Sends a ground-station heartbeat, which some autopilots require before they accept
245 /// commands.
246 ///
247 /// # Returns
248 ///
249 /// `Ok(())` once the heartbeat has been sent.
250 ///
251 /// # Errors
252 ///
253 /// Returns [`Error::Transport`](pamoja_core::Error::Transport) if the frame cannot be sent.
254 pub async fn send_heartbeat(&mut self) -> CoreResult<()> {
255 let heartbeat = Heartbeat {
256 custom_mode: 0,
257 type_: mav_type::GCS,
258 autopilot: mav_autopilot::INVALID,
259 base_mode: 0,
260 system_status: mav_state::ACTIVE,
261 mavlink_version: 3,
262 };
263 self.tx(&heartbeat).await
264 }
265
266 /// Reads the next telemetry report from the vehicle.
267 ///
268 /// Unlike [`Telemetry::next_frame`], this treats a closed link as an error rather than an
269 /// end of stream.
270 ///
271 /// # Returns
272 ///
273 /// The next decoded [`Report`].
274 ///
275 /// # Errors
276 ///
277 /// Returns [`Error::Closed`](pamoja_core::Error::Closed) if the link ends, or
278 /// [`Error::Transport`](pamoja_core::Error::Transport) on a link fault.
279 pub async fn recv(&mut self) -> CoreResult<Report> {
280 let frame = self.rx().await?;
281 Ok(Report::from_frame(&frame))
282 }
283
284 /// Sends a command to the vehicle and awaits its result, retransmitting on timeout.
285 ///
286 /// The command is sent as a `COMMAND_LONG` and matched to its `COMMAND_ACK`. An
287 /// in-progress acknowledgement extends the wait; a missing acknowledgement resends the
288 /// command with an incremented confirmation, up to the retry budget.
289 ///
290 /// # Arguments
291 ///
292 /// * `command` - the [`MAV_CMD`](crate::dialect::mav_cmd) id.
293 /// * `params` - the seven command parameters.
294 ///
295 /// # Returns
296 ///
297 /// The [`MAV_RESULT`](crate::dialect::mav_result) the vehicle reported, including a
298 /// rejection such as [`DENIED`](crate::dialect::mav_result::DENIED).
299 ///
300 /// # Errors
301 ///
302 /// Returns [`Error::Transport`](pamoja_core::Error::Transport) if the command is not
303 /// acknowledged within the retry budget or the link faults.
304 pub async fn send_command(&mut self, command: u16, params: [f32; 7]) -> CoreResult<u8> {
305 let mut protocol = CommandProtocol::new(command, MAX_RETRIES);
306 loop {
307 let request = CommandLong {
308 param1: params[0],
309 param2: params[1],
310 param3: params[2],
311 param4: params[3],
312 param5: params[4],
313 param6: params[5],
314 param7: params[6],
315 command,
316 target_system: self.target_system,
317 target_component: self.target_component,
318 confirmation: protocol.confirmation(),
319 };
320 self.tx(&request).await?;
321
322 // Wait for the matching acknowledgement, ignoring unrelated traffic; on a timeout
323 // fall out to resend, and on an exhausted budget give up.
324 let resend = loop {
325 match timeout(RESPONSE_TIMEOUT, self.rx()).await {
326 Err(_elapsed) => {
327 if protocol.on_timeout().is_none() {
328 return Err(CoreError::Transport(
329 "command was not acknowledged".into(),
330 ));
331 }
332 break true;
333 }
334 Ok(frame) => {
335 let frame = frame?;
336 if frame.message_id() == CommandAck::ID {
337 let ack = CommandAck::decode(frame.payload()).map_err(map_mav)?;
338 match protocol.on_ack(&ack) {
339 AckOutcome::Final(result) => return Ok(result),
340 AckOutcome::InProgress(_) | AckOutcome::Unrelated => continue,
341 }
342 }
343 }
344 }
345 };
346 debug_assert!(resend);
347 }
348 }
349
350 /// Arms or disarms the vehicle.
351 ///
352 /// # Arguments
353 ///
354 /// * `arm` - `true` to arm, `false` to disarm.
355 ///
356 /// # Returns
357 ///
358 /// The [`MAV_RESULT`](crate::dialect::mav_result) of the arm command.
359 ///
360 /// # Errors
361 ///
362 /// As [`send_command`](Vehicle::send_command).
363 pub async fn arm(&mut self, arm: bool) -> CoreResult<u8> {
364 let flag = if arm { 1.0 } else { 0.0 };
365 self.send_command(
366 mav_cmd::COMPONENT_ARM_DISARM,
367 [flag, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
368 )
369 .await
370 }
371
372 /// Requests a mode change.
373 ///
374 /// # Arguments
375 ///
376 /// * `base_mode` - the [`MAV_MODE_FLAG`](crate::dialect::mav_mode_flag) base-mode bits.
377 /// * `custom_mode` - the autopilot-specific custom mode.
378 ///
379 /// # Returns
380 ///
381 /// The [`MAV_RESULT`](crate::dialect::mav_result) of the mode command.
382 ///
383 /// # Errors
384 ///
385 /// As [`send_command`](Vehicle::send_command).
386 pub async fn set_mode(&mut self, base_mode: u8, custom_mode: u32) -> CoreResult<u8> {
387 self.send_command(
388 mav_cmd::DO_SET_MODE,
389 [
390 base_mode as f32,
391 custom_mode as f32,
392 0.0,
393 0.0,
394 0.0,
395 0.0,
396 0.0,
397 ],
398 )
399 .await
400 }
401
402 /// Commands a takeoff to an altitude.
403 ///
404 /// # Arguments
405 ///
406 /// * `altitude` - the target altitude, in metres.
407 ///
408 /// # Returns
409 ///
410 /// The [`MAV_RESULT`](crate::dialect::mav_result) of the takeoff command.
411 ///
412 /// # Errors
413 ///
414 /// As [`send_command`](Vehicle::send_command).
415 pub async fn takeoff(&mut self, altitude: f32) -> CoreResult<u8> {
416 self.send_command(
417 mav_cmd::NAV_TAKEOFF,
418 [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, altitude],
419 )
420 .await
421 }
422
423 /// Asks the vehicle to emit one message, by id.
424 ///
425 /// # Arguments
426 ///
427 /// * `message_id` - the id of the message to request.
428 ///
429 /// # Returns
430 ///
431 /// The [`MAV_RESULT`](crate::dialect::mav_result) of the request.
432 ///
433 /// # Errors
434 ///
435 /// As [`send_command`](Vehicle::send_command).
436 pub async fn request_message(&mut self, message_id: u32) -> CoreResult<u8> {
437 self.send_command(
438 mav_cmd::REQUEST_MESSAGE,
439 [message_id as f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
440 )
441 .await
442 }
443
444 /// Sets how often the vehicle streams a message.
445 ///
446 /// # Arguments
447 ///
448 /// * `message_id` - the id of the message.
449 /// * `interval_us` - the send interval, in microseconds, or `-1` to disable.
450 ///
451 /// # Returns
452 ///
453 /// The [`MAV_RESULT`](crate::dialect::mav_result) of the request.
454 ///
455 /// # Errors
456 ///
457 /// As [`send_command`](Vehicle::send_command).
458 pub async fn set_message_interval(
459 &mut self,
460 message_id: u32,
461 interval_us: i32,
462 ) -> CoreResult<u8> {
463 self.send_command(
464 mav_cmd::SET_MESSAGE_INTERVAL,
465 [
466 message_id as f32,
467 interval_us as f32,
468 0.0,
469 0.0,
470 0.0,
471 0.0,
472 0.0,
473 ],
474 )
475 .await
476 }
477
478 /// Uploads a mission plan to the vehicle.
479 ///
480 /// Runs the mission protocol's sender role: announces the count, answers each item request,
481 /// and completes on the vehicle's acknowledgement, retransmitting on timeout.
482 ///
483 /// # Arguments
484 ///
485 /// * `items` - the mission items, in sequence order; the target ids, sequence numbers, and
486 /// mission type are stamped on for you.
487 ///
488 /// # Returns
489 ///
490 /// `Ok(())` once the vehicle accepts the plan.
491 ///
492 /// # Errors
493 ///
494 /// Returns [`Error::Transport`](pamoja_core::Error::Transport) if the transfer times out or
495 /// the vehicle rejects the plan.
496 pub async fn upload_mission(&mut self, items: &[MissionItemInt]) -> CoreResult<()> {
497 let sender = MissionSender::new(
498 items,
499 self.target_system,
500 self.target_component,
501 mav_mission_type::MISSION,
502 );
503 self.tx(&sender.count()).await?;
504
505 // `None` means the opening count is still unanswered and is what a timeout resends;
506 // `Some(seq)` is the last item sent, resent on a timeout.
507 let mut last_seq: Option<u16> = None;
508 let mut retries = MAX_RETRIES;
509 loop {
510 match timeout(RESPONSE_TIMEOUT, self.rx()).await {
511 Err(_elapsed) => {
512 if retries == 0 {
513 return Err(CoreError::Transport("mission upload timed out".into()));
514 }
515 retries -= 1;
516 match last_seq {
517 None => self.tx(&sender.count()).await?,
518 Some(seq) => {
519 if let Some(item) = sender.item(seq) {
520 self.tx(&item).await?;
521 }
522 }
523 }
524 }
525 Ok(frame) => {
526 let frame = frame?;
527 match frame.message_id() {
528 MissionRequestInt::ID => {
529 let request =
530 MissionRequestInt::decode(frame.payload()).map_err(map_mav)?;
531 self.answer_item(&sender, request.seq, &mut last_seq, &mut retries)
532 .await?;
533 }
534 MissionRequest::ID => {
535 let request =
536 MissionRequest::decode(frame.payload()).map_err(map_mav)?;
537 self.answer_item(&sender, request.seq, &mut last_seq, &mut retries)
538 .await?;
539 }
540 MissionAck::ID => {
541 let ack = MissionAck::decode(frame.payload()).map_err(map_mav)?;
542 if ack.type_ == mav_mission_result::ACCEPTED {
543 return Ok(());
544 }
545 return Err(CoreError::Transport(format!(
546 "vehicle rejected the mission: result {}",
547 ack.type_
548 )));
549 }
550 _ => {}
551 }
552 }
553 }
554 }
555 }
556
557 /// Downloads the vehicle's mission plan.
558 ///
559 /// Runs the mission protocol's receiver role: requests the count, requests each item in
560 /// order, re-requests an out-of-order item, and acknowledges completion, retransmitting on
561 /// timeout.
562 ///
563 /// # Returns
564 ///
565 /// The mission items, in sequence order.
566 ///
567 /// # Errors
568 ///
569 /// Returns [`Error::Transport`](pamoja_core::Error::Transport) if the transfer times out or
570 /// the link faults.
571 pub async fn download_mission(&mut self) -> CoreResult<Vec<MissionItemInt>> {
572 let mut receiver = MissionReceiver::new(
573 self.target_system,
574 self.target_component,
575 mav_mission_type::MISSION,
576 );
577 self.tx(&receiver.request_list()).await?;
578
579 let mut items: Vec<MissionItemInt> = Vec::new();
580 let mut last_request: Option<MissionRequestInt> = None;
581 let mut got_count = false;
582 let mut retries = MAX_RETRIES;
583 loop {
584 match timeout(RESPONSE_TIMEOUT, self.rx()).await {
585 Err(_elapsed) => {
586 if retries == 0 {
587 return Err(CoreError::Transport("mission download timed out".into()));
588 }
589 retries -= 1;
590 match &last_request {
591 Some(request) => self.tx(request).await?,
592 None => self.tx(&receiver.request_list()).await?,
593 }
594 }
595 Ok(frame) => {
596 let frame = frame?;
597 match frame.message_id() {
598 MissionCount::ID if !got_count => {
599 let count = MissionCount::decode(frame.payload())
600 .map_err(map_mav)?
601 .count;
602 got_count = true;
603 items.reserve(count as usize);
604 match receiver.on_count(count) {
605 ReceiverAction::Request(request) => {
606 self.tx(&request).await?;
607 last_request = Some(request);
608 retries = MAX_RETRIES;
609 }
610 ReceiverAction::Ack(ack) => {
611 self.tx(&ack).await?;
612 return Ok(items);
613 }
614 }
615 }
616 MissionItemInt::ID => {
617 let item = MissionItemInt::decode(frame.payload()).map_err(map_mav)?;
618 let (accepted, action) = receiver.on_item(&item);
619 if let Some(item) = accepted {
620 items.push(item);
621 }
622 match action {
623 ReceiverAction::Request(request) => {
624 self.tx(&request).await?;
625 last_request = Some(request);
626 retries = MAX_RETRIES;
627 }
628 ReceiverAction::Ack(ack) => {
629 self.tx(&ack).await?;
630 return Ok(items);
631 }
632 }
633 }
634 _ => {}
635 }
636 }
637 }
638 }
639 }
640
641 // Answers a mission item request during an upload, recording it as the item to resend on a
642 // timeout and refilling the retry budget.
643 async fn answer_item(
644 &mut self,
645 sender: &MissionSender<'_>,
646 seq: u16,
647 last_seq: &mut Option<u16>,
648 retries: &mut u8,
649 ) -> CoreResult<()> {
650 if let Some(item) = sender.item(seq) {
651 self.tx(&item).await?;
652 *last_seq = Some(seq);
653 *retries = MAX_RETRIES;
654 }
655 Ok(())
656 }
657
658 // Waits for the first heartbeat, learning the target ids when auto-detecting.
659 async fn wait_for_heartbeat(&mut self) -> CoreResult<()> {
660 loop {
661 let frame = timeout(HEARTBEAT_TIMEOUT, self.rx())
662 .await
663 .map_err(|_| CoreError::Transport("no heartbeat from the vehicle".into()))??;
664 if frame.message_id() == Heartbeat::ID {
665 if self.autodetect_target {
666 self.target_system = frame.system_id();
667 self.target_component = frame.component_id();
668 self.id = Self::format_id(self.target_system, self.target_component);
669 }
670 return Ok(());
671 }
672 }
673 }
674
675 async fn tx<M: Message>(&mut self, message: &M) -> CoreResult<()> {
676 self.connection.send(message).await.map_err(map_mav)
677 }
678
679 async fn rx(&mut self) -> CoreResult<Frame> {
680 self.connection.recv().await.map_err(map_mav)
681 }
682
683 fn format_id(system_id: u8, component_id: u8) -> String {
684 format!("mavlink:{system_id}.{component_id}")
685 }
686}
687
688impl<L: ByteLink> Device for Vehicle<L> {
689 fn id(&self) -> &str {
690 &self.id
691 }
692
693 async fn connect(&mut self) -> CoreResult<()> {
694 self.wait_for_heartbeat().await
695 }
696
697 async fn disconnect(&mut self) -> CoreResult<()> {
698 // The link is released when the vehicle is dropped; there is no teardown handshake.
699 Ok(())
700 }
701}
702
703impl<L: ByteLink> Telemetry for Vehicle<L> {
704 type Frame = Report;
705
706 async fn next_frame(&mut self) -> CoreResult<Option<Report>> {
707 match self.connection.recv().await {
708 Ok(frame) => Ok(Some(Report::from_frame(&frame))),
709 Err(MavlinkError::Closed) => Ok(None),
710 Err(err) => Err(map_mav(err)),
711 }
712 }
713}
714
715impl<L: ByteLink> Actuator for Vehicle<L> {
716 type Command = Setpoint;
717
718 async fn apply(&mut self, command: Setpoint) -> CoreResult<()> {
719 match command {
720 Setpoint::Local(setpoint) => self.tx(&setpoint).await,
721 Setpoint::Global(setpoint) => self.tx(&setpoint).await,
722 }
723 }
724}
725
726// Maps a wire-layer fault onto the shared error model: a closed link is `Closed`, a bad payload
727// is a codec fault, a signing failure is an auth fault, and the rest are transport faults.
728fn map_mav(err: MavlinkError) -> CoreError {
729 match err {
730 MavlinkError::Closed => CoreError::Closed,
731 MavlinkError::BadPayload => CoreError::Codec("malformed MAVLink payload".into()),
732 MavlinkError::Unsigned | MavlinkError::BadSignature | MavlinkError::ReplayedTimestamp => {
733 CoreError::Auth(err.to_string())
734 }
735 other => CoreError::Transport(other.to_string()),
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::dialect::{mav_frame, mav_result};
743 use crate::link::{MemoryLink, SitlAutopilot};
744
745 fn waypoint(seq: u16, lat: i32, lon: i32, alt: f32) -> MissionItemInt {
746 MissionItemInt {
747 param1: 0.0,
748 param2: 0.0,
749 param3: 0.0,
750 param4: 0.0,
751 x: lat,
752 y: lon,
753 z: alt,
754 seq,
755 command: mav_cmd::NAV_WAYPOINT,
756 target_system: 0,
757 target_component: 0,
758 frame: mav_frame::GLOBAL_RELATIVE_ALT_INT,
759 current: (seq == 0) as u8,
760 autocontinue: 1,
761 mission_type: mav_mission_type::MISSION,
762 }
763 }
764
765 // Spawns a SITL autopilot that serves whatever the vehicle sends until the test drops it.
766 fn spawn_autopilot(link: MemoryLink) -> tokio::task::JoinHandle<()> {
767 tokio::spawn(async move {
768 let mut autopilot = SitlAutopilot::new(link, 1, 1);
769 let _ = autopilot.emit_heartbeat().await;
770 loop {
771 if autopilot.serve_once().await.is_err() {
772 break;
773 }
774 }
775 })
776 }
777
778 #[tokio::test]
779 async fn connect_learns_the_target_from_the_heartbeat() {
780 let (gcs, vehicle) = MemoryLink::pair();
781 let handle = spawn_autopilot(vehicle);
782 let mut client = Vehicle::new(gcs, 255, GCS_COMPONENT);
783 client.connect().await.unwrap();
784 assert_eq!(client.target_system(), 1);
785 assert_eq!(client.id(), "mavlink:1.1");
786 handle.abort();
787 }
788
789 #[tokio::test]
790 async fn a_command_is_acknowledged() {
791 let (gcs, vehicle) = MemoryLink::pair();
792 let handle = spawn_autopilot(vehicle);
793 let mut client = Vehicle::new(gcs, 255, GCS_COMPONENT);
794 client.connect().await.unwrap();
795 let result = client.arm(true).await.unwrap();
796 assert_eq!(result, mav_result::ACCEPTED);
797 handle.abort();
798 }
799
800 #[tokio::test]
801 async fn a_mission_uploads_and_downloads_unchanged() {
802 let (gcs, vehicle) = MemoryLink::pair();
803 let handle = spawn_autopilot(vehicle);
804 let mut client = Vehicle::new(gcs, 255, GCS_COMPONENT);
805 client.connect().await.unwrap();
806
807 let plan = [
808 waypoint(0, 473_977_418, 85_455_939, 10.0),
809 waypoint(1, 473_977_500, 85_456_000, 20.0),
810 waypoint(2, 473_977_600, 85_456_100, 15.0),
811 ];
812 client.upload_mission(&plan).await.unwrap();
813 let downloaded = client.download_mission().await.unwrap();
814
815 assert_eq!(downloaded.len(), 3);
816 for (sent, got) in plan.iter().zip(downloaded.iter()) {
817 assert_eq!(sent.x, got.x);
818 assert_eq!(sent.y, got.y);
819 assert_eq!(sent.z, got.z);
820 assert_eq!(sent.command, got.command);
821 }
822 handle.abort();
823 }
824
825 #[tokio::test]
826 async fn an_offboard_setpoint_is_accepted_by_the_actuator() {
827 let (gcs, vehicle) = MemoryLink::pair();
828 let handle = spawn_autopilot(vehicle);
829 let mut client = Vehicle::new(gcs, 255, GCS_COMPONENT);
830 client.connect().await.unwrap();
831 let setpoint = Setpoint::Local(SetPositionTargetLocalNed::velocity(
832 0,
833 mav_frame::LOCAL_NED,
834 client.target_system(),
835 client.target_component(),
836 0.5,
837 0.0,
838 -0.2,
839 ));
840 client.apply(setpoint).await.unwrap();
841 handle.abort();
842 }
843}