umshctl/
connection.rs

1//! Finding a device, opening a link to it, and holding the attached
2//! session.
3//!
4//! Discovery is BLE-only on purpose. Identifying a ULCP device over
5//! serial means opening the port and speaking to whatever is behind it,
6//! and opening a port has side effects — DTR toggles reset some boards,
7//! and a 1200-baud touch is this repository's own DFU trigger. A bench
8//! is full of `usbmodem`/`usbserial` devices that are not ULCP radios,
9//! so a serial port is used only when the user names one. BLE scanning
10//! is passive and filtered to the ULCP GATT service, so it cannot land
11//! on a foreign device.
12
13use 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
26/// How long general discovery listens before deciding what it found.
27const DISCOVERY_WINDOW: Duration = Duration::from_secs(2);
28
29/// How much longer discovery listens when the first window came up
30/// empty (or missed the saved default). Power-conscious boards can
31/// straddle a two-second advertising window.
32const DISCOVERY_EXTENSION: Duration = Duration::from_secs(3);
33
34/// The RF parameters here only size the driver's airtime-derived
35/// timeouts; an administrative or tethered attach never writes PHY
36/// configuration.
37pub fn attach_config() -> UlcpDeviceConfig {
38    UlcpDeviceConfig::new(910_525, 62_500, 7, 5)
39}
40
41/// A device this tool knows how to reach, in the form it would use to
42/// reach it again.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum Target {
45    Serial {
46        port: String,
47        baud: u32,
48    },
49    /// `selector` is whatever identifies the radio to
50    /// `BleFrameLink::connect`: a platform peripheral id when discovery
51    /// chose it, or the user's own name fragment.
52    Ble {
53        selector: String,
54        name: Option<String>,
55    },
56}
57
58impl Target {
59    /// Short transport tag for the prompt and for announcements.
60    pub fn transport(&self) -> &'static str {
61        match self {
62            Self::Serial { .. } => "serial",
63            Self::Ble { .. } => "ble",
64        }
65    }
66
67    /// What to call this device before it has told us its own name.
68    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
78// ---------------------------------------------------------------------
79// Links
80// ---------------------------------------------------------------------
81
82/// Every transport this tool can open, as one type, so a REPL can move
83/// between them without the session being generic over the link.
84pub 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    /// Keeps the type inhabited in a build with no transport feature.
90    /// Never constructed.
91    #[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
120/// The pcap sink shared by the link wrapper (ULCP frames) and the
121/// capture loop (radio frames).
122///
123/// It is installed and removed at runtime rather than fixed at attach,
124/// so a `capture --pcap` inside the REPL can record the session's own
125/// control traffic without the session having been opened for capture.
126pub type FrameTap = Rc<RefCell<Option<PcapWriter>>>;
127
128pub fn new_tap() -> FrameTap {
129    Rc::new(RefCell::new(None))
130}
131
132/// A link that copies every ULCP frame into the tap, when one is
133/// installed.
134pub 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
174// ---------------------------------------------------------------------
175// Session
176// ---------------------------------------------------------------------
177
178/// An attached device plus everything needed to describe it, tap it, and
179/// re-attach it in the other mode.
180pub struct Session {
181    pub device: UlcpDevice<SessionLink>,
182    pub target: Target,
183    pub label: String,
184    pub tap: FrameTap,
185}
186
187impl Session {
188    /// True while this handle refuses host-domain writes.
189    pub fn is_administrative(&self) -> bool {
190        matches!(
191            self.device.attach_mode(),
192            umsh::ulcp::AttachMode::Administrative
193        )
194    }
195
196    /// Re-attach the open link in the other mode.
197    ///
198    /// The device sees no detach — this replaces the host's own
199    /// bookkeeping — so session-scoped state survives and the cost is
200    /// four property reads rather than a reconnect.
201    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        // The link keeps its tap: the wrapper is recovered whole, so a
209        // capture in progress keeps recording.
210        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    /// Open a fresh link to the same radio.
224    ///
225    /// Unlike [`Self::reattach`] this really does drop the connection —
226    /// it exists for recovering a capture whose BLE link failed. The
227    /// capture tap comes along so a recovered capture stays one file.
228    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
250/// Wire the frame-trace hook to stderr.
251pub 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
257/// Open the transport named by `target`.
258pub 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// Each transport comes in a working form and an explanatory one, chosen
266// by feature. Two whole functions beat a `cfg` block inside one: the
267// bodies stay ordinary code, and the build without the feature still
268// produces a tool that says what it is missing.
269
270#[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
306/// Attach to an already-wrapped link.
307///
308/// Administrative is the default relationship: this tool administers
309/// devices rather than tethering to them, so the handle refuses
310/// host-domain writes. Only `provision` — which exists to establish a
311/// host domain — asks for a tethered handle.
312async 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
321/// Open, attach, and name a device in one step.
322pub 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    // A device without CAP_DEV_NAME still needs something to answer to.
330    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// ---------------------------------------------------------------------
343// Discovery
344// ---------------------------------------------------------------------
345
346/// One radio seen during a scan, in the form the chooser and the saved
347/// default both work with.
348#[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/// Scan for ULCP radios advertising the GATT service.
356#[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/// Scan for ULCP radios advertising the GATT service.
374#[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
379/// Stable ordering: by name, then by id.
380///
381/// Deliberately **not** by signal strength. RSSI jitters between scans
382/// and would reorder the list under the user's fingers between one
383/// listing and the next.
384pub 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
397/// Fold a later scan's results into an earlier one, keeping order stable
398/// and preferring the fresher name and RSSI.
399pub 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
412/// Print a numbered listing of scan results.
413pub 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/// How a scan result set is resolved into one radio.
442#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
443pub enum Discovery {
444    /// Take the saved default when it answers, and ask only when the
445    /// answer is genuinely ambiguous.
446    #[default]
447    Auto,
448    /// Always show the listing and ask. The saved default is not
449    /// consulted at all — the point of asking is to reach the radio the
450    /// preference does not name.
451    Ask,
452}
453
454/// Find the device to talk to when the command line named none.
455///
456/// Returns `None` when nothing was found, which the caller turns into an
457/// unattached REPL or a one-shot error.
458pub 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        // A stale preference costs one line of output, not a dead tool.
472        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
485/// Turn a scan result set into a single target, asking the user when the
486/// answer is ambiguous — or, under [`Discovery::Ask`], whenever there is
487/// anything to ask about.
488pub fn choose(found: Vec<Found>, interactive: bool, how: Discovery) -> Result<Option<Target>> {
489    match found.len() {
490        0 => Ok(None),
491        // One radio is only an answer when nobody asked to be shown the
492        // question.
493        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
514/// Read a 1-based choice from the terminal. `None` means the user
515/// declined (empty line or EOF).
516fn 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
536/// Ask a yes/no question, defaulting to no.
537pub 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// ---------------------------------------------------------------------
552// Preferences
553// ---------------------------------------------------------------------
554
555/// The device to reach for when the command line names none.
556///
557/// Both fields are kept: the platform id is the primary key, and the
558/// name is a fallback for the day a Bluetooth cache reset churns the
559/// ids — as well as what the user recognizes in a message.
560#[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/// Persisted tool preferences. Deliberately tiny: `setting = value`
598/// lines with `#` comments, the same vocabulary the provisioning file
599/// uses, and no dependency to read it.
600#[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                // Unknown settings are ignored rather than fatal: a
619                // preferences file is not a command line.
620                _ => {}
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
660/// Where the preferences file and the REPL history live. One directory,
661/// following `XDG_STATE_HOME` when it is set.
662fn 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        // The strongest signal changing does not move anything.
706        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        // Without a terminal there is nobody to answer, which is the
742        // observable half of "it asked" in a test.
743        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        // The id wins even when the name matches another radio.
810        assert_eq!(saved.find_in(&list).unwrap().id, "id-a");
811
812        // With the id gone, the name still finds it.
813        assert_eq!(saved.find_in(&list[..1]).unwrap().id, "id-z");
814        assert_eq!(saved.find_in(&[]), None);
815    }
816}