umshctl/command/capture/
mod.rs

1//! `capture`: listen on the device's radio, decode what it hears, and
2//! optionally write a Wireshark-compatible file.
3
4pub 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    /// Write a Wireshark-compatible capture here.
26    #[arg(long, value_name = "PATH")]
27    pub pcap: Option<PathBuf>,
28
29    /// Which layers the capture file records.
30    #[arg(long, value_enum, default_value_t = CaptureLayers::Radio)]
31    pub layers: CaptureLayers,
32
33    /// Store exact raw LoRa frames instead of the synthetic
34    /// Ethernet/IPv4/UDP encapsulation (radio layer only).
35    #[arg(long)]
36    pub pcap_raw: bool,
37
38    /// pcap LINKTYPE value required by --pcap-raw.
39    #[arg(long, value_name = "N")]
40    pub pcap_linktype: Option<u32>,
41
42    /// Suppress raw/decoded output for frames that are not UMSH.
43    #[arg(long)]
44    pub umsh_only: bool,
45
46    /// Verify link and radio health while the air is quiet.
47    #[arg(long, default_value_t = 10, value_name = "SECS")]
48    pub idle_probe_secs: u64,
49
50    /// Exit instead of recovering a failed BLE session.
51    #[arg(long)]
52    pub no_reconnect: bool,
53
54    /// Seconds to wait before rediscovering after a failed session.
55    #[arg(long, default_value_t = 2, value_name = "SECS")]
56    pub reconnect_delay_secs: u64,
57
58    /// Set the frequency in kHz for this session.
59    #[arg(long, value_name = "KHZ", help_heading = "RF overrides")]
60    pub freq_khz: Option<u32>,
61
62    /// Set the LoRa bandwidth in Hz for this session.
63    #[arg(long, value_name = "HZ", help_heading = "RF overrides")]
64    pub bw_hz: Option<u32>,
65
66    /// Set the LoRa spreading factor for this session.
67    #[arg(long, value_parser = clap::value_parser!(u8).range(5..=12), help_heading = "RF overrides")]
68    pub sf: Option<u8>,
69
70    /// Set the LoRa coding-rate denominator for this session.
71    #[arg(long, value_parser = clap::value_parser!(u8).range(5..=8), help_heading = "RF overrides")]
72    pub cr: Option<u8>,
73
74    /// Set the LoRa sync word for this session.
75    #[arg(long, value_name = "N", help_heading = "RF overrides")]
76    pub sync_word: Option<HexU16Arg>,
77
78    /// Set the transmit power in dBm for this session.
79    #[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    /// Decode nothing for a human: whatever reads the capture dissects
88    /// it itself.
89    ///
90    /// Not a command-line flag — it belongs to the extcap interface,
91    /// where Wireshark is the consumer. Narrating each frame there would
92    /// decode every packet twice and fill Wireshark's log with output
93    /// that reads like dissection but is not.
94    #[arg(skip)]
95    pub quiet: bool,
96}
97
98impl CaptureArgs {
99    pub fn validate(&self) -> Result<()> {
100        // `--layers` has a default, so "was it given?" is not visible
101        // from the parsed value alone; anything other than the default
102        // is an explicit choice, and an explicit default is harmless.
103        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    /// Whether the user asked for a specific RF configuration rather
130    /// than accepting the one the device is already running.
131    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
148/// Running totals, carried across BLE reconnects so one capture reads as
149/// one capture.
150struct 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
172/// Why a capture stopped.
173enum Stop {
174    /// The capture was asked to stop; the tool is not in trouble.
175    ///
176    /// Either the user interrupted it, or whoever was reading the
177    /// capture went away — Wireshark closing an extcap FIFO is a normal
178    /// end to a capture, not a failure worth recovering from.
179    Interrupted,
180}
181
182/// Whether an error is really "the reader hung up".
183///
184/// The tap writes through [`SessionLink`], so a closed sink can also
185/// surface wrapped in a `UlcpError` that otherwise looks like the link
186/// itself failed.
187///
188/// [`SessionLink`]: crate::connection::SessionLink
189fn 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
224/// Capture into an already-built sink.
225///
226/// The extcap interface owns its own writer over Wireshark's FIFO, so
227/// the two entry points differ only in where the sink came from.
228pub(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    // Both cleanups run whatever happened: a half-written pcap and a
241    // device left promiscuous are each worse than the original failure.
242    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
251/// Run the capture, recovering a dropped BLE link when asked to.
252///
253/// Reconnection belongs to one-shot mode, which owns the link and can
254/// rediscover it. In the REPL the *session* owns the link: if it drops,
255/// the REPL itself is unattached, and saying so beats silently
256/// reconnecting underneath the user.
257async 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        // Rediscovering the radio because the *capture reader* went away
267        // would leave a process holding the link with nowhere to write.
268        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    // Read once per session: the radio holds these still for the whole
304    // capture, but every LoRaTap record has to restate them.
305    let rf = read_rf_params(&mut session.device).await;
306
307    // A capture is a promiscuous listener. A device with a provisioned
308    // (or saved-and-restored) host domain filters receptions, so the
309    // factory deliver-everything rule cannot be relied on; bypass the
310    // filtering for this session (`PROP_MAC_PROMISCUOUS` is
311    // session-scoped and reverts on detach). A device that predates the
312    // property refuses the set — capture then sees only frames matching
313    // its receive filtering.
314    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        // Ctrl-C is the way out of a capture that is going nowhere. In
335        // the REPL that must return to the prompt, not kill the tool, so
336        // the dump future is cancelled rather than the process.
337        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
349/// The receive loop. Only ever returns through an error or through the
350/// caller cancelling it.
351async 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                // The probe itself is the point: it proves the link is
372                // still answering. Only the narration is suppressed.
373                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        // `--umsh-only` gates the recording as well as the display: a
392        // filtered capture that still carries every frame it claimed to
393        // drop is not what the flag says it is.
394        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                // Whoever was reading the capture closed it. That ends
413                // the capture, but nothing about the radio is wrong.
414                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        // The attach counter only distinguishes anything once a link has
433        // dropped and been re-established, which cannot happen over serial.
434        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
445/// Write the RF overrides the user asked for, as live-only state.
446async 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
472/// Say what the radio is actually listening on, read back from the
473/// device rather than assumed.
474async 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
489/// The same channel `report_rf` narrates, in a form a capture file can
490/// carry.
491///
492/// A device that will not report a parameter leaves it zero: LoRaTap has
493/// no way to say "unknown", and a wrong-looking zero is easier to spot
494/// than a plausible invented value.
495async 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    // LoRa carries a 16-bit sync word whose nibbles interleave the
515    // legacy 8-bit form (0x1424 is 0x12), which is all LoRaTap has room
516    // for.
517    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
534/// Leave the device as it was found.
535///
536/// One-shot mode gets this for free: the session detaches when the
537/// process exits and `PROP_MAC_PROMISCUOUS` is session-scoped. A REPL
538/// session outlives the capture, so it has to say so explicitly.
539async 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}