umshctl/extcap/
protocol.rs1use crate::connection::{DefaultDevice, Found};
10
11pub const INTERFACE: &str = "umsh";
15
16pub const DLT_LORATAP: u32 = 270;
20
21pub const CALL_RADIO: &str = "--radio";
23
24fn 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
39pub 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
60pub fn dlts() -> String {
65 format!("dlt {{number={DLT_LORATAP}}}{{name=LORATAP}}{{display=LoRaTap}}\n")
66}
67
68pub 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
125fn radio_value_auto() -> String {
127 "value {arg=0}{value=}{display=Auto (saved default, then discover)}{default=true}\n".to_string()
128}
129
130pub 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 #[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 #[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 #[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}