1pub mod advert;
8pub mod capture;
9pub mod discover;
10pub mod duty;
11pub mod gnss;
12pub mod info;
13pub mod lifecycle;
14pub mod manage;
15pub mod message;
16pub mod phy;
17pub mod ping;
18pub mod props;
19pub mod repeater;
20pub mod routes_cmd;
21pub mod tables;
22pub mod time;
23pub mod values;
24
25use anyhow::{Result, bail};
26
27use umsh::ulcp::{FrameLink, UlcpDevice};
28use umsh::ulcp_wire::ids::prop;
29
30use crate::App;
31use crate::connection::{self, Target};
32use crate::output;
33use values::PinArg;
34
35#[derive(Debug, clap::Subcommand)]
36pub enum Command {
37 Info(info::InfoArgs),
40
41 Get {
47 #[arg(value_name = "PROP", required = true)]
48 keys: Vec<props::PropArg>,
49
50 #[arg(long)]
52 hex: bool,
53 },
54
55 Illuminance,
57
58 Set {
64 #[arg(value_name = "PROP")]
65 key: props::PropArg,
66
67 #[arg(value_name = "VALUE")]
68 value: String,
69
70 #[arg(long)]
72 hex: bool,
73 },
74
75 Name {
77 #[arg(value_name = "NEW-NAME")]
78 name: Option<String>,
79 },
80
81 Identity {
83 #[command(subcommand)]
84 op: Option<lifecycle::IdentityOp>,
85 },
86
87 Phy {
89 #[command(subcommand)]
90 op: Option<phy::PhyOp>,
91 },
92
93 Duty {
95 #[command(subcommand)]
96 op: Option<duty::DutyOp>,
97 },
98
99 Repeater {
101 #[command(subcommand)]
102 op: Option<repeater::RepeaterOp>,
103 },
104
105 Time {
107 #[command(subcommand)]
108 op: Option<time::TimeOp>,
109 },
110
111 Gnss {
113 #[command(subcommand)]
114 op: Option<gnss::GnssOp>,
115 },
116
117 Advert {
119 #[command(subcommand)]
120 op: Option<advert::AdvertOp>,
121 },
122
123 Alert {
126 #[command(subcommand)]
127 op: Option<lifecycle::AlertOp>,
128 },
129
130 Pin {
132 #[arg(value_name = "6-DIGITS|clear")]
133 value: PinArg,
134 },
135
136 DevChannel {
139 #[command(subcommand)]
140 op: Option<tables::TableOp>,
141 },
142
143 DevPeer {
145 #[command(subcommand)]
146 op: Option<tables::TableOp>,
147 },
148
149 DevAdmin {
151 #[command(subcommand)]
152 op: Option<tables::TableOp>,
153 },
154
155 Save,
157
158 Restore,
160
161 Clear,
163
164 Reset,
167
168 Reboot,
171
172 Ble {
174 #[command(subcommand)]
175 op: lifecycle::BleOp,
176 },
177
178 FactoryReset {
181 #[arg(long)]
183 yes: bool,
184 },
185
186 Manage {
189 #[arg(value_name = "KEY")]
191 target: values::KeyArg,
192
193 #[command(subcommand)]
194 op: manage::ManageOp,
195 },
196
197 PeerRepeaters {
200 #[arg(value_name = "KEY")]
202 target: values::KeyArg,
203 },
204
205 Ping(ping::PingArgs),
208
209 Discover(discover::DiscoverArgs),
214
215 Send(message::SendArgs),
217
218 Listen(message::ListenArgs),
220
221 Routes {
227 #[command(subcommand)]
228 op: Option<routes_cmd::RoutesOp>,
229 },
230
231 AdminKey,
233
234 Capture(capture::CaptureArgs),
237
238 BleScan {
243 #[arg(long, default_value_t = 2, value_name = "SECS")]
245 timeout: u64,
246 },
247
248 Default {
251 #[command(subcommand)]
252 op: Option<DefaultOp>,
253 },
254}
255
256#[derive(Debug, clap::Subcommand)]
257pub enum DefaultOp {
258 Show,
260 Set {
262 #[arg(value_name = "SELECTOR")]
263 selector: Option<String>,
264 },
265 Clear,
267}
268
269impl Command {
270 pub fn needs_device(&self) -> bool {
272 match self {
273 Self::BleScan { .. } => false,
274 Self::AdminKey => false,
277 Self::Routes { .. } => false,
280 Self::Default { op } => matches!(op, Some(DefaultOp::Set { selector: None })),
283 _ => true,
284 }
285 }
286
287 pub fn mesh_refusal(&self) -> Option<&'static str> {
297 match self {
298 Self::Capture(_) => Some(
302 "capture listens on the attached radio's own receiver, which a mesh session \
303 cannot reach; `disconnect` first",
304 ),
305 Self::Manage { .. }
308 | Self::PeerRepeaters { .. }
309 | Self::Ping(_)
310 | Self::Discover(_)
311 | Self::Send(_)
312 | Self::Listen(_) => Some(
313 "this session has already borrowed the radio; `disconnect` first, then reach \
314 the node from there",
315 ),
316 _ => None,
317 }
318 }
319
320 pub fn validate(&self) -> Result<()> {
327 match self {
328 Self::Capture(args) => args.validate(),
329 Self::Set { key, value, .. } => props::encode_value(key.0, value).map(drop),
333 _ => Ok(()),
334 }
335 }
336
337 pub async fn run(self, app: &mut App) -> Result<()> {
338 if app
339 .session
340 .as_ref()
341 .is_some_and(|session| session.is_mesh())
342 && let Some(refusal) = self.mesh_refusal()
343 {
344 bail!("{refusal}");
345 }
346 match self {
347 Self::Info(args) => info::run(app.device()?, args).await,
348 Self::Identity { op } => lifecycle::identity(app.device()?, op).await,
349 Self::Name { name } => lifecycle::name(app, name).await,
350 Self::Save => lifecycle::save(app.device()?).await,
351 Self::Restore => lifecycle::restore(app.device()?).await,
352 Self::Clear => lifecycle::clear(app.device()?).await,
353 Self::Reset => lifecycle::reset(app.device()?).await,
354 Self::Reboot => lifecycle::reboot(app).await,
355 Self::Ble { op } => lifecycle::ble(app, op).await,
356 Self::FactoryReset { yes } => lifecycle::factory_reset(app, yes).await,
357 Self::Pin { value } => lifecycle::pin(app.device()?, value).await,
358 Self::Phy { op } => phy::run(app, op).await,
359 Self::Duty { op } => duty::run(app, op).await,
360 Self::Repeater { op } => repeater::run(app, op).await,
361 Self::Time { op } => time::run(app, op).await,
362 Self::Gnss { op } => gnss::run(app, op).await,
363 Self::Advert { op } => advert::run(app, op).await,
364 Self::DevChannel { op } => {
365 tables::run(app, prop::DEV_CHANNEL_KEYS, "channel", op).await
366 }
367 Self::DevPeer { op } => tables::run(app, prop::DEV_PEERS, "peer", op).await,
368 Self::DevAdmin { op } => tables::run(app, prop::DEV_ADMINS, "administrator", op).await,
369 Self::Manage { target, op } => {
370 manage::run(app, target, manage::Operation::Manage(op)).await
371 }
372 Self::PeerRepeaters { target } => {
373 manage::run(app, target, manage::Operation::PeerRepeaters).await
374 }
375 Self::Ping(args) => {
376 let target = args.target;
377 manage::run(app, target, manage::Operation::Ping(args)).await
378 }
379 Self::Discover(args) => discover::discover(app, args).await,
380 Self::Send(args) => message::send(app, args).await,
381 Self::Listen(args) => message::listen(app, args).await,
382 Self::Routes { op } => routes_cmd::run(op),
383 Self::AdminKey => crate::mesh::show_admin_key(),
384 Self::Get { keys, hex } => props::get(app.device()?, &keys, hex).await,
385 Self::Set { key, value, hex } => {
386 let no_save = app.no_save;
387 let device = app.device()?;
388 props::set(device, key, &value, hex).await?;
389 persist(device, no_save).await
390 }
391 Self::Illuminance => info::illuminance(app.device()?).await,
392 Self::Alert { op } => lifecycle::alert(app.device()?, op).await,
393 Self::Capture(args) => capture::run(app, args).await,
394 Self::BleScan { timeout } => ble_scan(app, timeout).await,
395 Self::Default { op } => default(app, op.unwrap_or(DefaultOp::Show)),
396 }
397 }
398}
399
400async fn ble_scan(app: &mut App, timeout: u64) -> Result<()> {
401 println!("scanning for ULCP radios ({timeout} s) ...");
402 let found = connection::scan(std::time::Duration::from_secs(timeout)).await?;
403 connection::render_found(&found);
404 app.last_scan = found;
406 Ok(())
407}
408
409fn default(app: &mut App, op: DefaultOp) -> Result<()> {
410 match op {
411 DefaultOp::Show => {
412 match &app.prefs.default_device {
413 Some(device) => output::field("default", device.display()),
414 None => println!("no default radio saved"),
415 }
416 if let Some(path) = connection::config_path() {
417 output::field("settings", path.display());
418 }
419 Ok(())
420 }
421 DefaultOp::Set { selector } => {
422 let device = match selector {
423 Some(selector) => connection::DefaultDevice {
424 selector,
425 name: None,
426 },
427 None => {
428 let session = app.session()?;
429 match &session.target {
430 Target::Ble { selector, .. } => connection::DefaultDevice {
431 selector: selector.clone(),
432 name: Some(session.label.clone()),
433 },
434 Target::Serial { .. } => bail!(
435 "the saved default is a BLE radio: serial port names change between \
436 plug-ins, so name one with --port or set UMSHCTL_PORT instead"
437 ),
438 Target::Tcp { .. } => bail!(
439 "the saved default is a BLE radio: a bridged radio is already named \
440 by its endpoint, so give it with --tcp or set UMSHCTL_TCP instead"
441 ),
442 Target::Mesh { .. } => bail!(
443 "the saved default is a radio to attach to, not a node to manage: \
444 `disconnect` first, then save the radio underneath"
445 ),
446 }
447 }
448 };
449 app.prefs.default_device = Some(device.clone());
450 let path = app.prefs.store()?;
451 println!("default radio set to {}", device.display());
452 output::field("settings", path.display());
453 Ok(())
454 }
455 DefaultOp::Clear => {
456 app.prefs.default_device = None;
457 app.prefs.store()?;
458 println!("default radio cleared");
459 Ok(())
460 }
461 }
462}
463
464pub async fn persist<L: FrameLink>(device: &mut UlcpDevice<L>, no_save: bool) -> Result<()> {
471 if no_save {
472 output::note("--no-save — changes are live only; the save command persists them");
473 } else {
474 device.save().await?;
475 println!("saved: changes persist across reboots");
476 }
477 Ok(())
478}
479
480fn decode_u16(value: &[u8]) -> Option<u16> {
481 <[u8; 2]>::try_from(value).ok().map(u16::from_le_bytes)
482}
483
484fn decode_u32(value: &[u8]) -> Option<u32> {
485 <[u8; 4]>::try_from(value).ok().map(u32::from_le_bytes)
486}
487
488fn duty_percent(raw: u16) -> f64 {
490 f64::from(raw) * 100.0 / 65535.0
491}
492
493fn format_duration(seconds: u32) -> String {
500 const MINUTE: u32 = 60;
501 const HOUR: u32 = 60 * MINUTE;
502 const DAY: u32 = 24 * HOUR;
503 match seconds {
504 s if s >= DAY && s % DAY == 0 => format!("{}d", s / DAY),
505 s if s >= DAY => format!("{}d{}h", s / DAY, (s % DAY) / HOUR),
506 s if s >= HOUR && s % HOUR == 0 => format!("{}h", s / HOUR),
507 s if s >= HOUR => format!("{}h{}m", s / HOUR, (s % HOUR) / MINUTE),
508 s if s >= MINUTE && s % MINUTE == 0 => format!("{}m", s / MINUTE),
509 s if s >= MINUTE => format!("{}m{}s", s / MINUTE, s % MINUTE),
510 s => format!("{s}s"),
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::format_duration;
517
518 #[test]
519 fn a_duration_reads_at_human_scale_to_two_units() {
520 assert_eq!(format_duration(0), "0s");
521 assert_eq!(format_duration(45), "45s");
522 assert_eq!(format_duration(1200), "20m");
523 assert_eq!(format_duration(90), "1m30s");
524 assert_eq!(format_duration(3600), "1h");
525 assert_eq!(format_duration(5400), "1h30m");
526 assert_eq!(format_duration(14400), "4h");
527 assert_eq!(format_duration(86400), "1d");
529 assert_eq!(format_duration(1_058_400), "12d6h");
530 assert_eq!(format_duration(172_800), "2d");
532 }
533}