umshctl/
main.rs

1//! `umshctl`: the host tool for ULCP radio devices — inspection,
2//! device identity, persistence, pairing, radio configuration, and
3//! packet capture.
4//!
5//! Every attach is administrative, with the non-resetting full-protocol
6//! handshake, so pointing this tool at an autonomously operating board
7//! never disturbs its configuration: only the command explicitly given
8//! 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 mesh;
23mod output;
24mod repl;
25mod routes;
26
27use std::io::IsTerminal;
28
29use anyhow::{Result, bail};
30use clap::Parser;
31
32use umsh::ulcp::UlcpDevice;
33
34use command::Command;
35use connection::{Discovery, Found, Prefs, Session, SessionLink, Target};
36use output::ColorChoice;
37
38#[derive(Debug, Parser)]
39#[command(
40    name = "umshctl",
41    version,
42    about = "Inspect, configure, and capture from ULCP radio devices",
43    long_about = "\
44Manages a ULCP radio device without disturbing it: attaches with the
45non-resetting full-protocol handshake, so an autonomously operating
46board keeps its configuration unless the command changes it.
47
48With no command, opens an interactive shell against one attachment.
49With no connection, discovers a radio over BLE: one match is used, and
50several offer a numbered choice, which --pick asks for outright. A
51serial port is used only when named,
52because identifying one means opening it, and opening a port can reset
53or DFU-trigger hardware that is not a ULCP radio at all. --tcp reaches a
54radio whose port has been bridged to a socket, which carries the same
55framing a wire does; there is nothing to discover, so it is always named.
56
57KEY values are 44-character base58 or 64-character hex. Secrets are
58never echoed in output or traces — though shell history keeps whatever
59was typed, the same as any shell.
60
61CODE values are a short code of one to three letters or digits — an IATA
62airport code (SJC) or a country or state (US, WA) — a raw 2-byte code
63(0x7853), or any other text, which is hashed as a region name
64(\"Rogue Valley\"). Only an all-letter short code reads back as itself.
65Region codes are routing-domain tags, not RF band plans, so every
66repeater in an area must agree on the same spelling."
67)]
68pub struct ToolArgs {
69    /// Serial port to attach to.
70    #[arg(
71        short = 'p',
72        long,
73        value_name = "PORT",
74        env = "UMSHCTL_PORT",
75        global = true
76    )]
77    port: Option<String>,
78
79    /// Attach over BLE, optionally naming the radio by name or scan id.
80    ///
81    /// The selector needs the `=` form (`--ble=T-Echo`); bare `--ble`
82    /// discovers.
83    #[arg(
84        short = 'b',
85        long,
86        value_name = "SELECTOR",
87        num_args(0..=1),
88        require_equals = true,
89        global = true
90    )]
91    ble: Option<Option<String>>,
92
93    /// Attach to a radio served over TCP, as `HOST:PORT`.
94    ///
95    /// The socket carries the same framing a serial port does, so
96    /// anything bridging one to a listening socket serves a radio this
97    /// way:
98    ///
99    ///   socat TCP-LISTEN:9000,reuseaddr /dev/cu.usbmodem101,raw,echo=0,b115200
100    #[arg(long, value_name = "HOST:PORT", env = "UMSHCTL_TCP", global = true)]
101    tcp: Option<String>,
102
103    /// Choose the radio from a numbered listing, ignoring the saved
104    /// default.
105    ///
106    /// The listing appears on its own whenever the answer is ambiguous;
107    /// this asks for it even when it is not.
108    #[arg(long, global = true)]
109    pick: bool,
110
111    /// Serial bit rate.
112    #[arg(long, default_value_t = 115_200, value_name = "N", global = true)]
113    baud: u32,
114
115    /// Print every ULCP frame on stderr.
116    #[arg(long, global = true)]
117    trace: bool,
118
119    /// Leave mutations live-only. They otherwise persist automatically
120    /// via CMD_SAVE.
121    #[arg(long, global = true)]
122    no_save: bool,
123
124    /// Reach a device across the mesh, using the attached radio to get
125    /// there.
126    ///
127    /// The command-line form of the shell's `remote`: attach a radio as
128    /// usual, then borrow it to open a session to KEY. With a command,
129    /// that command runs over the mesh and the radio is handed back;
130    /// with no command, the shell opens already talking to KEY. The
131    /// device must list this tool's administrator key (`admin-key`
132    /// prints it).
133    #[arg(long, value_name = "KEY", global = true)]
134    node: Option<command::values::KeyArg>,
135
136    /// When to colorize output.
137    #[arg(long, value_enum, default_value_t = ColorChoice::Auto, value_name = "WHEN", global = true)]
138    color: ColorChoice,
139
140    #[command(subcommand)]
141    command: Option<Command>,
142}
143
144impl ToolArgs {
145    fn discovery(&self) -> Discovery {
146        if self.pick {
147            Discovery::Ask
148        } else {
149            Discovery::Auto
150        }
151    }
152}
153
154/// Everything a command may need beyond the device itself: the
155/// attachment, the settings, and the difference between a shell and a
156/// one-shot invocation.
157pub struct App {
158    pub session: Option<Session>,
159    pub prefs: Prefs,
160    /// True in the shell, where a question can be asked and a failure
161    /// returns to a prompt.
162    pub interactive: bool,
163    pub trace: bool,
164    pub no_save: bool,
165    pub baud: u32,
166    /// How a bare `connect` resolves a scan — `--pick` at launch keeps
167    /// asking for the rest of the shell session.
168    pub discovery: Discovery,
169    /// The last `ble-scan` listing, so `connect <N>` can refer to it.
170    pub last_scan: Vec<Found>,
171    /// What a mesh session borrowed, while one is open. Present exactly
172    /// when `session` reaches its device over the air.
173    pub mesh: Option<mesh::MeshHome>,
174}
175
176impl App {
177    pub fn session(&mut self) -> Result<&mut Session> {
178        match &mut self.session {
179            Some(session) => Ok(session),
180            None => bail!("not attached — try `ble-scan` or `connect`"),
181        }
182    }
183
184    pub fn device(&mut self) -> Result<&mut UlcpDevice<SessionLink>> {
185        Ok(&mut self.session()?.device)
186    }
187
188    pub fn target_is_ble(&self) -> bool {
189        matches!(
190            self.session.as_ref().map(|session| &session.target),
191            Some(Target::Ble { .. })
192        )
193    }
194
195    /// Drop the attachment, returning what it was called. Dropping the
196    /// link is what reverts session-scoped device state.
197    ///
198    /// Ending a mesh session drops its device handle first, which is what
199    /// closes the link and lets the driver wind down and hand the
200    /// borrowed radio back — so the caller is left attached to the local
201    /// radio again rather than to nothing.
202    pub async fn detach(&mut self) -> Option<String> {
203        let label = self.session.take().map(|session| session.label);
204        if let Some(home) = self.mesh.take() {
205            mesh::restore_local(self, home).await;
206        }
207        label
208    }
209
210    pub fn rename(&mut self, label: String) {
211        if let Some(session) = &mut self.session {
212            session.label = label;
213        }
214    }
215
216    /// An attached, non-interactive app for the Wireshark extcap
217    /// interface.
218    ///
219    /// `interactive` is false in both its senses here: there is no
220    /// prompt to return to, and nobody to answer a question. That is
221    /// also what lets a dropped BLE link be recovered underneath a
222    /// running capture.
223    pub fn for_extcap(session: Session, prefs: Prefs, baud: u32) -> Self {
224        Self {
225            session: Some(session),
226            prefs,
227            interactive: false,
228            trace: false,
229            no_save: true,
230            baud,
231            discovery: Discovery::Auto,
232            last_scan: Vec::new(),
233            mesh: None,
234        }
235    }
236
237    pub async fn attach(&mut self, target: Target) -> Result<()> {
238        let session = connection::connect(target, self.trace).await?;
239        announce_attached(&session);
240        self.session = Some(session);
241        Ok(())
242    }
243
244    /// Open a fresh link to the same radio, keeping the capture tap so a
245    /// recovered capture stays one file.
246    pub async fn reconnect(&mut self) -> Result<()> {
247        let Some(session) = self.session.take() else {
248            bail!("not attached");
249        };
250        self.session = Some(session.reconnect(self.trace).await?);
251        Ok(())
252    }
253
254    pub fn prompt(&self) -> String {
255        match &self.session {
256            Some(session) => format!("{} ({})> ", session.label, session.target.transport()),
257            None => "(unattached)> ".to_string(),
258        }
259    }
260}
261
262fn announce_attached(session: &Session) {
263    eprintln!(
264        "attached: {} ({}) device={}{} boot_status={:?}",
265        session.label,
266        session.target.transport(),
267        session.device.dev_version(),
268        // `PROP_DEV_MODEL` is optional; say nothing rather than "unknown"
269        // when the device does not name its hardware — and a device that
270        // answers with an empty string has named it no better than one
271        // that refuses the read.
272        session
273            .device
274            .dev_model()
275            .filter(|model| !model.is_empty())
276            .map(|model| format!(" on {model}"))
277            .unwrap_or_default(),
278        session.device.boot_status(),
279    );
280}
281
282/// Work out which radio to talk to, from the flags, the environment,
283/// the saved default, and finally the air.
284async fn resolve(args: &ToolArgs, prefs: &Prefs) -> Result<Option<Target>> {
285    if args.pick && (args.port.is_some() || args.tcp.is_some() || matches!(args.ble, Some(Some(_))))
286    {
287        bail!(
288            "--pick chooses a radio from a listing; --port, --tcp, and --ble=SELECTOR already \
289             name one"
290        );
291    }
292    if let Some(endpoint) = &args.tcp {
293        if args.port.is_some() || args.ble.is_some() {
294            bail!("--tcp, --port, and --ble name different radios; give one");
295        }
296        let (host, port) = connection::parse_endpoint(endpoint)?;
297        return Ok(Some(Target::Tcp { host, port }));
298    }
299    if let Some(port) = &args.port {
300        if args.ble.is_some() {
301            bail!("--port and --ble name different radios; give one");
302        }
303        return Ok(Some(Target::Serial {
304            port: port.clone(),
305            baud: args.baud,
306        }));
307    }
308    if let Some(Some(selector)) = &args.ble {
309        return Ok(Some(Target::Ble {
310            selector: selector.clone(),
311            name: None,
312        }));
313    }
314    // Bare `--ble`, or nothing at all: the tool finds the radio itself.
315    let target =
316        connection::discover(prefs, std::io::stdin().is_terminal(), args.discovery()).await?;
317    if let Some(target) = &target {
318        // A mutating one-shot must never act on a silently chosen radio.
319        eprintln!("discovered: {}", target.provisional_label());
320    }
321    Ok(target)
322}
323
324async fn run(args: ToolArgs) -> Result<()> {
325    output::set_color(args.color.enabled());
326    let interactive = args.command.is_none();
327    let mut app = App {
328        session: None,
329        prefs: Prefs::load(),
330        interactive,
331        trace: args.trace,
332        no_save: args.no_save,
333        baud: args.baud,
334        discovery: args.discovery(),
335        last_scan: Vec::new(),
336        mesh: None,
337    };
338
339    // Everything clap's grammar cannot express is checked before a
340    // radio is opened.
341    if let Some(command) = &args.command {
342        command.validate()?;
343        // A command a mesh session cannot carry should say so now, not
344        // after a discovery pass and an attach handshake.
345        if args.node.is_some()
346            && let Some(refusal) = command.mesh_refusal()
347        {
348            bail!("{refusal}");
349        }
350    }
351    let needs_device = args
352        .command
353        .as_ref()
354        .is_none_or(|command| command.needs_device());
355    if needs_device {
356        match resolve(&args, &app.prefs).await? {
357            Some(target) => {
358                let session = connection::connect(target, app.trace).await?;
359                announce_attached(&session);
360                app.session = Some(session);
361            }
362            // The shell can still scan and connect; a one-shot cannot.
363            None if interactive => {}
364            None => bail!("no ULCP radios found; name one with --port, --tcp, or --ble=SELECTOR"),
365        }
366    }
367
368    // `--node` borrows the radio just attached and opens a session to
369    // the named device across the mesh. With a command that is the
370    // one-shot form of the shell's `remote`: run it and hand the radio
371    // back, so a script gets the same session the shell would have. With
372    // no command it is simply where the shell starts — the prompt opens
373    // already talking to the far node.
374    //
375    // A one-shot spends nothing on the air before the command itself: a
376    // script asking for the battery should pay for the battery and
377    // nothing else. The shell greets the node by name, because a prompt
378    // that says which device it is answers a question every subsequent
379    // command would otherwise raise.
380    if let Some(node) = args.node {
381        let greeting = if interactive {
382            mesh::Greeting::Named
383        } else {
384            mesh::Greeting::Silent
385        };
386        mesh::open_remote(&mut app, umsh::core::PublicKey(node.0), greeting).await?;
387        let result = match args.command {
388            Some(command) => command.run(&mut app).await,
389            None => repl::run(&mut app).await,
390        };
391        app.detach().await;
392        return result;
393    }
394
395    match args.command {
396        Some(command) => command.run(&mut app).await,
397        None => repl::run(&mut app).await,
398    }
399}
400
401#[tokio::main(flavor = "current_thread")]
402async fn main() {
403    // Wireshark drives this binary through its own argument vocabulary,
404    // which is checked before the tool's parser so the two never have to
405    // agree on a shared grammar.
406    //
407    // Everything here is `!Send` — the node layer, the pcap tap, the
408    // whole session — so the one task a mesh session spawns to hold the
409    // borrowed radio needs a `LocalSet` to be spawned onto.
410    let local = tokio::task::LocalSet::new();
411    let result = local
412        .run_until(async {
413            if extcap::is_extcap_invocation() {
414                extcap::run().await
415            } else {
416                run(ToolArgs::parse()).await
417            }
418        })
419        .await;
420    if let Err(error) = result {
421        eprintln!("error: {error:#}");
422        std::process::exit(1);
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use command::values::{DutyLimitArg, PinArg};
430
431    fn parse(argv: &[&str]) -> Result<ToolArgs, clap::Error> {
432        let mut args = vec!["umshctl"];
433        args.extend_from_slice(argv);
434        ToolArgs::try_parse_from(args)
435    }
436
437    #[test]
438    fn a_bare_invocation_has_no_command_and_no_connection() {
439        let args = parse(&[]).unwrap();
440        assert!(args.command.is_none());
441        assert!(args.port.is_none());
442        assert!(args.ble.is_none());
443        assert_eq!(args.baud, 115_200);
444    }
445
446    #[test]
447    fn the_ble_selector_needs_the_equals_form() {
448        // Bare `--ble` means "BLE, discover", and must not swallow the
449        // command word that follows it.
450        let bare = parse(&["--ble", "info"]).unwrap();
451        assert_eq!(bare.ble, Some(None));
452        assert!(matches!(bare.command, Some(Command::Info(_))));
453
454        let named = parse(&["--ble=UMSH T-Echo", "info"]).unwrap();
455        assert_eq!(named.ble, Some(Some("UMSH T-Echo".into())));
456        assert!(matches!(named.command, Some(Command::Info(_))));
457
458        // Even a selector that collides with a command word is
459        // unambiguous.
460        let collision = parse(&["--ble=info", "info"]).unwrap();
461        assert_eq!(collision.ble, Some(Some("info".into())));
462    }
463
464    #[test]
465    fn pick_asks_and_pairs_only_with_discovery() {
466        assert_eq!(parse(&[]).unwrap().discovery(), Discovery::Auto);
467        let picked = parse(&["--pick", "info"]).unwrap();
468        assert_eq!(picked.discovery(), Discovery::Ask);
469        // Bare `--ble` still discovers, so it composes; a named radio
470        // does not, but that is a resolve-time check, not a grammar one.
471        assert_eq!(
472            parse(&["--pick", "--ble"]).unwrap().discovery(),
473            Discovery::Ask
474        );
475    }
476
477    #[test]
478    fn connection_flags_work_before_or_after_the_command() {
479        let before = parse(&["-p", "/dev/cu.usbmodem101", "info"]).unwrap();
480        let after = parse(&["info", "-p", "/dev/cu.usbmodem101"]).unwrap();
481        assert_eq!(before.port, after.port);
482        assert_eq!(before.port.as_deref(), Some("/dev/cu.usbmodem101"));
483    }
484
485    #[test]
486    fn misplaced_options_are_rejected() {
487        assert!(parse(&["info", "--force"]).is_err());
488        assert!(parse(&["save", "--pcap", "x.pcap"]).is_err());
489        assert!(parse(&["info", "--expect-host-key=aa"]).is_err(), "bad key");
490    }
491
492    #[test]
493    fn pin_takes_six_digits_or_clear() {
494        let Some(Command::Pin { value }) = parse(&["pin", "042319"]).unwrap().command else {
495            panic!("expected pin");
496        };
497        assert_eq!(value, PinArg(Some(42_319)));
498        assert!(parse(&["pin", "12345"]).is_err());
499        assert!(parse(&["pin"]).is_err());
500    }
501
502    #[test]
503    fn duty_parses_show_and_limit_forms() {
504        assert!(matches!(
505            parse(&["duty"]).unwrap().command,
506            Some(Command::Duty { op: None })
507        ));
508        let Some(Command::Duty {
509            op: Some(command::duty::DutyOp::Limit { value }),
510        }) = parse(&["duty", "limit", "655"]).unwrap().command
511        else {
512            panic!("expected duty limit");
513        };
514        assert_eq!(value, DutyLimitArg(655));
515        assert!(matches!(
516            parse(&["duty", "limit", "off"]).unwrap().command,
517            Some(Command::Duty {
518                op: Some(command::duty::DutyOp::Limit {
519                    value: DutyLimitArg(u16::MAX)
520                })
521            })
522        ));
523        assert!(parse(&["duty", "limit"]).is_err());
524        assert!(parse(&["duty", "limit", "70000"]).is_err());
525        assert!(parse(&["duty", "now"]).is_err());
526    }
527
528    #[test]
529    fn phy_rejects_out_of_range_modulation() {
530        assert!(parse(&["phy", "sf", "7"]).is_ok());
531        assert!(parse(&["phy", "sf", "13"]).is_err());
532        assert!(parse(&["phy", "cr", "9"]).is_err());
533        // A negative TX power is a value, not a flag.
534        assert!(parse(&["phy", "power", "-9"]).is_ok());
535    }
536
537    #[test]
538    fn repeater_gates_accept_negative_values_and_none() {
539        use command::repeater::RepeaterOp;
540        assert!(matches!(
541            parse(&["repeater"]).unwrap().command,
542            Some(Command::Repeater { op: None })
543        ));
544        let Some(Command::Repeater {
545            op: Some(RepeaterOp::MinRssi { dbm }),
546        }) = parse(&["repeater", "min-rssi", "-110"]).unwrap().command
547        else {
548            panic!("expected min-rssi");
549        };
550        assert_eq!(dbm.0, Some(-110));
551        assert!(parse(&["repeater", "min-rssi", "none"]).is_ok());
552        assert!(parse(&["repeater", "min-rssi", "loud"]).is_err());
553        // The region table is edited entry at a time or replaced whole;
554        // a bare `regions` lists it.
555        assert!(parse(&["repeater", "regions"]).is_ok());
556        assert!(parse(&["repeater", "regions", "add", "Rogue Valley"]).is_ok());
557        assert!(parse(&["repeater", "regions", "set", "SJC,"]).is_err());
558        assert!(parse(&["repeater", "regions", "SJC"]).is_err());
559        assert!(parse(&["repeater", "yes"]).is_err());
560    }
561
562    #[test]
563    fn factory_reset_confirmation_is_a_flag_not_a_parse_error() {
564        // Unlike the tool this replaces, refusing an unconfirmed wipe is
565        // a decision made against the session — the shell asks instead.
566        let Some(Command::FactoryReset { yes }) = parse(&["factory-reset"]).unwrap().command else {
567            panic!("expected factory-reset");
568        };
569        assert!(!yes);
570        let Some(Command::FactoryReset { yes }) =
571            parse(&["factory-reset", "--yes"]).unwrap().command
572        else {
573            panic!("expected factory-reset");
574        };
575        assert!(yes);
576    }
577
578    #[test]
579    fn a_message_is_whatever_words_follow_the_key() {
580        const KEY: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
581        let Some(Command::Send(args)) = parse(&["send", KEY, "on", "my", "way"]).unwrap().command
582        else {
583            panic!("expected send");
584        };
585        assert_eq!(args.target.0, [0xC4; 32]);
586        assert_eq!(args.text.join(" "), "on my way");
587        assert_eq!(args.timeout, 30);
588        assert!(!args.no_ack);
589
590        // A message needs a body.
591        assert!(parse(&["send", KEY]).is_err());
592
593        // The flags stay flags, wherever they are written.
594        let Some(Command::Send(args)) =
595            parse(&["send", KEY, "hurry", "--timeout", "5", "--no-ack"])
596                .unwrap()
597                .command
598        else {
599            panic!("expected send");
600        };
601        assert_eq!(args.text.join(" "), "hurry");
602        assert_eq!(args.timeout, 5);
603        assert!(args.no_ack);
604
605        // A body that begins with a dash goes after `--`, as anywhere.
606        let Some(Command::Send(args)) = parse(&["send", KEY, "--", "-9 dBm?"]).unwrap().command
607        else {
608            panic!("expected send");
609        };
610        assert_eq!(args.text.join(" "), "-9 dBm?");
611
612        let Some(Command::Listen(args)) = parse(&["listen"]).unwrap().command else {
613            panic!("expected listen");
614        };
615        assert!(args.timeout.is_none());
616        assert!(args.from.is_empty());
617
618        // Listening for somebody this tool has never reached takes their
619        // key, and takes it more than once.
620        const OTHER: &str = "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7";
621        let Some(Command::Listen(args)) =
622            parse(&["listen", "--from", KEY, "--from", OTHER, "--timeout", "5"])
623                .unwrap()
624                .command
625        else {
626            panic!("expected listen");
627        };
628        assert_eq!(args.timeout, Some(5));
629        assert_eq!(
630            args.from.iter().map(|key| key.0).collect::<Vec<_>>(),
631            [[0xC4; 32], [0xA7; 32]]
632        );
633    }
634
635    #[test]
636    fn routes_reads_this_tools_own_notes() {
637        use command::routes_cmd::RoutesOp;
638        let key = "c4".repeat(32);
639        assert!(matches!(
640            parse(&["routes"]).unwrap().command,
641            Some(Command::Routes { op: None })
642        ));
643        let Some(Command::Routes {
644            op: Some(RoutesOp::Show { key: shown }),
645        }) = parse(&["routes", "show", &key]).unwrap().command
646        else {
647            panic!("expected routes show");
648        };
649        assert_eq!(shown.0, [0xC4; 32]);
650        assert!(matches!(
651            parse(&["routes", "clear"]).unwrap().command,
652            Some(Command::Routes {
653                op: Some(RoutesOp::Clear { key: None })
654            })
655        ));
656        assert!(parse(&["routes", "clear", &key]).is_ok());
657        assert!(parse(&["routes", "show"]).is_err(), "show names a node");
658        // No radio is involved in reading a file this tool wrote.
659        assert!(!parse(&["routes"]).unwrap().command.unwrap().needs_device());
660    }
661
662    #[test]
663    fn commands_that_look_up_nothing_need_no_device() {
664        assert!(
665            !parse(&["ble-scan"])
666                .unwrap()
667                .command
668                .unwrap()
669                .needs_device()
670        );
671        // The old name is gone rather than aliased.
672        assert!(parse(&["scan"]).is_err());
673        assert!(
674            !parse(&["default", "set", "id-a"])
675                .unwrap()
676                .command
677                .unwrap()
678                .needs_device()
679        );
680        assert!(
681            parse(&["default", "set"])
682                .unwrap()
683                .command
684                .unwrap()
685                .needs_device()
686        );
687        assert!(parse(&["info"]).unwrap().command.unwrap().needs_device());
688    }
689
690    #[test]
691    fn a_mesh_session_refuses_only_what_it_cannot_carry() {
692        let refused = |argv: &[&str]| {
693            parse(argv)
694                .unwrap()
695                .command
696                .unwrap()
697                .mesh_refusal()
698                .is_some()
699        };
700        let key = "c4".repeat(32);
701
702        // Needs the attached radio's own receiver, or the radio itself.
703        assert!(refused(&["capture"]));
704        assert!(refused(&["manage", &key, "info"]));
705        assert!(refused(&["peer-repeaters", &key]));
706        assert!(refused(&["ping", &key]));
707        // Messaging and discovery make this tool a node, and the mesh
708        // session has already borrowed the only radio there is.
709        assert!(refused(&["send", &key, "hello"]));
710        assert!(refused(&["listen"]));
711        assert!(refused(&["discover"]));
712
713        // Everything else is an ordinary property conversation, and the
714        // binding carries it — including the reset-class commands and
715        // the write-only pairing PIN.
716        for argv in [
717            vec!["info"],
718            vec!["get", "battery"],
719            vec!["set", "dev-name", "Repeater 3"],
720            vec!["name"],
721            vec!["save"],
722            vec!["reset"],
723            vec!["restore"],
724            vec!["factory-reset", "--yes"],
725            vec!["gnss"],
726            vec!["advert"],
727            vec!["repeater"],
728            vec!["time"],
729            vec!["phy"],
730            vec!["duty"],
731            vec!["dev-admin"],
732            vec!["dev-peer"],
733            vec!["alert"],
734            vec!["illuminance"],
735            vec!["pin", "123456"],
736            vec!["admin-key"],
737        ] {
738            assert!(!refused(&argv), "{argv:?} should work over the mesh");
739        }
740    }
741
742    #[test]
743    fn the_node_flag_names_a_device_with_or_without_a_command() {
744        let key = "c4".repeat(32);
745        let args = parse(&["--node", &key, "info"]).unwrap();
746        assert_eq!(args.node.unwrap().0, [0xC4; 32]);
747
748        // Global, so it reads the same before or after the command.
749        let args = parse(&["info", "--node", &key]).unwrap();
750        assert_eq!(args.node.unwrap().0, [0xC4; 32]);
751
752        // With no command it is where the shell starts, so the grammar
753        // must accept it standing alone.
754        let args = parse(&["--node", &key]).unwrap();
755        assert_eq!(args.node.unwrap().0, [0xC4; 32]);
756        assert!(args.command.is_none());
757
758        assert!(parse(&["--node", "nonsense", "info"]).is_err());
759    }
760
761    #[test]
762    fn ping_defaults_to_one_traced_ping() {
763        const KEY: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
764        let Some(Command::Ping(args)) = parse(&["ping", KEY]).unwrap().command else {
765            panic!("expected ping");
766        };
767        assert_eq!(args.target.0, [0xC4; 32]);
768        assert_eq!(args.count, 1);
769        assert_eq!(args.size, 8);
770        assert!(!args.untraced);
771        assert!(args.channel.is_none());
772        // Borrowing the radio is what a ping does, so it needs one.
773        assert!(
774            parse(&["ping", KEY])
775                .unwrap()
776                .command
777                .unwrap()
778                .needs_device()
779        );
780    }
781
782    #[test]
783    fn ping_flags_shape_the_frame() {
784        const KEY: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
785        let Some(Command::Ping(args)) = parse(&[
786            "ping",
787            KEY,
788            "-c",
789            "5",
790            "-i",
791            "10",
792            "-W",
793            "45",
794            "-s",
795            "120",
796            "--hops",
797            "3",
798            "--route",
799            "a1b2,9b68",
800            "--channel",
801            "trail",
802            "--mic",
803            "16",
804            "--region",
805            "SJC",
806            "--full-source",
807            "--salt",
808            "--untraced",
809        ])
810        .unwrap()
811        .command
812        else {
813            panic!("expected ping");
814        };
815        assert_eq!((args.count, args.interval, args.timeout), (5, 10, 45));
816        assert_eq!(args.size, 120);
817        assert_eq!(args.hops, Some(3));
818        assert_eq!(args.route.as_ref().unwrap().0.len(), 2);
819        assert_eq!(args.channel.as_ref().unwrap().0.name(), "trail");
820        assert!(args.full_source && args.salt && args.untraced);
821    }
822
823    #[test]
824    fn ping_rejects_contradictions_and_out_of_range_values() {
825        const KEY: &str = "c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4c4";
826        // A steered route and a forced flood ask for opposite things.
827        assert!(parse(&["ping", KEY, "--flood", "--route", "a1b2"]).is_err());
828        // An ack-only ping carries no echo data to size.
829        assert!(parse(&["ping", KEY, "--ack-only", "-s", "40"]).is_err());
830        // Two bytes of the echo are the nonce that matches the reply.
831        assert!(parse(&["ping", KEY, "-s", "1"]).is_err());
832        assert!(parse(&["ping", KEY, "-c", "0"]).is_err());
833        assert!(parse(&["ping", KEY, "--hops", "16"]).is_err());
834        assert!(parse(&["ping", KEY, "--mic", "10"]).is_err());
835    }
836
837    #[test]
838    fn the_name_command_replaces_set_name() {
839        assert!(matches!(
840            parse(&["name"]).unwrap().command,
841            Some(Command::Name { name: None })
842        ));
843        let Some(Command::Name { name }) = parse(&["name", "Repeater 3"]).unwrap().command else {
844            panic!("expected name");
845        };
846        assert_eq!(name.as_deref(), Some("Repeater 3"));
847        assert!(parse(&["set-name", "x"]).is_err());
848    }
849}