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 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 #[arg(
65 short = 'p',
66 long,
67 value_name = "PORT",
68 env = "UMSHCTL_PORT",
69 global = true
70 )]
71 port: Option<String>,
72
73 #[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 #[arg(long, global = true)]
93 pick: bool,
94
95 #[arg(long, default_value_t = 115_200, value_name = "N", global = true)]
97 baud: u32,
98
99 #[arg(long, global = true)]
101 trace: bool,
102
103 #[arg(long, global = true)]
106 no_save: bool,
107
108 #[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
126pub struct App {
130 pub session: Option<Session>,
131 pub prefs: Prefs,
132 pub interactive: bool,
135 pub trace: bool,
136 pub no_save: bool,
137 pub baud: u32,
138 pub discovery: Discovery,
141 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 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 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 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 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
246async 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 let target =
269 connection::discover(prefs, std::io::stdin().is_terminal(), args.discovery()).await?;
270 if let Some(target) = &target {
271 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 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 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 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 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 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 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 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 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}