umshctl/
main.rs

1//! `umshctl`: the host tool for ULCP radio devices — inspection,
2//! provisioning, device identity, persistence, pairing, radio
3//! configuration, and packet capture.
4//!
5//! Everything except `provision` attaches administratively, with the
6//! non-resetting full-protocol handshake, so pointing this tool at an
7//! autonomously operating board never disturbs its configuration: only
8//! the command explicitly given changes anything.
9//!
10//! With no command it opens a shell — one attach, many commands — which
11//! is worth a great deal over BLE, where each fresh attach costs a
12//! discovery pass plus a handshake.
13
14#![cfg_attr(
15    not(any(feature = "serial-radio", feature = "ble-radio")),
16    allow(unused_variables, dead_code)
17)]
18
19mod command;
20mod connection;
21mod extcap;
22mod output;
23mod repl;
24
25use std::io::IsTerminal;
26
27use anyhow::{Result, bail};
28use clap::Parser;
29
30use umsh::ulcp::UlcpDevice;
31
32use command::Command;
33use connection::{Discovery, Found, Prefs, Session, SessionLink, Target};
34use output::ColorChoice;
35
36#[derive(Debug, Parser)]
37#[command(
38    name = "umshctl",
39    version,
40    about = "Inspect, configure, and capture from ULCP radio devices",
41    long_about = "\
42Manages a ULCP radio device without disturbing it: attaches with the
43non-resetting full-protocol handshake, so an autonomously operating
44board keeps its configuration unless the command changes it.
45
46With no command, opens an interactive shell against one attachment.
47With no connection, discovers a radio over BLE: one match is used, and
48several offer a numbered choice, which --pick asks for outright. A
49serial port is used only when named,
50because identifying one means opening it, and opening a port can reset
51or DFU-trigger hardware that is not a ULCP radio at all.
52
53KEY values are 44-character base58 or 64-character hex. Secrets are
54never echoed in output or traces — though shell history keeps whatever
55was typed, the same as any shell.
56
57CODE values are a 3-letter IATA airport code (SJC), a raw 2-byte code
58(0x7853), or any other text, which is hashed as a region name
59(\"Rogue Valley\"). Region codes are routing-domain tags, not RF band
60plans, so every repeater in an area must agree on the same spelling."
61)]
62pub struct ToolArgs {
63    /// Serial port to attach to.
64    #[arg(
65        short = 'p',
66        long,
67        value_name = "PORT",
68        env = "UMSHCTL_PORT",
69        global = true
70    )]
71    port: Option<String>,
72
73    /// Attach over BLE, optionally naming the radio by name or scan id.
74    ///
75    /// The selector needs the `=` form (`--ble=T-Echo`); bare `--ble`
76    /// discovers.
77    #[arg(
78        short = 'b',
79        long,
80        value_name = "SELECTOR",
81        num_args(0..=1),
82        require_equals = true,
83        global = true
84    )]
85    ble: Option<Option<String>>,
86
87    /// Choose the radio from a numbered listing, ignoring the saved
88    /// default.
89    ///
90    /// The listing appears on its own whenever the answer is ambiguous;
91    /// this asks for it even when it is not.
92    #[arg(long, global = true)]
93    pick: bool,
94
95    /// Serial bit rate.
96    #[arg(long, default_value_t = 115_200, value_name = "N", global = true)]
97    baud: u32,
98
99    /// Print every ULCP frame on stderr.
100    #[arg(long, global = true)]
101    trace: bool,
102
103    /// Leave mutations live-only. They otherwise persist automatically
104    /// via CMD_SAVE.
105    #[arg(long, global = true)]
106    no_save: bool,
107
108    /// When to colorize output.
109    #[arg(long, value_enum, default_value_t = ColorChoice::Auto, value_name = "WHEN", global = true)]
110    color: ColorChoice,
111
112    #[command(subcommand)]
113    command: Option<Command>,
114}
115
116impl ToolArgs {
117    fn discovery(&self) -> Discovery {
118        if self.pick {
119            Discovery::Ask
120        } else {
121            Discovery::Auto
122        }
123    }
124}
125
126/// Everything a command may need beyond the device itself: the
127/// attachment, the settings, and the difference between a shell and a
128/// one-shot invocation.
129pub struct App {
130    pub session: Option<Session>,
131    pub prefs: Prefs,
132    /// True in the shell, where a question can be asked and a failure
133    /// returns to a prompt.
134    pub interactive: bool,
135    pub trace: bool,
136    pub no_save: bool,
137    pub baud: u32,
138    /// How a bare `connect` resolves a scan — `--pick` at launch keeps
139    /// asking for the rest of the shell session.
140    pub discovery: Discovery,
141    /// The last `scan` listing, so `connect <N>` can refer to it.
142    pub last_scan: Vec<Found>,
143}
144
145impl App {
146    pub fn session(&mut self) -> Result<&mut Session> {
147        match &mut self.session {
148            Some(session) => Ok(session),
149            None => bail!("not attached — try `scan` or `connect`"),
150        }
151    }
152
153    pub fn device(&mut self) -> Result<&mut UlcpDevice<SessionLink>> {
154        Ok(&mut self.session()?.device)
155    }
156
157    pub fn target_is_ble(&self) -> bool {
158        matches!(
159            self.session.as_ref().map(|session| &session.target),
160            Some(Target::Ble { .. })
161        )
162    }
163
164    /// Drop the attachment, returning what it was called. Dropping the
165    /// link is what reverts session-scoped device state.
166    pub fn detach(&mut self) -> Option<String> {
167        self.session.take().map(|session| session.label)
168    }
169
170    pub fn rename(&mut self, label: String) {
171        if let Some(session) = &mut self.session {
172            session.label = label;
173        }
174    }
175
176    /// An attached, non-interactive app for the Wireshark extcap
177    /// interface.
178    ///
179    /// `interactive` is false in both its senses here: there is no
180    /// prompt to return to, and nobody to answer a question. That is
181    /// also what lets a dropped BLE link be recovered underneath a
182    /// running capture.
183    pub fn for_extcap(session: Session, prefs: Prefs, baud: u32) -> Self {
184        Self {
185            session: Some(session),
186            prefs,
187            interactive: false,
188            trace: false,
189            no_save: true,
190            baud,
191            discovery: Discovery::Auto,
192            last_scan: Vec::new(),
193        }
194    }
195
196    pub async fn attach(&mut self, target: Target) -> Result<()> {
197        let session = connection::connect(target, false, self.trace).await?;
198        announce_attached(&session);
199        self.session = Some(session);
200        Ok(())
201    }
202
203    /// Re-attach the open link in the other mode. Used by `provision`,
204    /// which needs a tethered handle for one command.
205    pub async fn reattach(&mut self, tethered: bool) -> Result<()> {
206        let Some(session) = self.session.take() else {
207            bail!("not attached");
208        };
209        self.session = Some(session.reattach(tethered, self.trace).await?);
210        Ok(())
211    }
212
213    /// Open a fresh link to the same radio, keeping the capture tap so a
214    /// recovered capture stays one file.
215    pub async fn reconnect(&mut self) -> Result<()> {
216        let Some(session) = self.session.take() else {
217            bail!("not attached");
218        };
219        self.session = Some(session.reconnect(self.trace).await?);
220        Ok(())
221    }
222
223    pub fn prompt(&self) -> String {
224        match &self.session {
225            Some(session) => format!("{} ({})> ", session.label, session.target.transport()),
226            None => "(unattached)> ".to_string(),
227        }
228    }
229}
230
231fn announce_attached(session: &Session) {
232    eprintln!(
233        "attached: {} ({}) device={} boot_status={:?} mode={}",
234        session.label,
235        session.target.transport(),
236        session.device.dev_version(),
237        session.device.boot_status(),
238        if session.is_administrative() {
239            "administrative"
240        } else {
241            "tethered"
242        },
243    );
244}
245
246/// Work out which radio to talk to, from the flags, the environment,
247/// the saved default, and finally the air.
248async fn resolve(args: &ToolArgs, prefs: &Prefs) -> Result<Option<Target>> {
249    if args.pick && (args.port.is_some() || matches!(args.ble, Some(Some(_)))) {
250        bail!("--pick chooses a radio from a listing; --port and --ble=SELECTOR already name one");
251    }
252    if let Some(port) = &args.port {
253        if args.ble.is_some() {
254            bail!("--port and --ble name different radios; give one");
255        }
256        return Ok(Some(Target::Serial {
257            port: port.clone(),
258            baud: args.baud,
259        }));
260    }
261    if let Some(Some(selector)) = &args.ble {
262        return Ok(Some(Target::Ble {
263            selector: selector.clone(),
264            name: None,
265        }));
266    }
267    // Bare `--ble`, or nothing at all: the tool finds the radio itself.
268    let target =
269        connection::discover(prefs, std::io::stdin().is_terminal(), args.discovery()).await?;
270    if let Some(target) = &target {
271        // A mutating one-shot must never act on a silently chosen radio.
272        eprintln!("discovered: {}", target.provisional_label());
273    }
274    Ok(target)
275}
276
277async fn run(args: ToolArgs) -> Result<()> {
278    output::set_color(args.color.enabled());
279    let interactive = args.command.is_none();
280    let mut app = App {
281        session: None,
282        prefs: Prefs::load(),
283        interactive,
284        trace: args.trace,
285        no_save: args.no_save,
286        baud: args.baud,
287        discovery: args.discovery(),
288        last_scan: Vec::new(),
289    };
290
291    // Everything clap's grammar cannot express is checked before a
292    // radio is opened.
293    if let Some(command) = &args.command {
294        command.validate()?;
295    }
296
297    let needs_device = args
298        .command
299        .as_ref()
300        .is_none_or(|command| command.needs_device());
301    if needs_device {
302        match resolve(&args, &app.prefs).await? {
303            Some(target) => {
304                let tethered = args
305                    .command
306                    .as_ref()
307                    .is_some_and(|command| command.needs_tethered());
308                let session = connection::connect(target, tethered, app.trace).await?;
309                announce_attached(&session);
310                app.session = Some(session);
311            }
312            // The shell can still scan and connect; a one-shot cannot.
313            None if interactive => {}
314            None => bail!("no ULCP radios found; name one with --port or --ble=SELECTOR"),
315        }
316    }
317
318    match args.command {
319        Some(command) => command.run(&mut app).await,
320        None => repl::run(&mut app).await,
321    }
322}
323
324#[tokio::main(flavor = "current_thread")]
325async fn main() {
326    // Wireshark drives this binary through its own argument vocabulary,
327    // which is checked before the tool's parser so the two never have to
328    // agree on a shared grammar.
329    let result = if extcap::is_extcap_invocation() {
330        extcap::run().await
331    } else {
332        run(ToolArgs::parse()).await
333    };
334    if let Err(error) = result {
335        eprintln!("error: {error:#}");
336        std::process::exit(1);
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use command::values::{DutyLimitArg, PinArg};
344
345    fn parse(argv: &[&str]) -> Result<ToolArgs, clap::Error> {
346        let mut args = vec!["umshctl"];
347        args.extend_from_slice(argv);
348        ToolArgs::try_parse_from(args)
349    }
350
351    #[test]
352    fn a_bare_invocation_has_no_command_and_no_connection() {
353        let args = parse(&[]).unwrap();
354        assert!(args.command.is_none());
355        assert!(args.port.is_none());
356        assert!(args.ble.is_none());
357        assert_eq!(args.baud, 115_200);
358    }
359
360    #[test]
361    fn the_ble_selector_needs_the_equals_form() {
362        // Bare `--ble` means "BLE, discover", and must not swallow the
363        // command word that follows it.
364        let bare = parse(&["--ble", "info"]).unwrap();
365        assert_eq!(bare.ble, Some(None));
366        assert!(matches!(bare.command, Some(Command::Info(_))));
367
368        let named = parse(&["--ble=UMSH T-Echo", "info"]).unwrap();
369        assert_eq!(named.ble, Some(Some("UMSH T-Echo".into())));
370        assert!(matches!(named.command, Some(Command::Info(_))));
371
372        // Even a selector that collides with a command word is
373        // unambiguous.
374        let collision = parse(&["--ble=info", "info"]).unwrap();
375        assert_eq!(collision.ble, Some(Some("info".into())));
376    }
377
378    #[test]
379    fn pick_asks_and_pairs_only_with_discovery() {
380        assert_eq!(parse(&[]).unwrap().discovery(), Discovery::Auto);
381        let picked = parse(&["--pick", "info"]).unwrap();
382        assert_eq!(picked.discovery(), Discovery::Ask);
383        // Bare `--ble` still discovers, so it composes; a named radio
384        // does not, but that is a resolve-time check, not a grammar one.
385        assert_eq!(
386            parse(&["--pick", "--ble"]).unwrap().discovery(),
387            Discovery::Ask
388        );
389    }
390
391    #[test]
392    fn connection_flags_work_before_or_after_the_command() {
393        let before = parse(&["-p", "/dev/cu.usbmodem101", "info"]).unwrap();
394        let after = parse(&["info", "-p", "/dev/cu.usbmodem101"]).unwrap();
395        assert_eq!(before.port, after.port);
396        assert_eq!(before.port.as_deref(), Some("/dev/cu.usbmodem101"));
397    }
398
399    #[test]
400    fn provision_flags_build_the_desired_state() {
401        const KEY: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
402        let peer = format!("{KEY},{},{}", "e0".repeat(16), "50".repeat(16));
403        let args = parse(&[
404            "provision",
405            "--host-key",
406            KEY,
407            "--channel-key",
408            KEY,
409            "--peer",
410            &peer,
411            "--filter=pkt-type:1",
412            "--filter",
413            "channel-id:9b68",
414            "--auto-ack=off",
415            "--force",
416            "--no-save",
417        ])
418        .unwrap();
419        assert!(args.no_save);
420        let Some(Command::Provision(provision)) = args.command else {
421            panic!("expected provision");
422        };
423        assert!(provision.force);
424        assert_eq!(provision.host_key.len(), 1);
425        assert_eq!(provision.channel_key.len(), 1);
426        assert_eq!(provision.peer.len(), 1);
427        assert_eq!(provision.filter.len(), 2);
428        assert_eq!(provision.auto_ack.len(), 1);
429        assert!(!provision.auto_ack[0].0);
430    }
431
432    #[test]
433    fn misplaced_options_are_rejected() {
434        assert!(parse(&["info", "--force"]).is_err());
435        assert!(parse(&["save", "--host-key", "aa"]).is_err());
436        assert!(parse(&["info", "--expect-host-key=aa"]).is_err(), "bad key");
437    }
438
439    #[test]
440    fn pin_takes_six_digits_or_clear() {
441        let Some(Command::Pin { value }) = parse(&["pin", "042319"]).unwrap().command else {
442            panic!("expected pin");
443        };
444        assert_eq!(value, PinArg(Some(42_319)));
445        assert!(parse(&["pin", "12345"]).is_err());
446        assert!(parse(&["pin"]).is_err());
447    }
448
449    #[test]
450    fn duty_parses_show_and_limit_forms() {
451        assert!(matches!(
452            parse(&["duty"]).unwrap().command,
453            Some(Command::Duty { op: None })
454        ));
455        let Some(Command::Duty {
456            op: Some(command::duty::DutyOp::Limit { value }),
457        }) = parse(&["duty", "limit", "655"]).unwrap().command
458        else {
459            panic!("expected duty limit");
460        };
461        assert_eq!(value, DutyLimitArg(655));
462        assert!(matches!(
463            parse(&["duty", "limit", "off"]).unwrap().command,
464            Some(Command::Duty {
465                op: Some(command::duty::DutyOp::Limit {
466                    value: DutyLimitArg(u16::MAX)
467                })
468            })
469        ));
470        assert!(parse(&["duty", "limit"]).is_err());
471        assert!(parse(&["duty", "limit", "70000"]).is_err());
472        assert!(parse(&["duty", "now"]).is_err());
473    }
474
475    #[test]
476    fn phy_rejects_out_of_range_modulation() {
477        assert!(parse(&["phy", "sf", "7"]).is_ok());
478        assert!(parse(&["phy", "sf", "13"]).is_err());
479        assert!(parse(&["phy", "cr", "9"]).is_err());
480        // A negative TX power is a value, not a flag.
481        assert!(parse(&["phy", "power", "-9"]).is_ok());
482    }
483
484    #[test]
485    fn repeater_gates_accept_negative_values_and_none() {
486        use command::repeater::RepeaterOp;
487        assert!(matches!(
488            parse(&["repeater"]).unwrap().command,
489            Some(Command::Repeater { op: None })
490        ));
491        let Some(Command::Repeater {
492            op: Some(RepeaterOp::MinRssi { dbm }),
493        }) = parse(&["repeater", "min-rssi", "-110"]).unwrap().command
494        else {
495            panic!("expected min-rssi");
496        };
497        assert_eq!(dbm.0, Some(-110));
498        assert!(parse(&["repeater", "min-rssi", "none"]).is_ok());
499        assert!(parse(&["repeater", "min-rssi", "loud"]).is_err());
500        assert!(parse(&["repeater", "regions"]).is_err());
501        assert!(parse(&["repeater", "regions", "SJC,"]).is_err());
502        assert!(parse(&["repeater", "yes"]).is_err());
503    }
504
505    #[test]
506    fn factory_reset_confirmation_is_a_flag_not_a_parse_error() {
507        // Unlike the tool this replaces, refusing an unconfirmed wipe is
508        // a decision made against the session — the shell asks instead.
509        let Some(Command::FactoryReset { yes }) = parse(&["factory-reset"]).unwrap().command else {
510            panic!("expected factory-reset");
511        };
512        assert!(!yes);
513        let Some(Command::FactoryReset { yes }) =
514            parse(&["factory-reset", "--yes"]).unwrap().command
515        else {
516            panic!("expected factory-reset");
517        };
518        assert!(yes);
519    }
520
521    #[test]
522    fn commands_that_look_up_nothing_need_no_device() {
523        assert!(!parse(&["scan"]).unwrap().command.unwrap().needs_device());
524        assert!(
525            !parse(&["default", "set", "id-a"])
526                .unwrap()
527                .command
528                .unwrap()
529                .needs_device()
530        );
531        assert!(
532            parse(&["default", "set"])
533                .unwrap()
534                .command
535                .unwrap()
536                .needs_device()
537        );
538        assert!(parse(&["info"]).unwrap().command.unwrap().needs_device());
539    }
540
541    #[test]
542    fn only_provision_asks_to_tether() {
543        assert!(!parse(&["info"]).unwrap().command.unwrap().needs_tethered());
544        const KEY: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
545        assert!(
546            parse(&["provision", "--host-key", KEY])
547                .unwrap()
548                .command
549                .unwrap()
550                .needs_tethered()
551        );
552    }
553
554    #[test]
555    fn the_name_command_replaces_set_name() {
556        assert!(matches!(
557            parse(&["name"]).unwrap().command,
558            Some(Command::Name { name: None })
559        ));
560        let Some(Command::Name { name }) = parse(&["name", "Repeater 3"]).unwrap().command else {
561            panic!("expected name");
562        };
563        assert_eq!(name.as_deref(), Some("Repeater 3"));
564        assert!(parse(&["set-name", "x"]).is_err());
565    }
566}