1use std::cell::RefCell;
14use std::io::{IsTerminal, Write};
15use std::path::PathBuf;
16use std::rc::Rc;
17use std::time::Duration;
18
19use anyhow::{Context as _, Result, anyhow, bail};
20
21use umsh::ulcp::{FrameLink, UlcpDevice, UlcpDeviceConfig, UlcpError};
22
23use crate::command::capture::pcap::{PcapDirection, PcapWriter};
24use crate::output;
25
26const DISCOVERY_WINDOW: Duration = Duration::from_secs(2);
28
29const DISCOVERY_EXTENSION: Duration = Duration::from_secs(3);
33
34pub fn attach_config() -> UlcpDeviceConfig {
38 UlcpDeviceConfig::new(910_525, 62_500, 7, 5)
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum Target {
45 Serial {
46 port: String,
47 baud: u32,
48 },
49 Ble {
53 selector: String,
54 name: Option<String>,
55 },
56}
57
58impl Target {
59 pub fn transport(&self) -> &'static str {
61 match self {
62 Self::Serial { .. } => "serial",
63 Self::Ble { .. } => "ble",
64 }
65 }
66
67 pub fn provisional_label(&self) -> String {
69 match self {
70 Self::Serial { port, .. } => {
71 port.rsplit('/').next().unwrap_or(port.as_str()).to_string()
72 }
73 Self::Ble { selector, name } => name.clone().unwrap_or_else(|| selector.clone()),
74 }
75 }
76}
77
78pub enum AnyLink {
85 #[cfg(feature = "serial-radio")]
86 Serial(umsh::ulcp::SerialFrameLink<tokio_serial::SerialStream>),
87 #[cfg(feature = "ble-radio")]
88 Ble(umsh::ulcp::BleFrameLink),
89 #[allow(dead_code)]
92 Unavailable,
93}
94
95impl FrameLink for AnyLink {
96 async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
97 match self {
98 #[cfg(feature = "serial-radio")]
99 Self::Serial(link) => link.send_frame(frame).await,
100 #[cfg(feature = "ble-radio")]
101 Self::Ble(link) => link.send_frame(frame).await,
102 Self::Unavailable => Err(UlcpError::Disconnected),
103 }
104 }
105
106 fn poll_recv_frame(
107 &mut self,
108 cx: &mut core::task::Context<'_>,
109 ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
110 match self {
111 #[cfg(feature = "serial-radio")]
112 Self::Serial(link) => link.poll_recv_frame(cx),
113 #[cfg(feature = "ble-radio")]
114 Self::Ble(link) => link.poll_recv_frame(cx),
115 Self::Unavailable => core::task::Poll::Ready(Err(UlcpError::Disconnected)),
116 }
117 }
118}
119
120pub type FrameTap = Rc<RefCell<Option<PcapWriter>>>;
127
128pub fn new_tap() -> FrameTap {
129 Rc::new(RefCell::new(None))
130}
131
132pub struct SessionLink {
135 inner: AnyLink,
136 tap: FrameTap,
137}
138
139impl SessionLink {
140 pub fn new(inner: AnyLink, tap: FrameTap) -> Self {
141 Self { inner, tap }
142 }
143
144 fn record(&self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
145 if let Some(writer) = self.tap.borrow_mut().as_mut() {
146 writer.write_ulcp(direction, frame)?;
147 }
148 Ok(())
149 }
150}
151
152impl FrameLink for SessionLink {
153 async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
154 self.record(PcapDirection::HostToDevice, frame)?;
155 self.inner.send_frame(frame).await
156 }
157
158 fn poll_recv_frame(
159 &mut self,
160 cx: &mut core::task::Context<'_>,
161 ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
162 match self.inner.poll_recv_frame(cx) {
163 core::task::Poll::Ready(Ok(frame)) => {
164 match self.record(PcapDirection::DeviceToHost, &frame) {
165 Ok(()) => core::task::Poll::Ready(Ok(frame)),
166 Err(error) => core::task::Poll::Ready(Err(error.into())),
167 }
168 }
169 other => other,
170 }
171 }
172}
173
174pub struct Session {
181 pub device: UlcpDevice<SessionLink>,
182 pub target: Target,
183 pub label: String,
184 pub tap: FrameTap,
185}
186
187impl Session {
188 pub fn is_administrative(&self) -> bool {
190 matches!(
191 self.device.attach_mode(),
192 umsh::ulcp::AttachMode::Administrative
193 )
194 }
195
196 pub async fn reattach(self, tethered: bool, trace: bool) -> Result<Self> {
202 let Self {
203 device,
204 target,
205 label,
206 tap,
207 } = self;
208 let link = device.into_link();
211 let mut device = reattach_link(link, tethered).await?;
212 if trace {
213 install_trace(&mut device);
214 }
215 Ok(Self {
216 device,
217 target,
218 label,
219 tap,
220 })
221 }
222
223 pub async fn reconnect(self, trace: bool) -> Result<Self> {
229 let Self {
230 device,
231 target,
232 label,
233 tap,
234 } = self;
235 drop(device);
236 let link = open(&target).await?;
237 let mut device = attach_tapped(link, tap.clone(), false).await?;
238 if trace {
239 install_trace(&mut device);
240 }
241 Ok(Self {
242 device,
243 target,
244 label,
245 tap,
246 })
247 }
248}
249
250pub fn install_trace(device: &mut UlcpDevice<SessionLink>) {
252 device.set_frame_trace(Some(Box::new(|direction, line| {
253 eprintln!("trace {direction} {line}");
254 })));
255}
256
257pub async fn open(target: &Target) -> Result<AnyLink> {
259 match target {
260 Target::Serial { port, baud } => open_serial(port, *baud).await,
261 Target::Ble { selector, .. } => open_ble(selector).await,
262 }
263}
264
265#[cfg(feature = "serial-radio")]
271async fn open_serial(port: &str, baud: u32) -> Result<AnyLink> {
272 use tokio_serial::SerialPortBuilderExt as _;
273 let stream = tokio_serial::new(port, baud)
274 .open_native_async()
275 .with_context(|| format!("opening {port}"))?;
276 Ok(AnyLink::Serial(umsh::ulcp::SerialFrameLink::new(stream)))
277}
278
279#[cfg(not(feature = "serial-radio"))]
280async fn open_serial(_port: &str, _baud: u32) -> Result<AnyLink> {
281 bail!("this build has no serial support (build with the serial-radio feature)")
282}
283
284#[cfg(feature = "ble-radio")]
285async fn open_ble(selector: &str) -> Result<AnyLink> {
286 use umsh::ulcp::{BleFrameLink, BleFrameLinkConfig};
287 let link = BleFrameLink::connect(Some(selector), BleFrameLinkConfig::default())
288 .await
289 .with_context(|| format!("connecting to BLE radio {selector:?}"))?;
290 Ok(AnyLink::Ble(link))
291}
292
293#[cfg(not(feature = "ble-radio"))]
294async fn open_ble(_selector: &str) -> Result<AnyLink> {
295 bail!("this build has no BLE support (build with the ble-radio feature)")
296}
297
298async fn attach_tapped(
299 link: AnyLink,
300 tap: FrameTap,
301 tethered: bool,
302) -> Result<UlcpDevice<SessionLink>> {
303 reattach_link(SessionLink::new(link, tap), tethered).await
304}
305
306async fn reattach_link(link: SessionLink, tethered: bool) -> Result<UlcpDevice<SessionLink>> {
313 let device = if tethered {
314 UlcpDevice::attach_existing(link, attach_config()).await
315 } else {
316 UlcpDevice::attach_administrative(link, attach_config()).await
317 }?;
318 Ok(device)
319}
320
321pub async fn connect(target: Target, tethered: bool, trace: bool) -> Result<Session> {
323 let tap = new_tap();
324 let link = open(&target).await?;
325 let mut device = attach_tapped(link, tap.clone(), tethered).await?;
326 if trace {
327 install_trace(&mut device);
328 }
329 let label = match device.device_name().await {
331 Ok(name) if !name.is_empty() => name,
332 _ => target.provisional_label(),
333 };
334 Ok(Session {
335 device,
336 target,
337 label,
338 tap,
339 })
340}
341
342#[derive(Clone, Debug, PartialEq, Eq)]
349pub struct Found {
350 pub id: String,
351 pub name: Option<String>,
352 pub rssi: Option<i16>,
353}
354
355#[cfg(feature = "ble-radio")]
357pub async fn scan(timeout: Duration) -> Result<Vec<Found>> {
358 let results = umsh::ulcp::BleFrameLink::scan(timeout)
359 .await
360 .context("scanning for BLE radios")?;
361 let mut found: Vec<Found> = results
362 .into_iter()
363 .map(|result| Found {
364 id: result.id,
365 name: result.name,
366 rssi: result.rssi,
367 })
368 .collect();
369 sort_found(&mut found);
370 Ok(found)
371}
372
373#[cfg(not(feature = "ble-radio"))]
375pub async fn scan(_timeout: Duration) -> Result<Vec<Found>> {
376 bail!("this build has no BLE support (build with the ble-radio feature)")
377}
378
379pub fn sort_found(found: &mut [Found]) {
385 found.sort_by(|left, right| {
386 let key = |entry: &Found| {
387 (
388 entry.name.is_none(),
389 entry.name.clone().unwrap_or_default(),
390 entry.id.clone(),
391 )
392 };
393 key(left).cmp(&key(right))
394 });
395}
396
397pub fn merge_found(into: &mut Vec<Found>, more: Vec<Found>) {
400 for entry in more {
401 match into.iter_mut().find(|existing| existing.id == entry.id) {
402 Some(existing) => {
403 existing.name = entry.name.or_else(|| existing.name.take());
404 existing.rssi = entry.rssi.or(existing.rssi);
405 }
406 None => into.push(entry),
407 }
408 }
409 sort_found(into);
410}
411
412pub fn render_found(found: &[Found]) {
414 if found.is_empty() {
415 println!("no ULCP radios found");
416 return;
417 }
418 let width = found.len().to_string().len();
419 for (index, entry) in found.iter().enumerate() {
420 let name = entry.name.as_deref().unwrap_or("(no name)");
421 match entry.rssi {
422 Some(rssi) => println!(
423 "{:>width$}) {name} {} rssi {rssi} dBm",
424 index + 1,
425 entry.id
426 ),
427 None => println!("{:>width$}) {name} {}", index + 1, entry.id),
428 }
429 }
430}
431
432impl From<&Found> for Target {
433 fn from(found: &Found) -> Self {
434 Target::Ble {
435 selector: found.id.clone(),
436 name: found.name.clone(),
437 }
438 }
439}
440
441#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
443pub enum Discovery {
444 #[default]
447 Auto,
448 Ask,
452}
453
454pub async fn discover(prefs: &Prefs, interactive: bool, how: Discovery) -> Result<Option<Target>> {
459 let mut seen = scan(DISCOVERY_WINDOW).await?;
460 let mut extended = false;
461
462 if let (Discovery::Auto, Some(saved)) = (how, &prefs.default_device) {
463 if let Some(found) = saved.find_in(&seen) {
464 return Ok(Some(Target::from(found)));
465 }
466 merge_found(&mut seen, scan(DISCOVERY_EXTENSION).await?);
467 extended = true;
468 if let Some(found) = saved.find_in(&seen) {
469 return Ok(Some(Target::from(found)));
470 }
471 output::warn(format!(
473 "default radio {} not found; discovering instead",
474 saved.display()
475 ));
476 }
477
478 if seen.is_empty() && !extended {
479 merge_found(&mut seen, scan(DISCOVERY_EXTENSION).await?);
480 }
481
482 choose(seen, interactive, how)
483}
484
485pub fn choose(found: Vec<Found>, interactive: bool, how: Discovery) -> Result<Option<Target>> {
489 match found.len() {
490 0 => Ok(None),
491 1 if how == Discovery::Auto => Ok(Some(Target::from(&found[0]))),
494 _ => {
495 render_found(&found);
496 if !interactive {
497 match how {
498 Discovery::Ask => bail!(
499 "choosing a radio needs a terminal; name one with --ble=SELECTOR instead"
500 ),
501 Discovery::Auto => bail!(
502 "{} ULCP radios are in range; name one with --ble=SELECTOR (or set a \
503 default with `default set`)",
504 found.len()
505 ),
506 }
507 }
508 let index = prompt_index(found.len())?;
509 Ok(index.map(|index| Target::from(&found[index])))
510 }
511 }
512}
513
514fn prompt_index(count: usize) -> Result<Option<usize>> {
517 loop {
518 print!("select radio [1-{count}, or blank to cancel]: ");
519 std::io::stdout().flush().ok();
520 let mut line = String::new();
521 if std::io::stdin().read_line(&mut line)? == 0 {
522 println!();
523 return Ok(None);
524 }
525 let line = line.trim();
526 if line.is_empty() {
527 return Ok(None);
528 }
529 match line.parse::<usize>() {
530 Ok(choice) if (1..=count).contains(&choice) => return Ok(Some(choice - 1)),
531 _ => eprintln!("expected a number from 1 to {count}"),
532 }
533 }
534}
535
536pub fn confirm(question: &str) -> Result<bool> {
538 if !std::io::stdin().is_terminal() {
539 return Ok(false);
540 }
541 print!("{question} [y/N]: ");
542 std::io::stdout().flush().ok();
543 let mut line = String::new();
544 if std::io::stdin().read_line(&mut line)? == 0 {
545 println!();
546 return Ok(false);
547 }
548 Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes"))
549}
550
551#[derive(Clone, Debug, Default, PartialEq, Eq)]
561pub struct DefaultDevice {
562 pub selector: String,
563 pub name: Option<String>,
564}
565
566impl DefaultDevice {
567 pub fn display(&self) -> String {
568 match &self.name {
569 Some(name) => format!("{name:?} ({})", self.selector),
570 None => self.selector.clone(),
571 }
572 }
573
574 fn find_in<'a>(&self, found: &'a [Found]) -> Option<&'a Found> {
575 found
576 .iter()
577 .find(|entry| entry.id == self.selector)
578 .or_else(|| {
579 found.iter().find(|entry| {
580 entry
581 .name
582 .as_deref()
583 .is_some_and(|name| Some(name) == self.name.as_deref())
584 })
585 })
586 .or_else(|| {
587 found.iter().find(|entry| {
588 entry
589 .name
590 .as_deref()
591 .is_some_and(|name| name.contains(&self.selector))
592 })
593 })
594 }
595}
596
597#[derive(Clone, Debug, Default, PartialEq, Eq)]
601pub struct Prefs {
602 pub default_device: Option<DefaultDevice>,
603}
604
605impl Prefs {
606 pub fn parse(text: &str) -> Self {
607 let mut prefs = Self::default();
608 let mut selector = None;
609 let mut name = None;
610 for raw in text.lines() {
611 let line = raw.split('#').next().unwrap_or("").trim();
612 let Some((setting, value)) = line.split_once('=') else {
613 continue;
614 };
615 match setting.trim() {
616 "default-selector" => selector = Some(value.trim().to_string()),
617 "default-name" => name = Some(value.trim().to_string()),
618 _ => {}
621 }
622 }
623 if let Some(selector) = selector {
624 prefs.default_device = Some(DefaultDevice { selector, name });
625 }
626 prefs
627 }
628
629 pub fn render(&self) -> String {
630 let mut text = String::from("# umshctl preferences\n");
631 if let Some(device) = &self.default_device {
632 text.push_str(&format!("default-selector = {}\n", device.selector));
633 if let Some(name) = &device.name {
634 text.push_str(&format!("default-name = {name}\n"));
635 }
636 }
637 text
638 }
639
640 pub fn load() -> Self {
641 config_path()
642 .and_then(|path| std::fs::read_to_string(path).ok())
643 .map(|text| Self::parse(&text))
644 .unwrap_or_default()
645 }
646
647 pub fn store(&self) -> Result<PathBuf> {
648 let path =
649 config_path().ok_or_else(|| anyhow!("no HOME directory to store settings in"))?;
650 if let Some(parent) = path.parent() {
651 std::fs::create_dir_all(parent)
652 .with_context(|| format!("creating {}", parent.display()))?;
653 }
654 std::fs::write(&path, self.render())
655 .with_context(|| format!("writing {}", path.display()))?;
656 Ok(path)
657 }
658}
659
660fn state_dir() -> Option<PathBuf> {
663 if let Some(state) = std::env::var_os("XDG_STATE_HOME").filter(|value| !value.is_empty()) {
664 return Some(PathBuf::from(state).join("umsh"));
665 }
666 let home = std::env::var_os("HOME").filter(|value| !value.is_empty())?;
667 Some(PathBuf::from(home).join(".local/state/umsh"))
668}
669
670pub fn config_path() -> Option<PathBuf> {
671 state_dir().map(|dir| dir.join("umshctl.conf"))
672}
673
674pub fn history_path() -> Option<PathBuf> {
675 state_dir().map(|dir| dir.join("umshctl-history"))
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 fn found(id: &str, name: Option<&str>, rssi: Option<i16>) -> Found {
683 Found {
684 id: id.to_string(),
685 name: name.map(str::to_string),
686 rssi,
687 }
688 }
689
690 #[test]
691 fn scan_results_sort_by_name_never_by_signal() {
692 let mut list = vec![
693 found("id-c", Some("T-Echo"), Some(-30)),
694 found("id-a", Some("Solar"), Some(-90)),
695 found("id-b", None, Some(-40)),
696 ];
697 sort_found(&mut list);
698 assert_eq!(
699 list.iter()
700 .map(|entry| entry.id.as_str())
701 .collect::<Vec<_>>(),
702 ["id-a", "id-c", "id-b"],
703 );
704
705 list[0].rssi = Some(-10);
707 let before = list.clone();
708 sort_found(&mut list);
709 assert_eq!(list, before);
710 }
711
712 #[test]
713 fn merging_a_second_scan_keeps_one_entry_per_radio() {
714 let mut list = vec![found("id-a", None, None)];
715 merge_found(&mut list, vec![found("id-a", Some("T-Echo"), Some(-55))]);
716 assert_eq!(list.len(), 1);
717 assert_eq!(list[0].name.as_deref(), Some("T-Echo"));
718 assert_eq!(list[0].rssi, Some(-55));
719 }
720
721 #[test]
722 fn a_single_radio_needs_no_chooser() {
723 let target = choose(
724 vec![found("id-a", Some("T-Echo"), None)],
725 false,
726 Discovery::Auto,
727 )
728 .unwrap()
729 .unwrap();
730 assert_eq!(
731 target,
732 Target::Ble {
733 selector: "id-a".into(),
734 name: Some("T-Echo".into())
735 }
736 );
737 }
738
739 #[test]
740 fn asking_explicitly_asks_even_about_a_single_radio() {
741 let error = choose(
744 vec![found("id-a", Some("T-Echo"), None)],
745 false,
746 Discovery::Ask,
747 )
748 .unwrap_err();
749 assert!(error.to_string().contains("terminal"), "{error}");
750 }
751
752 #[test]
753 fn several_radios_fail_loudly_without_a_terminal() {
754 let error = choose(
755 vec![
756 found("id-a", Some("T-Echo"), None),
757 found("id-b", Some("Solar"), None),
758 ],
759 false,
760 Discovery::Auto,
761 )
762 .unwrap_err();
763 assert!(error.to_string().contains("--ble"), "{error}");
764 }
765
766 #[test]
767 fn no_radios_is_not_an_error_here() {
768 assert_eq!(choose(Vec::new(), false, Discovery::Auto).unwrap(), None);
769 assert_eq!(choose(Vec::new(), false, Discovery::Ask).unwrap(), None);
770 }
771
772 #[test]
773 fn preferences_round_trip() {
774 let prefs = Prefs {
775 default_device: Some(DefaultDevice {
776 selector: "1234-ABCD".into(),
777 name: Some("UMSH T-Echo".into()),
778 }),
779 };
780 assert_eq!(Prefs::parse(&prefs.render()), prefs);
781 }
782
783 #[test]
784 fn preferences_ignore_comments_and_unknown_settings() {
785 let prefs = Prefs::parse(
786 "# comment\n\
787 default-selector = id-a # trailing\n\
788 mystery = 7\n",
789 );
790 assert_eq!(
791 prefs.default_device,
792 Some(DefaultDevice {
793 selector: "id-a".into(),
794 name: None
795 })
796 );
797 }
798
799 #[test]
800 fn the_saved_default_matches_by_id_then_by_name() {
801 let saved = DefaultDevice {
802 selector: "id-a".into(),
803 name: Some("UMSH T-Echo".into()),
804 };
805 let list = vec![
806 found("id-z", Some("UMSH T-Echo"), None),
807 found("id-a", Some("renamed"), None),
808 ];
809 assert_eq!(saved.find_in(&list).unwrap().id, "id-a");
811
812 assert_eq!(saved.find_in(&list[..1]).unwrap().id, "id-z");
814 assert_eq!(saved.find_in(&[]), None);
815 }
816}