1pub mod decode;
5pub mod pcap;
6
7use std::path::PathBuf;
8use std::time::{Duration, Instant};
9
10use anyhow::{Result, bail};
11
12use umsh::hal::Radio as _;
13use umsh::ulcp::{UlcpDevice, UlcpError};
14use umsh::ulcp_wire::ids::prop;
15
16use self::pcap::{CaptureLayers, PcapEncapsulation, PcapWriter, RfParams};
17use super::values::HexU16Arg;
18use super::{decode_u32, phy};
19use crate::App;
20use crate::connection::SessionLink;
21use crate::output::{self, field, note, warn};
22
23#[derive(Debug, clap::Args)]
24pub struct CaptureArgs {
25 #[arg(long, value_name = "PATH")]
27 pub pcap: Option<PathBuf>,
28
29 #[arg(long, value_enum, default_value_t = CaptureLayers::Radio)]
31 pub layers: CaptureLayers,
32
33 #[arg(long)]
36 pub pcap_raw: bool,
37
38 #[arg(long, value_name = "N")]
40 pub pcap_linktype: Option<u32>,
41
42 #[arg(long)]
44 pub umsh_only: bool,
45
46 #[arg(long, default_value_t = 10, value_name = "SECS")]
48 pub idle_probe_secs: u64,
49
50 #[arg(long)]
52 pub no_reconnect: bool,
53
54 #[arg(long, default_value_t = 2, value_name = "SECS")]
56 pub reconnect_delay_secs: u64,
57
58 #[arg(long, value_name = "KHZ", help_heading = "RF overrides")]
60 pub freq_khz: Option<u32>,
61
62 #[arg(long, value_name = "HZ", help_heading = "RF overrides")]
64 pub bw_hz: Option<u32>,
65
66 #[arg(long, value_parser = clap::value_parser!(u8).range(5..=12), help_heading = "RF overrides")]
68 pub sf: Option<u8>,
69
70 #[arg(long, value_parser = clap::value_parser!(u8).range(5..=8), help_heading = "RF overrides")]
72 pub cr: Option<u8>,
73
74 #[arg(long, value_name = "N", help_heading = "RF overrides")]
76 pub sync_word: Option<HexU16Arg>,
77
78 #[arg(
80 long,
81 value_name = "DBM",
82 allow_hyphen_values = true,
83 help_heading = "RF overrides"
84 )]
85 pub tx_power: Option<i8>,
86
87 #[arg(skip)]
95 pub quiet: bool,
96}
97
98impl CaptureArgs {
99 pub fn validate(&self) -> Result<()> {
100 let layers_given = self.layers != CaptureLayers::Radio;
104 if layers_given && self.pcap.is_none() {
105 bail!("--layers requires --pcap=PATH");
106 }
107 if self.pcap_raw {
108 if self.pcap.is_none() {
109 bail!("--pcap-raw requires --pcap=PATH");
110 }
111 if self.layers != CaptureLayers::Radio {
112 bail!("--pcap-raw requires --layers=radio");
113 }
114 if self.pcap_linktype.is_none() {
115 bail!("--pcap-raw requires --pcap-linktype=N");
116 }
117 } else if self.pcap_linktype.is_some() {
118 bail!("--pcap-linktype requires --pcap-raw");
119 }
120 if self.idle_probe_secs == 0 {
121 bail!("--idle-probe-secs must be greater than zero");
122 }
123 if self.reconnect_delay_secs == 0 {
124 bail!("--reconnect-delay-secs must be greater than zero");
125 }
126 Ok(())
127 }
128
129 fn has_rf_overrides(&self) -> bool {
132 self.freq_khz.is_some()
133 || self.bw_hz.is_some()
134 || self.sf.is_some()
135 || self.cr.is_some()
136 || self.sync_word.is_some()
137 || self.tx_power.is_some()
138 }
139
140 fn encapsulation(&self) -> PcapEncapsulation {
141 match self.pcap_linktype {
142 Some(linktype) if self.pcap_raw => PcapEncapsulation::RawLoRa { linktype },
143 _ => PcapEncapsulation::Ethernet,
144 }
145 }
146}
147
148struct Stats {
151 started: Instant,
152 last_progress: Instant,
153 sequence: u64,
154 displayed: u64,
155 filtered: u64,
156 sessions: u64,
157}
158
159impl Stats {
160 fn new() -> Self {
161 Self {
162 started: Instant::now(),
163 last_progress: Instant::now(),
164 sequence: 0,
165 displayed: 0,
166 filtered: 0,
167 sessions: 0,
168 }
169 }
170}
171
172enum Stop {
174 Interrupted,
180}
181
182fn is_broken_pipe(error: &anyhow::Error) -> bool {
190 error.chain().any(|cause| {
191 cause
192 .downcast_ref::<std::io::Error>()
193 .is_some_and(|io| io.kind() == std::io::ErrorKind::BrokenPipe)
194 })
195}
196
197pub async fn run(app: &mut App, args: CaptureArgs) -> Result<()> {
198 args.validate()?;
199
200 let writer = match &args.pcap {
201 Some(path) => {
202 let writer = PcapWriter::create(path, args.layers, args.encapsulation())?;
203 field(
204 "capture",
205 format!(
206 "{} layers={:?} encoding={}",
207 path.display(),
208 args.layers,
209 if args.pcap_raw {
210 "raw LoRa"
211 } else {
212 "Ethernet/IPv4/UDP"
213 },
214 ),
215 );
216 Some(writer)
217 }
218 None => None,
219 };
220
221 run_with_writer(app, &args, writer).await
222}
223
224pub(crate) async fn run_with_writer(
229 app: &mut App,
230 args: &CaptureArgs,
231 writer: Option<PcapWriter>,
232) -> Result<()> {
233 let tapped = writer.is_some();
234 if let Some(writer) = writer {
235 *app.session()?.tap.borrow_mut() = Some(writer);
236 }
237
238 let outcome = capture_with_recovery(app, args).await;
239
240 if tapped && let Ok(session) = app.session() {
243 session.tap.borrow_mut().take();
244 }
245 if app.interactive {
246 clear_promiscuous(app).await;
247 }
248 outcome
249}
250
251async fn capture_with_recovery(app: &mut App, args: &CaptureArgs) -> Result<()> {
258 let mut stats = Stats::new();
259 let reconnect = !args.no_reconnect && !app.interactive && app.target_is_ble();
260 loop {
261 stats.sessions += 1;
262 let failure = match capture_once(app, args, &mut stats).await {
263 Ok(Stop::Interrupted) => return Ok(()),
264 Err(error) => error,
265 };
266 if is_broken_pipe(&failure) {
269 return Ok(());
270 }
271 if !reconnect {
272 return Err(failure);
273 }
274 eprintln!(
275 "session failure +{:.3}s after {} packets: {failure}",
276 stats.started.elapsed().as_secs_f64(),
277 stats.sequence,
278 );
279 println!(
280 "recovery: rediscovering in {} s (ctrl-c to exit) ...",
281 args.reconnect_delay_secs,
282 );
283 tokio::time::sleep(Duration::from_secs(args.reconnect_delay_secs)).await;
284 app.reconnect().await?;
285 }
286}
287
288async fn capture_once(app: &mut App, args: &CaptureArgs, stats: &mut Stats) -> Result<Stop> {
289 let interactive = app.interactive;
290 let session = app.session()?;
291 println!(
292 "session #{}: device={} boot_status={:?}",
293 stats.sessions,
294 session.device.dev_version(),
295 session.device.boot_status(),
296 );
297
298 if args.has_rf_overrides() {
299 apply_rf(&mut session.device, args).await?;
300 note("the radio's live RF configuration was changed for this session (never saved)");
301 }
302 report_rf(&mut session.device).await;
303 let rf = read_rf_params(&mut session.device).await;
306
307 match session.device.set_prop(prop::MAC_PROMISCUOUS, &[1]).await {
315 Ok(_) => println!("promiscuous mode enabled"),
316 Err(UlcpError::Status(status)) => warn(format!(
317 "device refused promiscuous mode ({status:?}); capture is limited to the device's \
318 receive filtering"
319 )),
320 Err(error) => return Err(error.into()),
321 }
322
323 let color = output::color();
324 if !args.quiet {
325 if color {
326 decode::print_legend();
327 }
328 println!("dumping packets (ctrl-c to {}) ...", {
329 if interactive { "stop" } else { "exit" }
330 });
331 }
332
333 if interactive {
334 tokio::select! {
338 result = dump(session, args, stats, color, &rf) => result,
339 _ = tokio::signal::ctrl_c() => {
340 println!();
341 Ok(Stop::Interrupted)
342 }
343 }
344 } else {
345 dump(session, args, stats, color, &rf).await
346 }
347}
348
349async fn dump(
352 session: &mut crate::connection::Session,
353 args: &CaptureArgs,
354 stats: &mut Stats,
355 color: bool,
356 rf: &RfParams,
357) -> Result<Stop> {
358 use std::fmt::Write as _;
359
360 let mut buf = [0u8; 256];
361 let idle_probe_interval = Duration::from_secs(args.idle_probe_secs);
362 loop {
363 let receive = core::future::poll_fn(|cx| session.device.poll_receive(cx, &mut buf));
364 let info = match tokio::time::timeout(idle_probe_interval, receive).await {
365 Ok(result) => result?,
366 Err(_) => {
367 let value = session.device.get_prop(prop::PHY_RSSI).await?;
368 let [rssi] = value.as_slice() else {
369 bail!("idle health probe returned malformed PHY_RSSI value: {value:02x?}");
370 };
371 if !args.quiet {
374 println!(
375 "idle +{:.3}s received={} displayed={} filtered={} session={} \
376 link=ok channel RSSI={} dBm",
377 stats.started.elapsed().as_secs_f64(),
378 stats.sequence,
379 stats.displayed,
380 stats.filtered,
381 stats.sessions,
382 *rssi as i8,
383 );
384 }
385 stats.last_progress = Instant::now();
386 continue;
387 }
388 };
389 stats.sequence += 1;
390 let packet = &buf[..info.len];
391 if !decode::should_display(packet, args.umsh_only) {
395 stats.filtered += 1;
396 if !args.quiet && stats.last_progress.elapsed() >= idle_probe_interval {
397 println!(
398 "filter +{:.3}s received={} displayed={} filtered={} session={} link=ok",
399 stats.started.elapsed().as_secs_f64(),
400 stats.sequence,
401 stats.displayed,
402 stats.filtered,
403 stats.sessions,
404 );
405 stats.last_progress = Instant::now();
406 }
407 continue;
408 }
409 if let Some(writer) = session.tap.borrow_mut().as_mut() {
410 match writer.write_radio_with_info(rf, &info, packet) {
411 Ok(()) => {}
412 Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {
415 return Ok(Stop::Interrupted);
416 }
417 Err(error) => return Err(error.into()),
418 }
419 }
420 stats.displayed += 1;
421 if args.quiet {
422 continue;
423 }
424 let mut meta = format!(
425 "\n#{} +{:.3}s {} B RSSI {} dBm SNR {}",
426 stats.sequence,
427 stats.started.elapsed().as_secs_f64(),
428 info.len,
429 info.rssi,
430 info.snr,
431 );
432 if stats.sessions > 1 {
435 let _ = write!(meta, " s{}", stats.sessions);
436 }
437 if let Some(lqi) = info.lqi {
438 let _ = write!(meta, " LQI {}", lqi.get());
439 }
440 println!("{meta}");
441 decode::print_frame(packet, color);
442 }
443}
444
445async fn apply_rf(device: &mut UlcpDevice<SessionLink>, args: &CaptureArgs) -> Result<()> {
447 if let Some(khz) = args.freq_khz {
448 device.set_prop(prop::PHY_FREQ, &khz.to_le_bytes()).await?;
449 }
450 if let Some(hz) = args.bw_hz {
451 device
452 .set_prop(prop::PHY_LORA_BW, &hz.to_le_bytes())
453 .await?;
454 }
455 if let Some(sf) = args.sf {
456 device.set_prop(prop::PHY_LORA_SF, &[sf]).await?;
457 }
458 if let Some(cr) = args.cr {
459 device.set_prop(prop::PHY_LORA_CR, &[cr]).await?;
460 }
461 if let Some(sync) = args.sync_word {
462 device
463 .set_prop(prop::PHY_LORA_SW, &sync.0.to_le_bytes())
464 .await?;
465 }
466 if let Some(dbm) = args.tx_power {
467 device.set_prop(prop::PHY_TX_POWER, &[dbm as u8]).await?;
468 }
469 Ok(())
470}
471
472async fn report_rf(device: &mut UlcpDevice<SessionLink>) {
475 let mut parts = Vec::new();
476 if let Some(freq) = device
477 .get_prop(prop::PHY_FREQ)
478 .await
479 .ok()
480 .and_then(|value| decode_u32(&value))
481 {
482 parts.push(format!("{freq} kHz"));
483 }
484 parts.extend(phy::lora_parts(device).await);
485 parts.extend(phy::power_part(device).await);
486 field("radio", parts.join(", "));
487}
488
489async fn read_rf_params(device: &mut UlcpDevice<SessionLink>) -> RfParams {
496 let freq_khz = device
497 .get_prop(prop::PHY_FREQ)
498 .await
499 .ok()
500 .and_then(|value| decode_u32(&value))
501 .unwrap_or(0);
502 let bw_hz = device
503 .get_prop(prop::PHY_LORA_BW)
504 .await
505 .ok()
506 .and_then(|value| decode_u32(&value))
507 .unwrap_or(0);
508 let sf = device
509 .get_prop(prop::PHY_LORA_SF)
510 .await
511 .ok()
512 .and_then(|value| value.first().copied())
513 .unwrap_or(0);
514 let sync_word = device
518 .get_prop(prop::PHY_LORA_SW)
519 .await
520 .ok()
521 .and_then(|value| <[u8; 2]>::try_from(value.as_slice()).ok())
522 .map(u16::from_le_bytes)
523 .map(|sw| (((sw >> 8) as u8) & 0xf0) | (((sw >> 4) as u8) & 0x0f))
524 .unwrap_or(0);
525
526 RfParams {
527 freq_hz: freq_khz.saturating_mul(1_000),
528 bw_hz,
529 sf,
530 sync_word,
531 }
532}
533
534async fn clear_promiscuous(app: &mut App) {
540 let Ok(session) = app.session() else {
541 return;
542 };
543 match session.device.set_prop(prop::MAC_PROMISCUOUS, &[0]).await {
544 Ok(_) => println!("promiscuous mode disabled"),
545 Err(UlcpError::Status(status)) => warn(format!(
546 "device refused to leave promiscuous mode ({status:?})"
547 )),
548 Err(error) => warn(format!("could not leave promiscuous mode: {error}")),
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 fn args() -> CaptureArgs {
557 CaptureArgs {
558 pcap: None,
559 layers: CaptureLayers::Radio,
560 pcap_raw: false,
561 pcap_linktype: None,
562 umsh_only: false,
563 idle_probe_secs: 10,
564 no_reconnect: false,
565 reconnect_delay_secs: 2,
566 freq_khz: None,
567 bw_hz: None,
568 sf: None,
569 cr: None,
570 sync_word: None,
571 tx_power: None,
572 quiet: false,
573 }
574 }
575
576 #[test]
577 fn a_bare_capture_is_valid_and_disturbs_nothing() {
578 let args = args();
579 args.validate().unwrap();
580 assert!(!args.has_rf_overrides());
581 }
582
583 #[test]
584 fn pcap_options_require_the_file_they_describe() {
585 let mut args = args();
586 args.layers = CaptureLayers::Both;
587 assert!(args.validate().is_err());
588 args.pcap = Some(PathBuf::from("out.pcap"));
589 args.validate().unwrap();
590 }
591
592 #[test]
593 fn raw_pcap_needs_a_path_a_linktype_and_the_radio_layer() {
594 let mut args = args();
595 args.pcap_raw = true;
596 assert!(args.validate().is_err(), "no path");
597 args.pcap = Some(PathBuf::from("out.pcap"));
598 assert!(args.validate().is_err(), "no linktype");
599 args.pcap_linktype = Some(147);
600 args.validate().unwrap();
601 assert!(matches!(
602 args.encapsulation(),
603 PcapEncapsulation::RawLoRa { linktype: 147 }
604 ));
605 args.layers = CaptureLayers::Both;
606 assert!(args.validate().is_err(), "raw is radio-only");
607 }
608
609 #[test]
610 fn a_linktype_without_raw_frames_means_nothing() {
611 let mut args = args();
612 args.pcap = Some(PathBuf::from("out.pcap"));
613 args.pcap_linktype = Some(147);
614 assert!(args.validate().is_err());
615 }
616
617 #[test]
618 fn zero_intervals_are_rejected() {
619 let mut idle = args();
620 idle.idle_probe_secs = 0;
621 assert!(idle.validate().is_err());
622
623 let mut delay = args();
624 delay.reconnect_delay_secs = 0;
625 assert!(delay.validate().is_err());
626 }
627
628 #[test]
629 fn any_rf_flag_makes_the_capture_configure_the_radio() {
630 let mut args = args();
631 args.sf = Some(9);
632 assert!(args.has_rf_overrides());
633 }
634}