1use std::fs::File;
16use std::io::{BufWriter, Write};
17use std::path::Path;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use umsh::hal::RxInfo;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
24pub enum CaptureLayers {
25 #[default]
27 Radio,
28 #[value(alias = "companion")]
30 Ulcp,
31 Both,
32}
33
34impl CaptureLayers {
35 pub fn radio(self) -> bool {
36 matches!(self, Self::Radio | Self::Both)
37 }
38
39 pub fn ulcp(self) -> bool {
40 matches!(self, Self::Ulcp | Self::Both)
41 }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum PcapDirection {
46 HostToDevice,
47 DeviceToHost,
48}
49
50const PCAP_LINKTYPE_ETHERNET: u32 = 1;
51const PCAP_LINKTYPE_LORATAP: u32 = 270;
52const RADIO_UDP_PORT: u16 = 4242;
53const ULCP_HOST_UDP_PORT: u16 = 4243;
54const ULCP_DEVICE_UDP_PORT: u16 = 4244;
55
56const LORATAP_V0_LEN: u16 = 15;
58
59const LORATAP_RSSI_BIAS: i32 = 139;
62
63#[derive(Clone, Copy, Debug)]
64pub enum PcapEncapsulation {
65 Ethernet,
66 RawLoRa { linktype: u32 },
67 LoRaTap,
68}
69
70#[derive(Clone, Copy, Debug, Default)]
76pub struct RfParams {
77 pub freq_hz: u32,
78 pub bw_hz: u32,
79 pub sf: u8,
80 pub sync_word: u8,
81}
82
83pub struct PcapWriter {
84 output: BufWriter<Box<dyn Write>>,
85 layers: CaptureLayers,
86 encapsulation: PcapEncapsulation,
87 packet_id: u16,
88}
89
90impl PcapWriter {
91 pub fn create(
92 path: &Path,
93 layers: CaptureLayers,
94 encapsulation: PcapEncapsulation,
95 ) -> std::io::Result<Self> {
96 Self::to_writer(Box::new(File::create(path)?), layers, encapsulation)
97 }
98
99 pub fn to_writer(
104 sink: Box<dyn Write>,
105 layers: CaptureLayers,
106 encapsulation: PcapEncapsulation,
107 ) -> std::io::Result<Self> {
108 let mut output = BufWriter::new(sink);
109 output.write_all(&0xa1b2_c3d4u32.to_le_bytes())?;
110 output.write_all(&2u16.to_le_bytes())?;
111 output.write_all(&4u16.to_le_bytes())?;
112 output.write_all(&0i32.to_le_bytes())?;
113 output.write_all(&0u32.to_le_bytes())?;
114 output.write_all(&65_535u32.to_le_bytes())?;
115 let linktype = match encapsulation {
116 PcapEncapsulation::Ethernet => PCAP_LINKTYPE_ETHERNET,
117 PcapEncapsulation::RawLoRa { linktype } => linktype,
118 PcapEncapsulation::LoRaTap => PCAP_LINKTYPE_LORATAP,
119 };
120 output.write_all(&linktype.to_le_bytes())?;
121 output.flush()?;
122 Ok(Self {
123 output,
124 layers,
125 encapsulation,
126 packet_id: 0,
127 })
128 }
129
130 pub fn write_radio(&mut self, frame: &[u8]) -> std::io::Result<()> {
131 if self.layers.radio() {
132 match self.encapsulation {
133 PcapEncapsulation::Ethernet => self.write_udp(
134 PcapDirection::DeviceToHost,
135 RADIO_UDP_PORT,
136 RADIO_UDP_PORT,
137 frame,
138 )?,
139 PcapEncapsulation::RawLoRa { .. } => self.write_record(frame)?,
140 PcapEncapsulation::LoRaTap => {
143 return Err(std::io::Error::other(
144 "LoRaTap capture requires per-frame reception metadata",
145 ));
146 }
147 }
148 }
149 Ok(())
150 }
151
152 pub fn write_radio_with_info(
157 &mut self,
158 rf: &RfParams,
159 info: &RxInfo,
160 frame: &[u8],
161 ) -> std::io::Result<()> {
162 if !self.layers.radio() {
163 return Ok(());
164 }
165 if !matches!(self.encapsulation, PcapEncapsulation::LoRaTap) {
166 return self.write_radio(frame);
167 }
168
169 let mut packet = Vec::with_capacity(usize::from(LORATAP_V0_LEN) + frame.len());
170 packet.push(0); packet.push(0); packet.extend_from_slice(&LORATAP_V0_LEN.to_be_bytes());
173 packet.extend_from_slice(&rf.freq_hz.to_be_bytes());
174 packet.push(loratap_bandwidth(rf.bw_hz));
175 packet.push(rf.sf);
176 let rssi = loratap_rssi(i32::from(info.rssi));
177 packet.push(rssi); packet.push(rssi); packet.push(rssi); packet.push(loratap_snr(info.snr.as_centibels()));
181 packet.push(rf.sync_word);
182 packet.extend_from_slice(frame);
183
184 self.write_record(&packet)
185 }
186
187 pub fn write_ulcp(&mut self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
188 if !self.layers.ulcp() {
189 return Ok(());
190 }
191 debug_assert!(matches!(self.encapsulation, PcapEncapsulation::Ethernet));
192 let (src_port, dst_port) = match direction {
193 PcapDirection::HostToDevice => (ULCP_HOST_UDP_PORT, ULCP_DEVICE_UDP_PORT),
194 PcapDirection::DeviceToHost => (ULCP_DEVICE_UDP_PORT, ULCP_HOST_UDP_PORT),
195 };
196 self.write_udp(direction, src_port, dst_port, frame)
197 }
198
199 fn write_udp(
200 &mut self,
201 direction: PcapDirection,
202 src_port: u16,
203 dst_port: u16,
204 payload: &[u8],
205 ) -> std::io::Result<()> {
206 let udp_len = 8usize
207 .checked_add(payload.len())
208 .and_then(|len| u16::try_from(len).ok())
209 .ok_or_else(|| std::io::Error::other("capture payload exceeds IPv4 UDP size"))?;
210 let ip_len = 20u16
211 .checked_add(udp_len)
212 .ok_or_else(|| std::io::Error::other("capture packet exceeds IPv4 size"))?;
213 let frame_len = 14usize + usize::from(ip_len);
214 let mut packet = Vec::with_capacity(frame_len);
215
216 packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]);
219 packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
220 packet.extend_from_slice(&0x0800u16.to_be_bytes());
221 let (src_ip, dst_ip) = match direction {
222 PcapDirection::HostToDevice => ([127, 0, 0, 1], [127, 0, 0, 2]),
223 PcapDirection::DeviceToHost => ([127, 0, 0, 2], [127, 0, 0, 1]),
224 };
225 let ip_start = packet.len();
226 packet.extend_from_slice(&[
227 0x45,
228 0,
229 (ip_len >> 8) as u8,
230 ip_len as u8,
231 (self.packet_id >> 8) as u8,
232 self.packet_id as u8,
233 0,
234 0,
235 64,
236 17,
237 0,
238 0,
239 src_ip[0],
240 src_ip[1],
241 src_ip[2],
242 src_ip[3],
243 dst_ip[0],
244 dst_ip[1],
245 dst_ip[2],
246 dst_ip[3],
247 ]);
248 let checksum = ipv4_checksum(&packet[ip_start..ip_start + 20]);
249 packet[ip_start + 10..ip_start + 12].copy_from_slice(&checksum.to_be_bytes());
250 packet.extend_from_slice(&src_port.to_be_bytes());
251 packet.extend_from_slice(&dst_port.to_be_bytes());
252 packet.extend_from_slice(&udp_len.to_be_bytes());
253 packet.extend_from_slice(&0u16.to_be_bytes());
254 packet.extend_from_slice(payload);
255 self.packet_id = self.packet_id.wrapping_add(1);
256
257 self.write_record(&packet)
258 }
259
260 fn write_record(&mut self, packet: &[u8]) -> std::io::Result<()> {
261 let timestamp = SystemTime::now()
262 .duration_since(UNIX_EPOCH)
263 .unwrap_or_default();
264 let seconds = u32::try_from(timestamp.as_secs()).unwrap_or(u32::MAX);
265 let captured_len = u32::try_from(packet.len())
266 .map_err(|_| std::io::Error::other("capture record exceeds pcap size"))?;
267 self.output.write_all(&seconds.to_le_bytes())?;
268 self.output
269 .write_all(×tamp.subsec_micros().to_le_bytes())?;
270 self.output.write_all(&captured_len.to_le_bytes())?;
271 self.output.write_all(&captured_len.to_le_bytes())?;
272 self.output.write_all(packet)?;
273 self.output.flush()
275 }
276}
277
278fn loratap_bandwidth(bw_hz: u32) -> u8 {
286 match bw_hz {
287 125_000 => 1,
288 250_000 => 2,
289 500_000 => 4,
290 _ => 0,
291 }
292}
293
294fn loratap_rssi(dbm: i32) -> u8 {
297 (dbm + LORATAP_RSSI_BIAS).clamp(0, u8::MAX as i32) as u8
298}
299
300fn loratap_snr(centibels: i16) -> u8 {
303 let scaled = i32::from(centibels) * 4;
304 let quarters = if scaled >= 0 {
305 (scaled + 5) / 10
306 } else {
307 (scaled - 5) / 10
308 };
309 quarters.clamp(i8::MIN as i32, i8::MAX as i32) as i8 as u8
310}
311
312fn ipv4_checksum(header: &[u8]) -> u16 {
313 let mut sum = 0u32;
314 for word in header.chunks_exact(2) {
315 sum += u32::from(u16::from_be_bytes([word[0], word[1]]));
316 }
317 while sum > 0xffff {
318 sum = (sum & 0xffff) + (sum >> 16);
319 }
320 !(sum as u16)
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use std::path::PathBuf;
327
328 fn temp_capture_path(label: &str) -> PathBuf {
329 let nonce = SystemTime::now()
330 .duration_since(UNIX_EPOCH)
331 .unwrap()
332 .as_nanos();
333 std::env::temp_dir().join(format!(
334 "umshctl-capture-{label}-{}-{nonce}.pcap",
335 std::process::id(),
336 ))
337 }
338
339 #[test]
340 fn raw_pcap_preserves_lora_bytes_and_requested_linktype() {
341 let path = temp_capture_path("raw");
342 let mut writer = PcapWriter::create(
343 &path,
344 CaptureLayers::Radio,
345 PcapEncapsulation::RawLoRa { linktype: 147 },
346 )
347 .unwrap();
348 writer.write_radio(&[0xc0, 0xa1, 0xb2, 0x03]).unwrap();
349 drop(writer);
350
351 let bytes = std::fs::read(&path).unwrap();
352 let _ = std::fs::remove_file(path);
353 assert_eq!(u32::from_le_bytes(bytes[20..24].try_into().unwrap()), 147);
354 assert_eq!(u32::from_le_bytes(bytes[32..36].try_into().unwrap()), 4);
355 assert_eq!(&bytes[40..], &[0xc0, 0xa1, 0xb2, 0x03]);
356 }
357
358 #[test]
359 fn ethernet_pcap_preserves_ulcp_direction_and_payload() {
360 let path = temp_capture_path("ulcp");
361 let mut writer =
362 PcapWriter::create(&path, CaptureLayers::Ulcp, PcapEncapsulation::Ethernet).unwrap();
363 writer
364 .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
365 .unwrap();
366 drop(writer);
367
368 let bytes = std::fs::read(&path).unwrap();
369 let _ = std::fs::remove_file(path);
370 let packet = &bytes[40..];
371 assert_eq!(&packet[12..14], &0x0800u16.to_be_bytes());
372 assert_eq!(packet[23], 17);
373 assert_eq!(
374 u16::from_be_bytes(packet[34..36].try_into().unwrap()),
375 ULCP_HOST_UDP_PORT,
376 );
377 assert_eq!(
378 u16::from_be_bytes(packet[36..38].try_into().unwrap()),
379 ULCP_DEVICE_UDP_PORT,
380 );
381 assert_eq!(&packet[42..], &[0x81, 0x02, 0x26]);
382 }
383
384 #[derive(Clone, Default)]
386 struct SharedSink(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
387
388 impl Write for SharedSink {
389 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
390 self.0.borrow_mut().extend_from_slice(buf);
391 Ok(buf.len())
392 }
393 fn flush(&mut self) -> std::io::Result<()> {
394 Ok(())
395 }
396 }
397
398 fn sample_rf() -> RfParams {
399 RfParams {
400 freq_hz: 910_525_000,
401 bw_hz: 125_000,
402 sf: 7,
403 sync_word: 0x2b,
404 }
405 }
406
407 fn sample_info(len: usize, rssi: i16, snr_centibels: i16) -> RxInfo {
408 RxInfo {
409 len,
410 rssi,
411 snr: umsh::hal::Snr::from_centibels(snr_centibels),
412 lqi: None,
413 }
414 }
415
416 #[test]
420 fn loratap_records_channel_and_reception_metadata() {
421 let sink = SharedSink::default();
422 let bytes = sink.0.clone();
423 let mut writer = PcapWriter::to_writer(
424 Box::new(sink),
425 CaptureLayers::Radio,
426 PcapEncapsulation::LoRaTap,
427 )
428 .unwrap();
429 let frame = [0xc0, 0xa1, 0xb2, 0x03, 0x11, 0x22];
430 writer
431 .write_radio_with_info(&sample_rf(), &sample_info(frame.len(), -52, 95), &frame)
432 .unwrap();
433 drop(writer);
434
435 let bytes = bytes.borrow();
436 assert_eq!(
437 u32::from_le_bytes(bytes[20..24].try_into().unwrap()),
438 PCAP_LINKTYPE_LORATAP,
439 );
440 let packet = &bytes[40..];
441 assert_eq!(packet.len(), 15 + frame.len());
442 assert_eq!(packet[0], 0, "version");
443 assert_eq!(u16::from_be_bytes(packet[2..4].try_into().unwrap()), 15);
444 assert_eq!(
445 u32::from_be_bytes(packet[4..8].try_into().unwrap()),
446 910_525_000,
447 );
448 assert_eq!(packet[8], 1, "125 kHz in 125 kHz steps");
449 assert_eq!(packet[9], 7, "spreading factor");
450 assert_eq!(packet[10], 87, "-52 dBm biased by 139");
451 assert_eq!(packet[13], 38, "9.5 dB in quarter-dB steps");
452 assert_eq!(packet[14], 0x2b, "sync word");
453 assert_eq!(&packet[15..], &frame);
454 }
455
456 #[test]
459 fn loratap_bandwidth_is_an_enumeration_not_a_scale() {
460 assert_eq!(loratap_bandwidth(125_000), 1);
461 assert_eq!(loratap_bandwidth(250_000), 2);
462 assert_eq!(loratap_bandwidth(500_000), 4);
463 assert_eq!(loratap_bandwidth(62_500), 0, "UMSH's default is unnameable");
464 assert_eq!(loratap_bandwidth(200_000), 0, "not rounded down to 125 kHz");
465 }
466
467 #[test]
468 fn loratap_encodes_negative_snr_as_a_signed_byte() {
469 assert_eq!(loratap_snr(-75) as i8, -30, "-7.5 dB");
470 assert_eq!(loratap_snr(0), 0);
471 assert_eq!(loratap_rssi(-52), 87);
472 assert_eq!(loratap_rssi(-200), 0, "saturates rather than wrapping");
473 }
474
475 #[test]
478 fn loratap_rejects_frames_without_metadata() {
479 let mut writer = PcapWriter::to_writer(
480 Box::new(SharedSink::default()),
481 CaptureLayers::Radio,
482 PcapEncapsulation::LoRaTap,
483 )
484 .unwrap();
485 assert!(writer.write_radio(&[0xc0, 0xa1]).is_err());
486 }
487
488 #[test]
489 fn a_radio_only_file_ignores_ulcp_frames() {
490 let path = temp_capture_path("radio-only");
491 let mut writer =
492 PcapWriter::create(&path, CaptureLayers::Radio, PcapEncapsulation::Ethernet).unwrap();
493 writer
494 .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
495 .unwrap();
496 drop(writer);
497
498 let bytes = std::fs::read(&path).unwrap();
499 let _ = std::fs::remove_file(path);
500 assert_eq!(bytes.len(), 24, "header only, no records");
501 }
502}