umshctl/extcap/
protocol.rs

1//! Rendering the extcap control protocol.
2//!
3//! Wireshark parses these as `keyword {key=value}{key=value}` lines, one
4//! per line, so every interpolated value has to be kept clear of the
5//! delimiters. Nothing here does I/O: the whole wire format is a pure
6//! function of the tool's state, which is what makes it testable without
7//! a radio or a Wireshark.
8
9use crate::connection::{DefaultDevice, Found};
10
11/// The interface name. Wireshark writes this into its `recent` file and
12/// into the preference keys for the configuration dialog, so it is
13/// permanent: changing it silently orphans saved capture setups.
14pub const INTERFACE: &str = "umsh";
15
16/// pcap `LINKTYPE_LORATAP`. The radio frame arrives under a header
17/// describing the channel it was heard on, so nothing has to be
18/// invented the way the synthetic Ethernet encapsulation does.
19pub const DLT_LORATAP: u32 = 270;
20
21/// The `{call=}` of the reloadable radio selector.
22pub const CALL_RADIO: &str = "--radio";
23
24/// Strip the delimiters of the extcap grammar out of a value.
25///
26/// Device names come off the radio, so a name containing a brace or a
27/// newline would otherwise be able to forge fields or whole lines.
28fn sanitize(value: &str) -> String {
29    value
30        .chars()
31        .map(|c| match c {
32            '{' | '}' => '(',
33            '\r' | '\n' | '\t' => ' ',
34            other => other,
35        })
36        .collect()
37}
38
39/// The reply to `--extcap-interfaces`.
40///
41/// A saved default device is named here purely so the interface is
42/// recognizable in Wireshark's list. It is read from the preferences
43/// file, never from the radio: Wireshark runs this on every startup and
44/// every refresh of the interface list, so it must not touch hardware.
45pub fn interfaces(default: Option<&DefaultDevice>) -> String {
46    let mut out = format!(
47        "extcap {{version={}}}{{display=UMSH radio}}\n",
48        env!("CARGO_PKG_VERSION"),
49    );
50    let display = match default.and_then(|device| device.name.as_deref()) {
51        Some(name) => format!("UMSH radio ({})", sanitize(name)),
52        None => "UMSH radio".to_string(),
53    };
54    out.push_str(&format!(
55        "interface {{value={INTERFACE}}}{{display={display}}}\n"
56    ));
57    out
58}
59
60/// The reply to `--extcap-dlts`.
61///
62/// Exactly one line: Wireshark never passes a chosen DLT back on
63/// `--capture`, so a second link type would be unactionable.
64pub fn dlts() -> String {
65    format!("dlt {{number={DLT_LORATAP}}}{{name=LORATAP}}{{display=LoRaTap}}\n")
66}
67
68/// The reply to `--extcap-config`: the capture-options dialog.
69///
70/// The RF overrides are strings rather than numbers on purpose. A
71/// numeric extcap argument renders as a spin box that always holds a
72/// value and is always passed, which would rewrite the live PHY of a
73/// radio the user only wanted to listen to. Empty means "leave the
74/// radio alone".
75pub fn config() -> String {
76    let mut out = String::new();
77    out.push_str(&format!(
78        "arg {{number=0}}{{call={CALL_RADIO}}}{{display=Radio}}\
79         {{tooltip=BLE radio to capture from. Leave empty to use the saved default, \
80         then discover.}}{{type=selector}}{{reload=true}}{{placeholder=Scan for radios}}\
81         {{required=false}}{{group=Connection}}\n"
82    ));
83    out.push_str(&radio_value_auto());
84    out.push_str(
85        "arg {number=1}{call=--serial-port}{display=Serial port}\
86         {tooltip=Capture over a named serial port instead of BLE. A port must be named: \
87         umshctl never probes serial ports, because opening one can reset or DFU-trigger \
88         hardware.}{type=string}{required=false}{group=Connection}\n",
89    );
90    out.push_str(
91        "arg {number=2}{call=--baud}{display=Serial bit rate}{type=unsigned}\
92         {default=115200}{required=false}{group=Connection}\n",
93    );
94    out.push_str(
95        "arg {number=3}{call=--umsh-only}{display=UMSH frames only}\
96         {tooltip=Drop frames that are not valid UMSH before they reach Wireshark.}\
97         {type=boolflag}{default=false}{group=Capture}\n",
98    );
99    out.push_str(
100        "arg {number=4}{call=--idle-probe-secs}{display=Idle health probe (s)}\
101         {tooltip=Seconds of silence before the link and channel RSSI are re-checked.}\
102         {type=unsigned}{default=10}{range=1,3600}{group=Capture}\n",
103    );
104    out.push_str(
105        "arg {number=5}{call=--no-reconnect}{display=Do not recover a dropped BLE link}\
106         {type=boolflag}{default=false}{group=Capture}\n",
107    );
108    for (number, call, display, hint) in [
109        (6, "--freq-khz", "Frequency (kHz)", ""),
110        (7, "--bw-hz", "Bandwidth (Hz)", ""),
111        (8, "--sf", "Spreading factor", " (5-12)"),
112        (9, "--cr", "Coding rate denominator", " (5-8)"),
113        (10, "--sync-word", "Sync word (hex)", ""),
114    ] {
115        out.push_str(&format!(
116            "arg {{number={number}}}{{call={call}}}{{display={display}{hint}}}\
117             {{tooltip=Live-only override, never saved to the radio. Leave empty to \
118             capture on the radio's current configuration.}}{{type=string}}\
119             {{required=false}}{{group=RF overrides}}\n"
120        ));
121    }
122    out
123}
124
125/// The always-present first entry of the radio selector.
126fn radio_value_auto() -> String {
127    "value {arg=0}{value=}{display=Auto (saved default, then discover)}{default=true}\n".to_string()
128}
129
130/// The reply to `--extcap-reload-option=--radio`: what a scan just saw.
131///
132/// This is the one phase allowed to touch the radio, and only because
133/// the user pressed Reload in the dialog.
134pub fn radio_values(found: &[Found]) -> String {
135    let mut out = radio_value_auto();
136    for entry in found {
137        let name = entry.name.as_deref().unwrap_or("(no name)");
138        let rssi = match entry.rssi {
139            Some(rssi) => format!("  ({rssi} dBm)"),
140            None => String::new(),
141        };
142        out.push_str(&format!(
143            "value {{arg=0}}{{value={}}}{{display={}{}}}\n",
144            sanitize(&entry.id),
145            sanitize(name),
146            rssi,
147        ));
148    }
149    out
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn found(id: &str, name: Option<&str>, rssi: Option<i16>) -> Found {
157        Found {
158            id: id.to_string(),
159            name: name.map(str::to_string),
160            rssi,
161        }
162    }
163
164    #[test]
165    fn interfaces_names_the_saved_default() {
166        let plain = interfaces(None);
167        assert!(plain.contains("interface {value=umsh}{display=UMSH radio}"));
168
169        let device = DefaultDevice {
170            selector: "ABC-123".to_string(),
171            name: Some("T-Echo".to_string()),
172        };
173        let named = interfaces(Some(&device));
174        assert!(named.contains("{display=UMSH radio (T-Echo)}"), "{named}");
175    }
176
177    /// Wireshark cannot tell us which DLT the user picked, so offering a
178    /// choice would be a lie.
179    #[test]
180    fn exactly_one_dlt_is_offered() {
181        let text = dlts();
182        assert_eq!(text.lines().count(), 1);
183        assert!(text.contains("{number=270}"));
184    }
185
186    #[test]
187    fn config_numbers_are_contiguous_and_calls_unique() {
188        let text = config();
189        let mut numbers = Vec::new();
190        let mut calls = Vec::new();
191        for line in text.lines().filter(|line| line.starts_with("arg ")) {
192            let number = line
193                .split("{number=")
194                .nth(1)
195                .and_then(|rest| rest.split('}').next())
196                .unwrap()
197                .parse::<usize>()
198                .unwrap();
199            let call = line
200                .split("{call=")
201                .nth(1)
202                .and_then(|rest| rest.split('}').next())
203                .unwrap()
204                .to_string();
205            numbers.push(number);
206            calls.push(call);
207        }
208        assert_eq!(
209            numbers,
210            (0..numbers.len()).collect::<Vec<_>>(),
211            "a gap or duplicate silently drops an argument from the dialog",
212        );
213        let mut unique = calls.clone();
214        unique.sort();
215        unique.dedup();
216        assert_eq!(unique.len(), calls.len(), "duplicate {{call=}}: {calls:?}");
217    }
218
219    /// Every `type=selector` needs at least one value line, or the
220    /// dialog renders an empty, unusable dropdown.
221    #[test]
222    fn the_radio_selector_always_offers_auto() {
223        assert!(config().contains("value {arg=0}{value=}"));
224        assert!(radio_values(&[]).contains("{value=}"));
225    }
226
227    #[test]
228    fn radio_values_render_name_and_rssi() {
229        let text = radio_values(&[
230            found("id-1", Some("T-Echo"), Some(-52)),
231            found("id-2", None, None),
232        ]);
233        assert!(
234            text.contains("{value=id-1}{display=T-Echo  (-52 dBm)}"),
235            "{text}"
236        );
237        assert!(text.contains("{value=id-2}{display=(no name)}"), "{text}");
238    }
239
240    /// A device names itself over the air, so the name is untrusted
241    /// input to a brace-delimited grammar.
242    #[test]
243    fn a_hostile_device_name_cannot_forge_lines() {
244        let text = radio_values(&[found("id", Some("evil}\ninterface {value=fake"), None)]);
245        assert_eq!(
246            text.lines().count(),
247            2,
248            "auto entry plus exactly one device: {text}",
249        );
250        assert!(!text.contains("interface {value=fake"));
251    }
252}