1use 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
27const DEFAULT_SIZE: u16 = 8;
33
34#[derive(Debug, clap::Args)]
35pub struct PingArgs {
36 #[arg(value_name = "KEY")]
38 pub target: KeyArg,
39
40 #[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 #[arg(short = 'i', long, value_name = "SECONDS", default_value_t = 3)]
47 pub interval: u64,
48
49 #[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 #[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 #[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 #[arg(long, conflicts_with = "route")]
67 pub flood: bool,
68
69 #[arg(long, value_name = "HOP,HOP")]
73 pub route: Option<RouteArg>,
74
75 #[arg(long, value_name = "CHANNEL")]
80 pub channel: Option<ChannelArg>,
81
82 #[arg(long)]
86 pub ack_only: bool,
87
88 #[arg(long, value_name = "BYTES", default_value = "8")]
90 pub mic: MicArg,
91
92 #[arg(long, value_name = "REGION")]
94 pub region: Option<RegionCodeArg>,
95
96 #[arg(long)]
98 pub full_source: bool,
99
100 #[arg(long)]
102 pub salt: bool,
103
104 #[arg(long)]
111 pub untraced: bool,
112}
113
114impl PingArgs {
115 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 options = options
129 .try_with_source_route(&[])
130 .map_err(|error| anyhow!("{error:?}"))?;
131 }
132 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
152enum Reply {
154 Echo(PongMetadata),
155 Acked(u64),
157 Silence {
159 progress: Option<SendProgress>,
165 },
166}
167
168struct 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 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 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#[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 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 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
346async 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 ctl.stack.pump_until(deadline).await?;
359 }
360 Ok(())
361}
362
363fn 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
376fn 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
411fn 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
459fn 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 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}