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, snr) = if info.origin.is_measured() {
182 (
183 loratap_rssi(i32::from(info.rssi)),
184 loratap_snr(info.snr.as_centibels()),
185 )
186 } else {
187 (0, loratap_snr(i16::MIN))
188 };
189 packet.push(rssi); packet.push(rssi); packet.push(rssi); packet.push(snr);
193 packet.push(rf.sync_word);
194 packet.extend_from_slice(frame);
195
196 self.write_record(&packet)
197 }
198
199 pub fn write_ulcp(&mut self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
200 if !self.layers.ulcp() {
201 return Ok(());
202 }
203 debug_assert!(matches!(self.encapsulation, PcapEncapsulation::Ethernet));
204 let (src_port, dst_port) = match direction {
205 PcapDirection::HostToDevice => (ULCP_HOST_UDP_PORT, ULCP_DEVICE_UDP_PORT),
206 PcapDirection::DeviceToHost => (ULCP_DEVICE_UDP_PORT, ULCP_HOST_UDP_PORT),
207 };
208 self.write_udp(direction, src_port, dst_port, frame)
209 }
210
211 fn write_udp(
212 &mut self,
213 direction: PcapDirection,
214 src_port: u16,
215 dst_port: u16,
216 payload: &[u8],
217 ) -> std::io::Result<()> {
218 let udp_len = 8usize
219 .checked_add(payload.len())
220 .and_then(|len| u16::try_from(len).ok())
221 .ok_or_else(|| std::io::Error::other("capture payload exceeds IPv4 UDP size"))?;
222 let ip_len = 20u16
223 .checked_add(udp_len)
224 .ok_or_else(|| std::io::Error::other("capture packet exceeds IPv4 size"))?;
225 let frame_len = 14usize + usize::from(ip_len);
226 let mut packet = Vec::with_capacity(frame_len);
227
228 packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]);
231 packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
232 packet.extend_from_slice(&0x0800u16.to_be_bytes());
233 let (src_ip, dst_ip) = match direction {
234 PcapDirection::HostToDevice => ([127, 0, 0, 1], [127, 0, 0, 2]),
235 PcapDirection::DeviceToHost => ([127, 0, 0, 2], [127, 0, 0, 1]),
236 };
237 let ip_start = packet.len();
238 packet.extend_from_slice(&[
239 0x45,
240 0,
241 (ip_len >> 8) as u8,
242 ip_len as u8,
243 (self.packet_id >> 8) as u8,
244 self.packet_id as u8,
245 0,
246 0,
247 64,
248 17,
249 0,
250 0,
251 src_ip[0],
252 src_ip[1],
253 src_ip[2],
254 src_ip[3],
255 dst_ip[0],
256 dst_ip[1],
257 dst_ip[2],
258 dst_ip[3],
259 ]);
260 let checksum = ipv4_checksum(&packet[ip_start..ip_start + 20]);
261 packet[ip_start + 10..ip_start + 12].copy_from_slice(&checksum.to_be_bytes());
262 packet.extend_from_slice(&src_port.to_be_bytes());
263 packet.extend_from_slice(&dst_port.to_be_bytes());
264 packet.extend_from_slice(&udp_len.to_be_bytes());
265 packet.extend_from_slice(&0u16.to_be_bytes());
266 packet.extend_from_slice(payload);
267 self.packet_id = self.packet_id.wrapping_add(1);
268
269 self.write_record(&packet)
270 }
271
272 fn write_record(&mut self, packet: &[u8]) -> std::io::Result<()> {
273 let timestamp = SystemTime::now()
274 .duration_since(UNIX_EPOCH)
275 .unwrap_or_default();
276 let seconds = u32::try_from(timestamp.as_secs()).unwrap_or(u32::MAX);
277 let captured_len = u32::try_from(packet.len())
278 .map_err(|_| std::io::Error::other("capture record exceeds pcap size"))?;
279 self.output.write_all(&seconds.to_le_bytes())?;
280 self.output
281 .write_all(×tamp.subsec_micros().to_le_bytes())?;
282 self.output.write_all(&captured_len.to_le_bytes())?;
283 self.output.write_all(&captured_len.to_le_bytes())?;
284 self.output.write_all(packet)?;
285 self.output.flush()
287 }
288}
289
290fn loratap_bandwidth(bw_hz: u32) -> u8 {
298 match bw_hz {
299 125_000 => 1,
300 250_000 => 2,
301 500_000 => 4,
302 _ => 0,
303 }
304}
305
306fn loratap_rssi(dbm: i32) -> u8 {
309 (dbm + LORATAP_RSSI_BIAS).clamp(0, u8::MAX as i32) as u8
310}
311
312fn loratap_snr(centibels: i16) -> u8 {
315 let scaled = i32::from(centibels) * 4;
316 let quarters = if scaled >= 0 {
317 (scaled + 5) / 10
318 } else {
319 (scaled - 5) / 10
320 };
321 quarters.clamp(i8::MIN as i32, i8::MAX as i32) as i8 as u8
322}
323
324fn ipv4_checksum(header: &[u8]) -> u16 {
325 let mut sum = 0u32;
326 for word in header.chunks_exact(2) {
327 sum += u32::from(u16::from_be_bytes([word[0], word[1]]));
328 }
329 while sum > 0xffff {
330 sum = (sum & 0xffff) + (sum >> 16);
331 }
332 !(sum as u16)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use std::path::PathBuf;
339
340 fn temp_capture_path(label: &str) -> PathBuf {
341 let nonce = SystemTime::now()
342 .duration_since(UNIX_EPOCH)
343 .unwrap()
344 .as_nanos();
345 std::env::temp_dir().join(format!(
346 "umshctl-capture-{label}-{}-{nonce}.pcap",
347 std::process::id(),
348 ))
349 }
350
351 #[test]
352 fn raw_pcap_preserves_lora_bytes_and_requested_linktype() {
353 let path = temp_capture_path("raw");
354 let mut writer = PcapWriter::create(
355 &path,
356 CaptureLayers::Radio,
357 PcapEncapsulation::RawLoRa { linktype: 147 },
358 )
359 .unwrap();
360 writer.write_radio(&[0xc0, 0xa1, 0xb2, 0x03]).unwrap();
361 drop(writer);
362
363 let bytes = std::fs::read(&path).unwrap();
364 let _ = std::fs::remove_file(path);
365 assert_eq!(u32::from_le_bytes(bytes[20..24].try_into().unwrap()), 147);
366 assert_eq!(u32::from_le_bytes(bytes[32..36].try_into().unwrap()), 4);
367 assert_eq!(&bytes[40..], &[0xc0, 0xa1, 0xb2, 0x03]);
368 }
369
370 #[test]
371 fn ethernet_pcap_preserves_ulcp_direction_and_payload() {
372 let path = temp_capture_path("ulcp");
373 let mut writer =
374 PcapWriter::create(&path, CaptureLayers::Ulcp, PcapEncapsulation::Ethernet).unwrap();
375 writer
376 .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
377 .unwrap();
378 drop(writer);
379
380 let bytes = std::fs::read(&path).unwrap();
381 let _ = std::fs::remove_file(path);
382 let packet = &bytes[40..];
383 assert_eq!(&packet[12..14], &0x0800u16.to_be_bytes());
384 assert_eq!(packet[23], 17);
385 assert_eq!(
386 u16::from_be_bytes(packet[34..36].try_into().unwrap()),
387 ULCP_HOST_UDP_PORT,
388 );
389 assert_eq!(
390 u16::from_be_bytes(packet[36..38].try_into().unwrap()),
391 ULCP_DEVICE_UDP_PORT,
392 );
393 assert_eq!(&packet[42..], &[0x81, 0x02, 0x26]);
394 }
395
396 #[derive(Clone, Default)]
398 struct SharedSink(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
399
400 impl Write for SharedSink {
401 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
402 self.0.borrow_mut().extend_from_slice(buf);
403 Ok(buf.len())
404 }
405 fn flush(&mut self) -> std::io::Result<()> {
406 Ok(())
407 }
408 }
409
410 fn sample_rf() -> RfParams {
411 RfParams {
412 freq_hz: 910_525_000,
413 bw_hz: 125_000,
414 sf: 7,
415 sync_word: 0x2b,
416 }
417 }
418
419 fn sample_info(len: usize, rssi: i16, snr_centibels: i16) -> RxInfo {
420 RxInfo {
421 len,
422 rssi,
423 snr: umsh::hal::Snr::from_centibels(snr_centibels),
424 lqi: None,
425 origin: umsh::hal::RxOrigin::Air,
426 }
427 }
428
429 #[test]
433 fn loratap_records_channel_and_reception_metadata() {
434 let sink = SharedSink::default();
435 let bytes = sink.0.clone();
436 let mut writer = PcapWriter::to_writer(
437 Box::new(sink),
438 CaptureLayers::Radio,
439 PcapEncapsulation::LoRaTap,
440 )
441 .unwrap();
442 let frame = [0xc0, 0xa1, 0xb2, 0x03, 0x11, 0x22];
443 writer
444 .write_radio_with_info(&sample_rf(), &sample_info(frame.len(), -52, 95), &frame)
445 .unwrap();
446 drop(writer);
447
448 let bytes = bytes.borrow();
449 assert_eq!(
450 u32::from_le_bytes(bytes[20..24].try_into().unwrap()),
451 PCAP_LINKTYPE_LORATAP,
452 );
453 let packet = &bytes[40..];
454 assert_eq!(packet.len(), 15 + frame.len());
455 assert_eq!(packet[0], 0, "version");
456 assert_eq!(u16::from_be_bytes(packet[2..4].try_into().unwrap()), 15);
457 assert_eq!(
458 u32::from_be_bytes(packet[4..8].try_into().unwrap()),
459 910_525_000,
460 );
461 assert_eq!(packet[8], 1, "125 kHz in 125 kHz steps");
462 assert_eq!(packet[9], 7, "spreading factor");
463 assert_eq!(packet[10], 87, "-52 dBm biased by 139");
464 assert_eq!(packet[13], 38, "9.5 dB in quarter-dB steps");
465 assert_eq!(packet[14], 0x2b, "sync word");
466 assert_eq!(&packet[15..], &frame);
467 }
468
469 #[test]
473 fn loratap_records_self_tx_at_the_rails() {
474 let sink = SharedSink::default();
475 let bytes = sink.0.clone();
476 let mut writer = PcapWriter::to_writer(
477 Box::new(sink),
478 CaptureLayers::Radio,
479 PcapEncapsulation::LoRaTap,
480 )
481 .unwrap();
482 let frame = [0xc0, 0xa1];
483 let mut info = sample_info(frame.len(), 0, 0);
484 info.origin = umsh::hal::RxOrigin::Backhaul;
485 writer
486 .write_radio_with_info(&sample_rf(), &info, &frame)
487 .unwrap();
488 drop(writer);
489
490 let bytes = bytes.borrow();
491 let packet = &bytes[40..];
492 assert_eq!(packet[10], 0, "RSSI at the -139 dBm floor");
493 assert_eq!(packet[13] as i8, i8::MIN, "SNR at the -32 dB rail");
494 }
495
496 #[test]
499 fn loratap_bandwidth_is_an_enumeration_not_a_scale() {
500 assert_eq!(loratap_bandwidth(125_000), 1);
501 assert_eq!(loratap_bandwidth(250_000), 2);
502 assert_eq!(loratap_bandwidth(500_000), 4);
503 assert_eq!(loratap_bandwidth(62_500), 0, "UMSH's default is unnameable");
504 assert_eq!(loratap_bandwidth(200_000), 0, "not rounded down to 125 kHz");
505 }
506
507 #[test]
508 fn loratap_encodes_negative_snr_as_a_signed_byte() {
509 assert_eq!(loratap_snr(-75) as i8, -30, "-7.5 dB");
510 assert_eq!(loratap_snr(0), 0);
511 assert_eq!(loratap_rssi(-52), 87);
512 assert_eq!(loratap_rssi(-200), 0, "saturates rather than wrapping");
513 }
514
515 #[test]
518 fn loratap_rejects_frames_without_metadata() {
519 let mut writer = PcapWriter::to_writer(
520 Box::new(SharedSink::default()),
521 CaptureLayers::Radio,
522 PcapEncapsulation::LoRaTap,
523 )
524 .unwrap();
525 assert!(writer.write_radio(&[0xc0, 0xa1]).is_err());
526 }
527
528 #[test]
529 fn a_radio_only_file_ignores_ulcp_frames() {
530 let path = temp_capture_path("radio-only");
531 let mut writer =
532 PcapWriter::create(&path, CaptureLayers::Radio, PcapEncapsulation::Ethernet).unwrap();
533 writer
534 .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
535 .unwrap();
536 drop(writer);
537
538 let bytes = std::fs::read(&path).unwrap();
539 let _ = std::fs::remove_file(path);
540 assert_eq!(bytes.len(), 24, "header only, no records");
541 }
542}