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    let profile = umsh::ulcp_wire::profiles::DEFAULT;
39    UlcpDeviceConfig::new(
40        profile.freq_khz,
41        profile.bw_hz,
42        profile.sf,
43        profile.cr_denom,
44    )
45}
46
47/// How long a mesh session waits for one command to come back.
48///
49/// Strictly longer than the driver's own per-request budget, so a
50/// command that ran out of patience is reported by the driver — which
51/// knows whether the device was unreachable or this tool unlisted — and
52/// not by the handle, which would only know that nothing arrived.
53const MESH_RESPONSE_TIMEOUT: Duration = Duration::from_secs(200);
54
55/// The attach configuration for a device reached over the mesh.
56///
57/// `phy` describes the *local* radio, adopted from it before the link
58/// was borrowed; over a mesh session those numbers only size airtime
59/// estimates that nothing on this path consults.
60pub fn mesh_attach_config(phy: UlcpDeviceConfig) -> UlcpDeviceConfig {
61    UlcpDeviceConfig {
62        response_timeout: MESH_RESPONSE_TIMEOUT,
63        ..phy
64    }
65}
66
67/// A device this tool knows how to reach, in the form it would use to
68/// reach it again.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum Target {
71    Serial {
72        port: String,
73        baud: u32,
74    },
75    /// `selector` is whatever identifies the radio to
76    /// `BleFrameLink::connect`: a platform peripheral id when discovery
77    /// chose it, or the user's own name fragment.
78    Ble {
79        selector: String,
80        name: Option<String>,
81    },
82    /// A serial link carried over a socket: the bytes are the same
83    /// HDLC-Lite frames a UART would carry, so anything that bridges a
84    /// port to a listening socket serves a radio this way.
85    Tcp {
86        host: String,
87        port: u16,
88    },
89    /// A device reached over the mesh rather than a wire, through the
90    /// Node Management binding. Opening one borrows whatever radio is
91    /// attached; the session that results is a device handle like any
92    /// other, and the local radio is not reachable again until it ends.
93    Mesh {
94        key: [u8; 32],
95    },
96}
97
98impl Target {
99    /// Short transport tag for the prompt and for announcements.
100    pub fn transport(&self) -> &'static str {
101        match self {
102            Self::Serial { .. } => "serial",
103            Self::Ble { .. } => "ble",
104            Self::Tcp { .. } => "tcp",
105            Self::Mesh { .. } => "mesh",
106        }
107    }
108
109    /// What to call this device before it has told us its own name.
110    pub fn provisional_label(&self) -> String {
111        match self {
112            Self::Serial { port, .. } => {
113                port.rsplit('/').next().unwrap_or(port.as_str()).to_string()
114            }
115            Self::Ble { selector, name } => name.clone().unwrap_or_else(|| selector.clone()),
116            Self::Tcp { host, port } => format_endpoint(host, *port),
117            Self::Mesh { key } => umsh::core::PublicKey(*key).to_string(),
118        }
119    }
120}
121
122/// Render a host and port the way the user would type them, bracketing
123/// a bare IPv6 literal so the result parses back.
124pub fn format_endpoint(host: &str, port: u16) -> String {
125    if host.contains(':') {
126        format!("[{host}]:{port}")
127    } else {
128        format!("{host}:{port}")
129    }
130}
131
132/// Parse a `host:port` endpoint, accepting a bracketed IPv6 literal.
133///
134/// The port is required: a bare host would have to guess a port number,
135/// and there is no registered one to guess.
136pub fn parse_endpoint(spec: &str) -> Result<(String, u16)> {
137    let spec = spec.trim();
138    let (host, port) = if let Some(rest) = spec.strip_prefix('[') {
139        let (host, rest) = rest
140            .split_once(']')
141            .ok_or_else(|| anyhow!("unterminated IPv6 literal in {spec:?}"))?;
142        let port = rest
143            .strip_prefix(':')
144            .ok_or_else(|| anyhow!("{spec:?} names no port (expected [host]:port)"))?;
145        (host, port)
146    } else {
147        spec.rsplit_once(':')
148            .ok_or_else(|| anyhow!("{spec:?} names no port (expected host:port)"))?
149    };
150    if host.is_empty() {
151        bail!("{spec:?} names no host");
152    }
153    let port: u16 = port
154        .parse()
155        .with_context(|| format!("{port:?} is not a port number"))?;
156    if port == 0 {
157        bail!("port 0 is not a destination");
158    }
159    Ok((host.to_string(), port))
160}
161
162// ---------------------------------------------------------------------
163// Links
164// ---------------------------------------------------------------------
165
166/// Every transport this tool can open, as one type, so a REPL can move
167/// between them without the session being generic over the link.
168pub enum AnyLink {
169    #[cfg(feature = "serial-radio")]
170    Serial(umsh::ulcp::SerialFrameLink<tokio_serial::SerialStream>),
171    #[cfg(feature = "ble-radio")]
172    Ble(umsh::ulcp::BleFrameLink),
173    /// Ungated: a socket needs no driver crate, so every build can reach
174    /// a bridged radio.
175    Tcp(umsh::ulcp::SerialFrameLink<tokio::net::TcpStream>),
176    /// Ungated too: the mesh is reached through whatever radio is
177    /// already attached, so it needs no transport of its own.
178    Mesh(umsh::ulcp_mesh::MeshFrameLink),
179    /// Keeps the type inhabited in a build with no transport feature.
180    /// Never constructed.
181    #[allow(dead_code)]
182    Unavailable,
183}
184
185impl FrameLink for AnyLink {
186    async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
187        match self {
188            #[cfg(feature = "serial-radio")]
189            Self::Serial(link) => link.send_frame(frame).await,
190            #[cfg(feature = "ble-radio")]
191            Self::Ble(link) => link.send_frame(frame).await,
192            Self::Tcp(link) => link.send_frame(frame).await,
193            Self::Mesh(link) => link.send_frame(frame).await,
194            Self::Unavailable => Err(UlcpError::Disconnected),
195        }
196    }
197
198    fn poll_recv_frame(
199        &mut self,
200        cx: &mut core::task::Context<'_>,
201    ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
202        match self {
203            #[cfg(feature = "serial-radio")]
204            Self::Serial(link) => link.poll_recv_frame(cx),
205            #[cfg(feature = "ble-radio")]
206            Self::Ble(link) => link.poll_recv_frame(cx),
207            Self::Tcp(link) => link.poll_recv_frame(cx),
208            Self::Mesh(link) => link.poll_recv_frame(cx),
209            Self::Unavailable => core::task::Poll::Ready(Err(UlcpError::Disconnected)),
210        }
211    }
212}
213
214/// The pcap sink shared by the link wrapper (ULCP frames) and the
215/// capture loop (radio frames).
216///
217/// It is installed and removed at runtime rather than fixed at attach,
218/// so a `capture --pcap` inside the REPL can record the session's own
219/// control traffic without the session having been opened for capture.
220pub type FrameTap = Rc<RefCell<Option<PcapWriter>>>;
221
222pub fn new_tap() -> FrameTap {
223    Rc::new(RefCell::new(None))
224}
225
226/// A link that copies every ULCP frame into the tap, when one is
227/// installed.
228pub struct SessionLink {
229    inner: AnyLink,
230    tap: FrameTap,
231}
232
233impl SessionLink {
234    pub fn new(inner: AnyLink, tap: FrameTap) -> Self {
235        Self { inner, tap }
236    }
237
238    fn record(&self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
239        if let Some(writer) = self.tap.borrow_mut().as_mut() {
240            writer.write_ulcp(direction, frame)?;
241        }
242        Ok(())
243    }
244}
245
246impl FrameLink for SessionLink {
247    async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
248        self.record(PcapDirection::HostToDevice, frame)?;
249        self.inner.send_frame(frame).await
250    }
251
252    fn poll_recv_frame(
253        &mut self,
254        cx: &mut core::task::Context<'_>,
255    ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
256        match self.inner.poll_recv_frame(cx) {
257            core::task::Poll::Ready(Ok(frame)) => {
258                match self.record(PcapDirection::DeviceToHost, &frame) {
259                    Ok(()) => core::task::Poll::Ready(Ok(frame)),
260                    Err(error) => core::task::Poll::Ready(Err(error.into())),
261                }
262            }
263            other => other,
264        }
265    }
266}
267
268// ---------------------------------------------------------------------
269// Session
270// ---------------------------------------------------------------------
271
272/// An attached device plus everything needed to describe it, tap it, and
273/// re-open it.
274pub struct Session {
275    pub device: UlcpDevice<SessionLink>,
276    pub target: Target,
277    pub label: String,
278    pub tap: FrameTap,
279}
280
281impl Session {
282    /// True while this session reaches its device over the mesh.
283    pub fn is_mesh(&self) -> bool {
284        matches!(self.target, Target::Mesh { .. })
285    }
286
287    /// Open a fresh link to the same radio.
288    ///
289    /// This really does drop the connection — it exists for recovering a
290    /// capture whose BLE link failed. The capture tap comes along so a
291    /// recovered capture stays one file.
292    pub async fn reconnect(self, trace: bool) -> Result<Self> {
293        let Self {
294            device,
295            target,
296            label,
297            tap,
298        } = self;
299        drop(device);
300        let link = open(&target).await?;
301        let mut device = attach_tapped(link, tap.clone()).await?;
302        if trace {
303            install_trace(&mut device);
304        }
305        Ok(Self {
306            device,
307            target,
308            label,
309            tap,
310        })
311    }
312}
313
314/// Wire the frame-trace hook to stderr.
315pub fn install_trace(device: &mut UlcpDevice<SessionLink>) {
316    device.set_frame_trace(Some(Box::new(|direction, line| {
317        eprintln!("trace {direction} {line}");
318    })));
319}
320
321/// Open the transport named by `target`.
322pub async fn open(target: &Target) -> Result<AnyLink> {
323    match target {
324        Target::Serial { port, baud } => open_serial(port, *baud).await,
325        Target::Ble { selector, .. } => open_ble(selector).await,
326        Target::Tcp { host, port } => open_tcp(host, *port).await,
327        // A mesh session is not opened by naming a transport: it is
328        // built on the radio already attached, by `mesh::open_remote`.
329        Target::Mesh { .. } => bail!("a mesh session cannot be reopened on its own"),
330    }
331}
332
333/// Open a bridged serial link.
334///
335/// Nagle is off: ULCP frames are small and each one is a turn in a
336/// request/response exchange, so coalescing only adds latency.
337async fn open_tcp(host: &str, port: u16) -> Result<AnyLink> {
338    let endpoint = format_endpoint(host, port);
339    let stream = tokio::net::TcpStream::connect((host, port))
340        .await
341        .with_context(|| format!("connecting to {endpoint}"))?;
342    stream
343        .set_nodelay(true)
344        .with_context(|| format!("disabling Nagle on {endpoint}"))?;
345    Ok(AnyLink::Tcp(umsh::ulcp::SerialFrameLink::new(stream)))
346}
347
348// Each transport comes in a working form and an explanatory one, chosen
349// by feature. Two whole functions beat a `cfg` block inside one: the
350// bodies stay ordinary code, and the build without the feature still
351// produces a tool that says what it is missing.
352
353#[cfg(feature = "serial-radio")]
354async fn open_serial(port: &str, baud: u32) -> Result<AnyLink> {
355    use tokio_serial::SerialPortBuilderExt as _;
356    let stream = tokio_serial::new(port, baud)
357        .open_native_async()
358        .with_context(|| format!("opening {port}"))?;
359    Ok(AnyLink::Serial(umsh::ulcp::SerialFrameLink::new(stream)))
360}
361
362#[cfg(not(feature = "serial-radio"))]
363async fn open_serial(_port: &str, _baud: u32) -> Result<AnyLink> {
364    bail!("this build has no serial support (build with the serial-radio feature)")
365}
366
367#[cfg(feature = "ble-radio")]
368async fn open_ble(selector: &str) -> Result<AnyLink> {
369    use umsh::ulcp::{BleFrameLink, BleFrameLinkConfig};
370    let link = BleFrameLink::connect(Some(selector), BleFrameLinkConfig::default())
371        .await
372        .with_context(|| format!("connecting to BLE radio {selector:?}"))?;
373    Ok(AnyLink::Ble(link))
374}
375
376#[cfg(not(feature = "ble-radio"))]
377async fn open_ble(_selector: &str) -> Result<AnyLink> {
378    bail!("this build has no BLE support (build with the ble-radio feature)")
379}
380
381/// Attach to a link, wrapping it in the capture tap.
382///
383/// Administrative is the only relationship this tool has with a device:
384/// it administers radios rather than tethering to them, so the handle
385/// refuses host-domain writes.
386async fn attach_tapped(link: AnyLink, tap: FrameTap) -> Result<UlcpDevice<SessionLink>> {
387    let device =
388        UlcpDevice::attach_administrative(SessionLink::new(link, tap), attach_config()).await?;
389    Ok(device)
390}
391
392/// Open, attach, and name a device in one step.
393pub async fn connect(target: Target, trace: bool) -> Result<Session> {
394    let tap = new_tap();
395    let link = open(&target).await?;
396    let mut device = attach_tapped(link, tap.clone()).await?;
397    if trace {
398        install_trace(&mut device);
399    }
400    // A device without CAP_DEV_NAME still needs something to answer to.
401    let label = match device.device_name().await {
402        Ok(name) if !name.is_empty() => name,
403        _ => target.provisional_label(),
404    };
405    Ok(Session {
406        device,
407        target,
408        label,
409        tap,
410    })
411}
412
413// ---------------------------------------------------------------------
414// Discovery
415// ---------------------------------------------------------------------
416
417/// One radio seen during a scan, in the form the chooser and the saved
418/// default both work with.
419#[derive(Clone, Debug, PartialEq, Eq)]
420pub struct Found {
421    pub id: String,
422    pub name: Option<String>,
423    pub rssi: Option<i16>,
424}
425
426/// Scan for ULCP radios advertising the GATT service.
427#[cfg(feature = "ble-radio")]
428pub async fn scan(timeout: Duration) -> Result<Vec<Found>> {
429    let results = umsh::ulcp::BleFrameLink::scan(timeout)
430        .await
431        .context("scanning for BLE radios")?;
432    let mut found: Vec<Found> = results
433        .into_iter()
434        .map(|result| Found {
435            id: result.id,
436            name: result.name,
437            rssi: result.rssi,
438        })
439        .collect();
440    sort_found(&mut found);
441    Ok(found)
442}
443
444/// Scan for ULCP radios advertising the GATT service.
445#[cfg(not(feature = "ble-radio"))]
446pub async fn scan(_timeout: Duration) -> Result<Vec<Found>> {
447    bail!("this build has no BLE support (build with the ble-radio feature)")
448}
449
450/// Stable ordering: by name, then by id.
451///
452/// Deliberately **not** by signal strength. RSSI jitters between scans
453/// and would reorder the list under the user's fingers between one
454/// listing and the next.
455pub fn sort_found(found: &mut [Found]) {
456    found.sort_by(|left, right| {
457        let key = |entry: &Found| {
458            (
459                entry.name.is_none(),
460                entry.name.clone().unwrap_or_default(),
461                entry.id.clone(),
462            )
463        };
464        key(left).cmp(&key(right))
465    });
466}
467
468/// Fold a later scan's results into an earlier one, keeping order stable
469/// and preferring the fresher name and RSSI.
470pub fn merge_found(into: &mut Vec<Found>, more: Vec<Found>) {
471    for entry in more {
472        match into.iter_mut().find(|existing| existing.id == entry.id) {
473            Some(existing) => {
474                existing.name = entry.name.or_else(|| existing.name.take());
475                existing.rssi = entry.rssi.or(existing.rssi);
476            }
477            None => into.push(entry),
478        }
479    }
480    sort_found(into);
481}
482
483/// Print a numbered listing of scan results.
484pub fn render_found(found: &[Found]) {
485    if found.is_empty() {
486        println!("no ULCP radios found");
487        return;
488    }
489    let width = found.len().to_string().len();
490    for (index, entry) in found.iter().enumerate() {
491        let name = entry.name.as_deref().unwrap_or("(no name)");
492        match entry.rssi {
493            Some(rssi) => println!(
494                "{:>width$}) {name}  {}  rssi {rssi} dBm",
495                index + 1,
496                entry.id
497            ),
498            None => println!("{:>width$}) {name}  {}", index + 1, entry.id),
499        }
500    }
501}
502
503impl From<&Found> for Target {
504    fn from(found: &Found) -> Self {
505        Target::Ble {
506            selector: found.id.clone(),
507            name: found.name.clone(),
508        }
509    }
510}
511
512/// How a scan result set is resolved into one radio.
513#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
514pub enum Discovery {
515    /// Take the saved default when it answers, and ask only when the
516    /// answer is genuinely ambiguous.
517    #[default]
518    Auto,
519    /// Always show the listing and ask. The saved default is not
520    /// consulted at all — the point of asking is to reach the radio the
521    /// preference does not name.
522    Ask,
523}
524
525/// Find the device to talk to when the command line named none.
526///
527/// Returns `None` when nothing was found, which the caller turns into an
528/// unattached REPL or a one-shot error.
529pub async fn discover(prefs: &Prefs, interactive: bool, how: Discovery) -> Result<Option<Target>> {
530    let mut seen = scan(DISCOVERY_WINDOW).await?;
531    let mut extended = false;
532
533    if let (Discovery::Auto, Some(saved)) = (how, &prefs.default_device) {
534        if let Some(found) = saved.find_in(&seen) {
535            return Ok(Some(Target::from(found)));
536        }
537        merge_found(&mut seen, scan(DISCOVERY_EXTENSION).await?);
538        extended = true;
539        if let Some(found) = saved.find_in(&seen) {
540            return Ok(Some(Target::from(found)));
541        }
542        // A stale preference costs one line of output, not a dead tool.
543        output::warn(format!(
544            "default radio {} not found; discovering instead",
545            saved.display()
546        ));
547    }
548
549    if seen.is_empty() && !extended {
550        merge_found(&mut seen, scan(DISCOVERY_EXTENSION).await?);
551    }
552
553    choose(seen, interactive, how)
554}
555
556/// Turn a scan result set into a single target, asking the user when the
557/// answer is ambiguous — or, under [`Discovery::Ask`], whenever there is
558/// anything to ask about.
559pub fn choose(found: Vec<Found>, interactive: bool, how: Discovery) -> Result<Option<Target>> {
560    match found.len() {
561        0 => Ok(None),
562        // One radio is only an answer when nobody asked to be shown the
563        // question.
564        1 if how == Discovery::Auto => Ok(Some(Target::from(&found[0]))),
565        _ => {
566            render_found(&found);
567            if !interactive {
568                match how {
569                    Discovery::Ask => bail!(
570                        "choosing a radio needs a terminal; name one with --ble=SELECTOR instead"
571                    ),
572                    Discovery::Auto => bail!(
573                        "{} ULCP radios are in range; name one with --ble=SELECTOR (or set a \
574                         default with `default set`)",
575                        found.len()
576                    ),
577                }
578            }
579            let index = prompt_index(found.len())?;
580            Ok(index.map(|index| Target::from(&found[index])))
581        }
582    }
583}
584
585/// Read a 1-based choice from the terminal. `None` means the user
586/// declined (empty line or EOF).
587fn prompt_index(count: usize) -> Result<Option<usize>> {
588    loop {
589        print!("select radio [1-{count}, or blank to cancel]: ");
590        std::io::stdout().flush().ok();
591        let mut line = String::new();
592        if std::io::stdin().read_line(&mut line)? == 0 {
593            println!();
594            return Ok(None);
595        }
596        let line = line.trim();
597        if line.is_empty() {
598            return Ok(None);
599        }
600        match line.parse::<usize>() {
601            Ok(choice) if (1..=count).contains(&choice) => return Ok(Some(choice - 1)),
602            _ => eprintln!("expected a number from 1 to {count}"),
603        }
604    }
605}
606
607/// Ask a yes/no question, defaulting to no.
608pub fn confirm(question: &str) -> Result<bool> {
609    if !std::io::stdin().is_terminal() {
610        return Ok(false);
611    }
612    print!("{question} [y/N]: ");
613    std::io::stdout().flush().ok();
614    let mut line = String::new();
615    if std::io::stdin().read_line(&mut line)? == 0 {
616        println!();
617        return Ok(false);
618    }
619    Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes"))
620}
621
622// ---------------------------------------------------------------------
623// Preferences
624// ---------------------------------------------------------------------
625
626/// The device to reach for when the command line names none.
627///
628/// Both fields are kept: the platform id is the primary key, and the
629/// name is a fallback for the day a Bluetooth cache reset churns the
630/// ids — as well as what the user recognizes in a message.
631#[derive(Clone, Debug, Default, PartialEq, Eq)]
632pub struct DefaultDevice {
633    pub selector: String,
634    pub name: Option<String>,
635}
636
637impl DefaultDevice {
638    pub fn display(&self) -> String {
639        match &self.name {
640            Some(name) => format!("{name:?} ({})", self.selector),
641            None => self.selector.clone(),
642        }
643    }
644
645    fn find_in<'a>(&self, found: &'a [Found]) -> Option<&'a Found> {
646        found
647            .iter()
648            .find(|entry| entry.id == self.selector)
649            .or_else(|| {
650                found.iter().find(|entry| {
651                    entry
652                        .name
653                        .as_deref()
654                        .is_some_and(|name| Some(name) == self.name.as_deref())
655                })
656            })
657            .or_else(|| {
658                found.iter().find(|entry| {
659                    entry
660                        .name
661                        .as_deref()
662                        .is_some_and(|name| name.contains(&self.selector))
663                })
664            })
665    }
666}
667
668/// Persisted tool preferences. Deliberately tiny: `setting = value`
669/// lines with `#` comments, and no dependency to read it.
670#[derive(Clone, Debug, Default, PartialEq, Eq)]
671pub struct Prefs {
672    pub default_device: Option<DefaultDevice>,
673}
674
675impl Prefs {
676    pub fn parse(text: &str) -> Self {
677        let mut prefs = Self::default();
678        let mut selector = None;
679        let mut name = None;
680        for raw in text.lines() {
681            let line = raw.split('#').next().unwrap_or("").trim();
682            let Some((setting, value)) = line.split_once('=') else {
683                continue;
684            };
685            match setting.trim() {
686                "default-selector" => selector = Some(value.trim().to_string()),
687                "default-name" => name = Some(value.trim().to_string()),
688                // Unknown settings are ignored rather than fatal: a
689                // preferences file is not a command line.
690                _ => {}
691            }
692        }
693        if let Some(selector) = selector {
694            prefs.default_device = Some(DefaultDevice { selector, name });
695        }
696        prefs
697    }
698
699    pub fn render(&self) -> String {
700        let mut text = String::from("# umshctl preferences\n");
701        if let Some(device) = &self.default_device {
702            text.push_str(&format!("default-selector = {}\n", device.selector));
703            if let Some(name) = &device.name {
704                text.push_str(&format!("default-name = {name}\n"));
705            }
706        }
707        text
708    }
709
710    pub fn load() -> Self {
711        config_path()
712            .and_then(|path| std::fs::read_to_string(path).ok())
713            .map(|text| Self::parse(&text))
714            .unwrap_or_default()
715    }
716
717    pub fn store(&self) -> Result<PathBuf> {
718        let path =
719            config_path().ok_or_else(|| anyhow!("no HOME directory to store settings in"))?;
720        if let Some(parent) = path.parent() {
721            std::fs::create_dir_all(parent)
722                .with_context(|| format!("creating {}", parent.display()))?;
723        }
724        std::fs::write(&path, self.render())
725            .with_context(|| format!("writing {}", path.display()))?;
726        Ok(path)
727    }
728}
729
730/// Where the preferences file and the REPL history live. One directory,
731/// following `XDG_STATE_HOME` when it is set.
732fn state_dir() -> Option<PathBuf> {
733    if let Some(state) = std::env::var_os("XDG_STATE_HOME").filter(|value| !value.is_empty()) {
734        return Some(PathBuf::from(state).join("umsh"));
735    }
736    let home = std::env::var_os("HOME").filter(|value| !value.is_empty())?;
737    Some(PathBuf::from(home).join(".local/state/umsh"))
738}
739
740pub fn config_path() -> Option<PathBuf> {
741    state_dir().map(|dir| dir.join("umshctl.conf"))
742}
743
744pub fn history_path() -> Option<PathBuf> {
745    state_dir().map(|dir| dir.join("umshctl-history"))
746}
747
748/// The Ed25519 seed this tool administers devices with, and beside it the
749/// frame counters that identity has spent.
750///
751/// The identity is persistent because a device authorizes an
752/// administrator by public key: a tool that generated a fresh one each
753/// run would have to be re-authorized every time. The counters must
754/// persist for the same reason any node's must — a peer that has seen a
755/// higher counter rejects a lower one as a replay.
756pub fn admin_identity_path() -> Option<PathBuf> {
757    state_dir().map(|dir| dir.join("umshctl-admin.key"))
758}
759
760pub fn admin_counter_path() -> Option<PathBuf> {
761    state_dir().map(|dir| dir.join("umshctl-admin.counters"))
762}
763
764/// Routes learned to the nodes this tool has reached.
765pub fn routes_path() -> Option<PathBuf> {
766    state_dir().map(|dir| dir.join("umshctl-routes"))
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    fn found(id: &str, name: Option<&str>, rssi: Option<i16>) -> Found {
774        Found {
775            id: id.to_string(),
776            name: name.map(str::to_string),
777            rssi,
778        }
779    }
780
781    #[test]
782    fn a_mesh_target_names_itself_by_its_key() {
783        let target = Target::Mesh { key: [0xC4; 32] };
784        assert_eq!(target.transport(), "mesh");
785        // The label is the key as the rest of the tool writes one, so it
786        // can be pasted straight back into `remote`.
787        assert_eq!(
788            target.provisional_label(),
789            umsh::core::PublicKey([0xC4; 32]).to_string()
790        );
791    }
792
793    #[test]
794    fn endpoints_round_trip_through_their_written_form() {
795        for (spec, host, port) in [
796            ("127.0.0.1:9000", "127.0.0.1", 9000u16),
797            ("localhost:9000", "localhost", 9000),
798            ("[::1]:9000", "::1", 9000),
799            ("[fe80::1%en0]:65535", "fe80::1%en0", 65535),
800        ] {
801            let parsed = parse_endpoint(spec).unwrap();
802            assert_eq!(parsed, (host.to_string(), port), "parsing {spec}");
803            assert_eq!(format_endpoint(host, port), spec, "rendering {spec}");
804        }
805    }
806
807    #[test]
808    fn an_endpoint_without_a_usable_port_is_refused() {
809        // There is no registered port to guess, so a bare host cannot
810        // be completed into a destination.
811        for spec in [
812            "127.0.0.1",
813            "localhost",
814            "[::1]",
815            "[::1:9000",
816            "127.0.0.1:",
817            "127.0.0.1:0",
818            "127.0.0.1:70000",
819            ":9000",
820        ] {
821            assert!(parse_endpoint(spec).is_err(), "{spec} should not parse");
822        }
823    }
824
825    #[test]
826    fn a_tcp_target_is_labeled_by_its_endpoint() {
827        let target = Target::Tcp {
828            host: "::1".into(),
829            port: 9000,
830        };
831        assert_eq!(target.transport(), "tcp");
832        assert_eq!(target.provisional_label(), "[::1]:9000");
833    }
834
835    #[test]
836    fn scan_results_sort_by_name_never_by_signal() {
837        let mut list = vec![
838            found("id-c", Some("T-Echo"), Some(-30)),
839            found("id-a", Some("Solar"), Some(-90)),
840            found("id-b", None, Some(-40)),
841        ];
842        sort_found(&mut list);
843        assert_eq!(
844            list.iter()
845                .map(|entry| entry.id.as_str())
846                .collect::<Vec<_>>(),
847            ["id-a", "id-c", "id-b"],
848        );
849
850        // The strongest signal changing does not move anything.
851        list[0].rssi = Some(-10);
852        let before = list.clone();
853        sort_found(&mut list);
854        assert_eq!(list, before);
855    }
856
857    #[test]
858    fn merging_a_second_scan_keeps_one_entry_per_radio() {
859        let mut list = vec![found("id-a", None, None)];
860        merge_found(&mut list, vec![found("id-a", Some("T-Echo"), Some(-55))]);
861        assert_eq!(list.len(), 1);
862        assert_eq!(list[0].name.as_deref(), Some("T-Echo"));
863        assert_eq!(list[0].rssi, Some(-55));
864    }
865
866    #[test]
867    fn a_single_radio_needs_no_chooser() {
868        let target = choose(
869            vec![found("id-a", Some("T-Echo"), None)],
870            false,
871            Discovery::Auto,
872        )
873        .unwrap()
874        .unwrap();
875        assert_eq!(
876            target,
877            Target::Ble {
878                selector: "id-a".into(),
879                name: Some("T-Echo".into())
880            }
881        );
882    }
883
884    #[test]
885    fn asking_explicitly_asks_even_about_a_single_radio() {
886        // Without a terminal there is nobody to answer, which is the
887        // observable half of "it asked" in a test.
888        let error = choose(
889            vec![found("id-a", Some("T-Echo"), None)],
890            false,
891            Discovery::Ask,
892        )
893        .unwrap_err();
894        assert!(error.to_string().contains("terminal"), "{error}");
895    }
896
897    #[test]
898    fn several_radios_fail_loudly_without_a_terminal() {
899        let error = choose(
900            vec![
901                found("id-a", Some("T-Echo"), None),
902                found("id-b", Some("Solar"), None),
903            ],
904            false,
905            Discovery::Auto,
906        )
907        .unwrap_err();
908        assert!(error.to_string().contains("--ble"), "{error}");
909    }
910
911    #[test]
912    fn no_radios_is_not_an_error_here() {
913        assert_eq!(choose(Vec::new(), false, Discovery::Auto).unwrap(), None);
914        assert_eq!(choose(Vec::new(), false, Discovery::Ask).unwrap(), None);
915    }
916
917    #[test]
918    fn preferences_round_trip() {
919        let prefs = Prefs {
920            default_device: Some(DefaultDevice {
921                selector: "1234-ABCD".into(),
922                name: Some("UMSH T-Echo".into()),
923            }),
924        };
925        assert_eq!(Prefs::parse(&prefs.render()), prefs);
926    }
927
928    #[test]
929    fn preferences_ignore_comments_and_unknown_settings() {
930        let prefs = Prefs::parse(
931            "# comment\n\
932             default-selector = id-a  # trailing\n\
933             mystery = 7\n",
934        );
935        assert_eq!(
936            prefs.default_device,
937            Some(DefaultDevice {
938                selector: "id-a".into(),
939                name: None
940            })
941        );
942    }
943
944    #[test]
945    fn the_saved_default_matches_by_id_then_by_name() {
946        let saved = DefaultDevice {
947            selector: "id-a".into(),
948            name: Some("UMSH T-Echo".into()),
949        };
950        let list = vec![
951            found("id-z", Some("UMSH T-Echo"), None),
952            found("id-a", Some("renamed"), None),
953        ];
954        // The id wins even when the name matches another radio.
955        assert_eq!(saved.find_in(&list).unwrap().id, "id-a");
956
957        // With the id gone, the name still finds it.
958        assert_eq!(saved.find_in(&list[..1]).unwrap().id, "id-z");
959        assert_eq!(saved.find_in(&[]), None);
960    }
961}