umshctl/command/
ping.rs

1//! `ping`: reachability and path quality, measured over the mesh.
2//!
3//! Like `manage` and `peer-repeaters` this borrows the attached radio and
4//! becomes a node, but it needs no authorization from the far end: an Echo
5//! Request is a plain MAC command that every node's MAC answers on its own.
6//! What a ping is for is measuring the path that real traffic will take, so
7//! the flags here are the frame's own shape—MIC size, source form, flood
8//! budget, an explicit route, the channel it rides—and they carry through
9//! to the wire untouched.
10
11use std::cell::RefCell;
12use std::rc::Rc;
13use std::time::Duration;
14
15use anyhow::{Result, anyhow, bail};
16
17use tokio::time::Instant;
18use umsh::core::PayloadType;
19use umsh::hal::Radio;
20use umsh::mac::{MAX_FLOOD_HOPS, SendOptions};
21use umsh::node::PongMetadata;
22
23use super::manage::Ctl;
24use super::values::{ChannelArg, KeyArg, MicArg, RegionCodeArg, RouteArg};
25use crate::output::{field, note};
26
27/// The default echo size, in bytes of echo data.
28///
29/// Two of them are the nonce that matches a reply to its request, so this
30/// is a small ping with six bytes of filler—enough to be a real frame
31/// without paying for airtime nobody asked about.
32const DEFAULT_SIZE: u16 = 8;
33
34#[derive(Debug, clap::Args)]
35pub struct PingArgs {
36    /// The node to ping, as its node public key.
37    #[arg(value_name = "KEY")]
38    pub target: KeyArg,
39
40    /// How many pings to send.
41    #[arg(short = 'c', long, value_name = "N", default_value_t = 1,
42          value_parser = clap::value_parser!(u32).range(1..))]
43    pub count: u32,
44
45    /// Seconds to wait between one ping and the next.
46    #[arg(short = 'i', long, value_name = "SECONDS", default_value_t = 3)]
47    pub interval: u64,
48
49    /// Seconds to wait for each reply.
50    #[arg(short = 'W', long, value_name = "SECONDS", default_value_t = 30,
51          value_parser = clap::value_parser!(u64).range(1..))]
52    pub timeout: u64,
53
54    /// Bytes of echo data, including the 2-byte nonce. The frame's own
55    /// budget is the real ceiling, and an oversize ping is refused rather
56    /// than shortened.
57    #[arg(short = 's', long, value_name = "BYTES", default_value_t = DEFAULT_SIZE,
58          conflicts_with = "ack_only", value_parser = clap::value_parser!(u16).range(2..))]
59    pub size: u16,
60
61    /// Flood-hop ceiling. `0` sends without a flood budget at all.
62    #[arg(long, value_name = "HOPS", value_parser = clap::value_parser!(u8).range(0..=MAX_FLOOD_HOPS as i64))]
63    pub hops: Option<u8>,
64
65    /// Flood even where a route to the target is already cached.
66    #[arg(long, conflicts_with = "route")]
67    pub flood: bool,
68
69    /// Steer the ping down an explicit route: a comma-separated list of
70    /// hops, first hop first, each four hex digits of a router hint or a
71    /// full node key.
72    #[arg(long, value_name = "HOP,HOP")]
73    pub route: Option<RouteArg>,
74
75    /// Ping inside a channel, as a blind unicast—a channel name, or the
76    /// 32 bytes of a private channel key. A target that has never been
77    /// heard from cannot resolve a 3-byte source hint, so first contact on
78    /// a channel wants `--full-source`.
79    #[arg(long, value_name = "CHANNEL")]
80    pub channel: Option<ChannelArg>,
81
82    /// Send a No-op MAC command asking for an acknowledgment, and time the
83    /// ack instead of an echo. Measures the MAC's own round trip, with no
84    /// application frame coming back.
85    #[arg(long)]
86    pub ack_only: bool,
87
88    /// MIC size in bytes.
89    #[arg(long, value_name = "BYTES", default_value = "8")]
90    pub mic: MicArg,
91
92    /// Stamp the ping with a region code.
93    #[arg(long, value_name = "REGION")]
94    pub region: Option<RegionCodeArg>,
95
96    /// Carry the whole 32-byte source key rather than a 3-byte hint.
97    #[arg(long)]
98    pub full_source: bool,
99
100    /// Randomize the SECINFO salt.
101    #[arg(long)]
102    pub salt: bool,
103
104    /// Leave off the trace-route and trace-signal options, which are
105    /// otherwise on: without them a reply that crossed repeaters names
106    /// none of them and carries no per-hop signal. The RSSI and SNR on
107    /// each line are this radio's own measurement of the reply and are
108    /// unaffected. Unrelated to the global `--trace`, which prints frames
109    /// on stderr.
110    #[arg(long)]
111    pub untraced: bool,
112}
113
114impl PingArgs {
115    /// The frame shape these flags ask for.
116    fn send_options(&self) -> Result<SendOptions> {
117        let mut options = SendOptions::default().with_mic_size(self.mic.0);
118        if !self.untraced {
119            options = options.with_trace_route().with_trace_signal();
120        }
121        if let Some(route) = &self.route {
122            options = options
123                .try_with_source_route(&route.0)
124                .map_err(|error| anyhow!("that source route is too long: {error:?}"))?;
125        } else if self.flood {
126            // An explicit empty route suppresses the cached one, which is
127            // what forcing a flood means. The cache itself is left alone.
128            options = options
129                .try_with_source_route(&[])
130                .map_err(|error| anyhow!("{error:?}"))?;
131        }
132        // After the route, which fills in a hop budget of its own when
133        // none was set: an explicit `--hops` is the one the caller meant.
134        options = match self.hops {
135            Some(0) => options.no_flood(),
136            Some(hops) => options.with_flood_hops(hops),
137            None => options,
138        };
139        if let Some(region) = self.region {
140            options = options.with_region_code(region.0.to_bytes());
141        }
142        if self.full_source {
143            options = options.with_full_source();
144        }
145        if self.salt {
146            options = options.with_salt();
147        }
148        Ok(options)
149    }
150}
151
152/// What one ping came back as.
153enum Reply {
154    Echo(PongMetadata),
155    /// An ack-only ping, and how long the acknowledgment took.
156    Acked(u64),
157    /// Nothing came back.
158    Silence {
159        /// What the MAC saw of the frame on its way out, where it kept a
160        /// receipt to see it by—which separates "nobody answered" from
161        /// "it never left the antenna". An echo ping asks for no
162        /// acknowledgment, so the MAC tracks nothing and there is nothing
163        /// here to report.
164        progress: Option<SendProgress>,
165    },
166}
167
168/// What the MAC observed of an ack-tracked frame it sent.
169struct SendProgress {
170    transmitted: bool,
171    repeated: bool,
172}
173
174pub async fn run<R: Radio>(ctl: &mut Ctl<'_, R>, args: PingArgs) -> Result<()>
175where
176    R::Error: core::fmt::Debug,
177{
178    let target = ctl.target;
179    let options = args.send_options()?;
180
181    // A blind unicast still needs the destination registered as a peer —
182    // the channel conceals the pair, it does not stand in for knowing who
183    // they are—and a channel-bound peer handle does not register one.
184    // This is also what keeps a target whose firmware still answers off
185    // the channel working: its plain unicast reply lands on a known peer.
186    let peer = ctl
187        .stack
188        .node
189        .peer(target)
190        .await
191        .map_err(|error| anyhow!("registering the target as a peer: {error:?}"))?;
192
193    let bound = match &args.channel {
194        Some(channel) => Some(
195            ctl.stack
196                .node
197                .join(&channel.0)
198                .await
199                .map_err(|error| anyhow!("joining the channel: {error:?}"))?,
200        ),
201        None => None,
202    };
203
204    if let Some(channel) = &args.channel {
205        field("channel", channel.0.name());
206    }
207    field("shape", describe_shape(&args, &options));
208
209    // Replies arrive asynchronously on the receive path, so a subscription
210    // collects them and the loop below waits for one to land.
211    let pong: Rc<RefCell<Option<PongMetadata>>> = Rc::new(RefCell::new(None));
212    let sink = pong.clone();
213    let _pong_subscription = ctl.stack.node.on_pong_with_metadata(move |from, metadata| {
214        if from == target {
215            *sink.borrow_mut() = Some(metadata.clone());
216        }
217    });
218    let expired = Rc::new(RefCell::new(false));
219    let expiry = expired.clone();
220    let _timeout_subscription = ctl.stack.node.on_ping_timeout(move |from| {
221        if from == target {
222            *expiry.borrow_mut() = true;
223        }
224    });
225
226    let mut sent = 0u32;
227    let mut rtts: Vec<u64> = Vec::new();
228    let mut result = Ok(());
229
230    for seq in 1..=args.count {
231        pong.borrow_mut().take();
232        *expired.borrow_mut() = false;
233
234        let reply =
235            match one_ping(ctl, &peer, bound.as_ref(), &args, &options, &pong, &expired).await {
236                Ok(reply) => reply,
237                Err(error) => {
238                    result = Err(error);
239                    break;
240                }
241            };
242        sent += 1;
243        report(seq, &args, &reply);
244        if let Some(rtt) = match &reply {
245            Reply::Echo(metadata) => Some(metadata.round_trip_ms),
246            Reply::Acked(rtt_ms) => Some(*rtt_ms),
247            Reply::Silence { .. } => None,
248        } {
249            rtts.push(rtt);
250        }
251
252        if seq < args.count {
253            let until = Instant::now() + Duration::from_secs(args.interval);
254            pump_until(ctl, until, || false).await?;
255        }
256    }
257
258    if let Some(channel) = &args.channel {
259        let _ = ctl.stack.node.leave(&channel.0).await;
260    }
261    result?;
262
263    summarize(sent, &rtts);
264    if rtts.is_empty() {
265        if args.channel.is_some() && !args.full_source {
266            note(
267                "a node that has never heard this key cannot resolve its 3-byte hint inside a \
268                 channel; try again with `--full-source`",
269            );
270        }
271        bail!("no reply from {target} after {sent} ping(s)");
272    }
273    Ok(())
274}
275
276/// Send one ping and wait out its deadline.
277#[allow(clippy::too_many_arguments)]
278async fn one_ping<R: Radio>(
279    ctl: &mut Ctl<'_, R>,
280    peer: &umsh::node::PeerConnection<umsh::node::LocalNode<crate::mesh::CtlHandle<'_, R>>>,
281    bound: Option<&umsh::node::BoundChannel<crate::mesh::CtlHandle<'_, R>>>,
282    args: &PingArgs,
283    options: &SendOptions,
284    pong: &Rc<RefCell<Option<PongMetadata>>>,
285    expired: &Rc<RefCell<bool>>,
286) -> Result<Reply>
287where
288    R::Error: core::fmt::Debug,
289{
290    let timeout = Duration::from_secs(args.timeout);
291    let started = Instant::now();
292    let deadline = started + timeout;
293
294    if args.ack_only {
295        // A No-op is one command byte and produces no reply frame; what is
296        // being timed is the MAC acknowledgment it asks for.
297        let payload = [
298            PayloadType::MacCommand as u8,
299            umsh::node::mac_command::CommandId::Noop as u8,
300        ];
301        let options = options.clone().with_ack_requested(true);
302        let ticket = match bound {
303            Some(bound) => bound.peer(ctl.target).send(&payload, &options).await,
304            None => peer.send(&payload, &options).await,
305        }
306        .map_err(describe_send)?;
307        pump_until(ctl, deadline, || ticket.is_finished()).await?;
308        return Ok(if ticket.was_acked() {
309            Reply::Acked(started.elapsed().as_millis() as u64)
310        } else {
311            Reply::Silence {
312                progress: Some(SendProgress {
313                    transmitted: ticket.was_transmitted(),
314                    repeated: ticket.was_repeated(),
315                }),
316            }
317        });
318    }
319
320    let extra = usize::from(args.size - 2);
321    // The echo response is the acknowledgment, so `ping` asks for no MAC
322    // ack and the MAC keeps no receipt to track the frame by; the ticket
323    // exists only so the send's own failures surface.
324    match bound {
325        Some(bound) => {
326            bound
327                .peer(ctl.target)
328                .ping(extra, options, timeout.as_millis() as u64)
329                .await
330        }
331        None => peer.ping(extra, options, timeout.as_millis() as u64).await,
332    }
333    .map_err(describe_send)?;
334
335    pump_until(ctl, deadline, || {
336        pong.borrow().is_some() || *expired.borrow()
337    })
338    .await?;
339
340    Ok(match pong.borrow_mut().take() {
341        Some(metadata) => Reply::Echo(metadata),
342        None => Reply::Silence { progress: None },
343    })
344}
345
346/// Drive the host until `done` or the deadline, whichever comes first.
347async fn pump_until<R: Radio>(
348    ctl: &mut Ctl<'_, R>,
349    deadline: Instant,
350    mut done: impl FnMut() -> bool,
351) -> Result<()>
352where
353    R::Error: core::fmt::Debug,
354{
355    while !done() && Instant::now() < deadline {
356        // A quiet radio produces no MAC wake, so the deadlines that retire
357        // an unanswered ping need their own nudge — which the pump does.
358        ctl.stack.pump_until(deadline).await?;
359    }
360    Ok(())
361}
362
363/// What went wrong on the way to the antenna, in the terms of the flag
364/// that caused it.
365fn describe_send(error: impl core::fmt::Debug) -> anyhow::Error {
366    let rendered = format!("{error:?}");
367    if rendered.contains("BufferTooSmall") {
368        return anyhow!(
369            "the ping does not fit in one frame; lower `--size`, or shorten the frame with a \
370             smaller `--mic` or without `--full-source`"
371        );
372    }
373    anyhow!("sending the ping: {rendered}")
374}
375
376/// The one-line summary of the frame shape being measured, so the numbers
377/// below it can be read back later without the command line.
378fn describe_shape(args: &PingArgs, options: &SendOptions) -> String {
379    let mut parts = Vec::new();
380    if args.ack_only {
381        parts.push(String::from("no-op, ack requested"));
382    } else {
383        parts.push(format!("{} bytes echo", args.size));
384    }
385    parts.push(format!("mic {}", options.mic_size.byte_len()));
386    match &options.source_route {
387        Some(route) if route.is_empty() => parts.push(String::from("forced flood")),
388        Some(route) => parts.push(format!(
389            "route {}",
390            route
391                .iter()
392                .map(|hop| hop.to_string())
393                .collect::<Vec<_>>()
394                .join(" → ")
395        )),
396        None => {}
397    }
398    match options.flood_hops {
399        Some(hops) => parts.push(format!("{hops} flood hops")),
400        None => parts.push(String::from("no flood budget")),
401    }
402    if options.full_source {
403        parts.push(String::from("full source"));
404    }
405    if !options.trace_route {
406        parts.push(String::from("untraced"));
407    }
408    parts.join(", ")
409}
410
411/// One line per ping, in the order they were sent.
412fn report(seq: u32, args: &PingArgs, reply: &Reply) {
413    match reply {
414        Reply::Echo(metadata) => {
415            let mut line = format!("seq {seq}: reply in {}", seconds(metadata.round_trip_ms));
416            if let Some(hops) = metadata.hop_count {
417                line.push_str(&format!(", {hops} hop{}", if hops == 1 { "" } else { "s" }));
418            }
419            if !metadata.route_hints.is_empty() {
420                line.push_str(&format!(
421                    " via {}",
422                    metadata
423                        .route_hints
424                        .iter()
425                        .map(|hop| hop.to_string())
426                        .collect::<Vec<_>>()
427                        .join(" → ")
428                ));
429            }
430            if let Some(rssi) = metadata.rssi_dbm {
431                line.push_str(&format!(", {rssi} dBm"));
432            }
433            if let Some(snr) = metadata.snr_centibels {
434                line.push_str(&format!(", snr {:.1} dB", f32::from(snr) / 10.0));
435            }
436            println!("{line}");
437        }
438        Reply::Acked(rtt_ms) => println!("seq {seq}: acked in {}", seconds(*rtt_ms)),
439        Reply::Silence { progress } => {
440            let what = if args.ack_only { "no ack" } else { "timeout" };
441            let mut line = format!("seq {seq}: {what} after {} s", args.timeout);
442            if let Some(progress) = progress {
443                let mut seen = Vec::new();
444                if progress.transmitted {
445                    seen.push("transmitted");
446                }
447                if progress.repeated {
448                    seen.push("repeated");
449                }
450                if !seen.is_empty() {
451                    line.push_str(&format!(" ({})", seen.join(", ")));
452                }
453            }
454            println!("{line}");
455        }
456    }
457}
458
459/// Loss and round-trip spread over the whole run.
460fn summarize(sent: u32, rtts: &[u64]) {
461    if sent == 0 {
462        return;
463    }
464    let replies = rtts.len() as u32;
465    let loss = u64::from(sent - replies) * 100 / u64::from(sent);
466    field("replies", format!("{replies} of {sent} ({loss}% loss)"));
467    if let (Some(min), Some(max)) = (rtts.iter().min(), rtts.iter().max()) {
468        let avg = rtts.iter().sum::<u64>() / rtts.len() as u64;
469        // One unit for the three numbers, the way ping has always shown it.
470        field(
471            "rtt",
472            format!(
473                "min/avg/max {}/{}/{} s",
474                bare_seconds(*min),
475                bare_seconds(avg),
476                bare_seconds(*max)
477            ),
478        );
479    }
480}
481
482fn seconds(ms: u64) -> String {
483    format!("{} s", bare_seconds(ms))
484}
485
486fn bare_seconds(ms: u64) -> String {
487    format!("{:.2}", ms as f64 / 1000.0)
488}