1#![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 #[arg(
71 short = 'p',
72 long,
73 value_name = "PORT",
74 env = "UMSHCTL_PORT",
75 global = true
76 )]
77 port: Option<String>,
78
79 #[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 #[arg(long, value_name = "HOST:PORT", env = "UMSHCTL_TCP", global = true)]
101 tcp: Option<String>,
102
103 #[arg(long, global = true)]
109 pick: bool,
110
111 #[arg(long, default_value_t = 115_200, value_name = "N", global = true)]
113 baud: u32,
114
115 #[arg(long, global = true)]
117 trace: bool,
118
119 #[arg(long, global = true)]
122 no_save: bool,
123
124 #[arg(long, value_name = "KEY", global = true)]
134 node: Option<command::values::KeyArg>,
135
136 #[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
154pub struct App {
158 pub session: Option<Session>,
159 pub prefs: Prefs,
160 pub interactive: bool,
163 pub trace: bool,
164 pub no_save: bool,
165 pub baud: u32,
166 pub discovery: Discovery,
169 pub last_scan: Vec<Found>,
171 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 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 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 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 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
282async 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 let target =
316 connection::discover(prefs, std::io::stdin().is_terminal(), args.discovery()).await?;
317 if let Some(target) = &target {
318 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 if let Some(command) = &args.command {
342 command.validate()?;
343 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 None if interactive => {}
364 None => bail!("no ULCP radios found; name one with --port, --tcp, or --ble=SELECTOR"),
365 }
366 }
367
368 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 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 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 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 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 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 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 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 assert!(parse(&["send", KEY]).is_err());
592
593 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 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 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 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 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 assert!(refused(&["capture"]));
704 assert!(refused(&["manage", &key, "info"]));
705 assert!(refused(&["peer-repeaters", &key]));
706 assert!(refused(&["ping", &key]));
707 assert!(refused(&["send", &key, "hello"]));
710 assert!(refused(&["listen"]));
711 assert!(refused(&["discover"]));
712
713 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 let args = parse(&["info", "--node", &key]).unwrap();
750 assert_eq!(args.node.unwrap().0, [0xC4; 32]);
751
752 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 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 assert!(parse(&["ping", KEY, "--flood", "--route", "a1b2"]).is_err());
828 assert!(parse(&["ping", KEY, "--ack-only", "-s", "40"]).is_err());
830 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}