umshctl/command/
mod.rs

1//! The command tree.
2//!
3//! One `clap` `Subcommand` enum serves four surfaces: the one-shot argv
4//! grammar, the REPL line grammar, `--help` at every level, and the
5//! completion tree the REPL's tab handler walks.
6
7pub 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    /// Report what the device says about itself, whole or by topic.
38    /// Changes nothing.
39    Info(info::InfoArgs),
40
41    /// Read properties by name or number.
42    ///
43    /// Names are the spec mnemonics without their prefix
44    /// (`phy-freq`, `dev-name`); numbers are decimal or `0x`-prefixed.
45    /// Several are read in one exchange where the device supports it.
46    Get {
47        #[arg(value_name = "PROP", required = true)]
48        keys: Vec<props::PropArg>,
49
50        /// Print values as raw octets rather than reading them.
51        #[arg(long)]
52        hex: bool,
53    },
54
55    /// Take one ambient light reading.
56    Illuminance,
57
58    /// Write one property by name or number.
59    ///
60    /// The value is written the way `get` reads it back: `on`/`off` for
61    /// a switch, a decimal number for a count, an address for a key. A
62    /// property whose shape is a structure takes hex.
63    Set {
64        #[arg(value_name = "PROP")]
65        key: props::PropArg,
66
67        #[arg(value_name = "VALUE")]
68        value: String,
69
70        /// Print the stored value as raw octets.
71        #[arg(long)]
72        hex: bool,
73    },
74
75    /// Show or set the human-readable device name.
76    Name {
77        #[arg(value_name = "NEW-NAME")]
78        name: Option<String>,
79    },
80
81    /// Show or generate the device identity public key.
82    Identity {
83        #[command(subcommand)]
84        op: Option<lifecycle::IdentityOp>,
85    },
86
87    /// Show or set the PHY enable state and LoRa parameters.
88    Phy {
89        #[command(subcommand)]
90        op: Option<phy::PhyOp>,
91    },
92
93    /// Show duty-cycle usage, or bound it.
94    Duty {
95        #[command(subcommand)]
96        op: Option<duty::DutyOp>,
97    },
98
99    /// Show or set the autonomous repeater forwarding policy.
100    Repeater {
101        #[command(subcommand)]
102        op: Option<repeater::RepeaterOp>,
103    },
104
105    /// Show or set the device's wall clock and time zone.
106    Time {
107        #[command(subcommand)]
108        op: Option<time::TimeOp>,
109    },
110
111    /// Show or set the GNSS receiver and what is done with its fixes.
112    Gnss {
113        #[command(subcommand)]
114        op: Option<gnss::GnssOp>,
115    },
116
117    /// Show or set what the device announces on its own schedule.
118    Advert {
119        #[command(subcommand)]
120        op: Option<advert::AdvertOp>,
121    },
122
123    /// Show or drive the locate alert: make the radio conspicuous so it
124    /// can be found.
125    Alert {
126        #[command(subcommand)]
127        op: Option<lifecycle::AlertOp>,
128    },
129
130    /// Set or clear the persisted BLE pairing PIN.
131    Pin {
132        #[arg(value_name = "6-DIGITS|clear")]
133        value: PinArg,
134    },
135
136    /// Device-identity channel keys: the multicast this device's own
137    /// node joins.
138    DevChannel {
139        #[command(subcommand)]
140        op: Option<tables::TableOp>,
141    },
142
143    /// Device-identity peer public keys.
144    DevPeer {
145        #[command(subcommand)]
146        op: Option<tables::TableOp>,
147    },
148
149    /// Nodes authorized to manage this device over the mesh.
150    DevAdmin {
151        #[command(subcommand)]
152        op: Option<tables::TableOp>,
153    },
154
155    /// Persist live state across reboots (CMD_SAVE).
156    Save,
157
158    /// Revert live state to the saved snapshot.
159    Restore,
160
161    /// Erase persisted state; live state keeps running.
162    Clear,
163
164    /// Protocol reset (CMD_RST): state returns to its post-reset values,
165    /// restoring any saved snapshot. The MCU does not reboot.
166    Reset,
167
168    /// Restart the device (CMD_REBOOT): a power cycle that keeps
169    /// everything the device has persisted.
170    Reboot,
171
172    /// Manage the device's Bluetooth bonds (CAP_BLE).
173    Ble {
174        #[command(subcommand)]
175        op: lifecycle::BleOp,
176    },
177
178    /// Erase ALL state including BLE bonds and the pairing PIN, then
179    /// reboot into a blank factory state.
180    FactoryReset {
181        /// Confirm the wipe. Required outside the REPL, which asks.
182        #[arg(long)]
183        yes: bool,
184    },
185
186    /// Administer another device over the mesh, using the attached radio
187    /// to reach it.
188    Manage {
189        /// The device to manage, as its node public key.
190        #[arg(value_name = "KEY")]
191        target: values::KeyArg,
192
193        #[command(subcommand)]
194        op: manage::ManageOp,
195    },
196
197    /// Ask a repeater which repeaters it knows of, using the attached
198    /// radio to reach it.
199    PeerRepeaters {
200        /// The repeater to ask, as its node public key.
201        #[arg(value_name = "KEY")]
202        target: values::KeyArg,
203    },
204
205    /// Measure the path to another node: reachability, round-trip time,
206    /// hops, and signal. Needs no authorization from the far end.
207    Ping(ping::PingArgs),
208
209    /// Find out which nodes are within reach of this radio.
210    ///
211    /// Asks every node in earshot to identify itself, then listens for
212    /// the answers and for whatever advertises itself unprompted.
213    Discover(discover::DiscoverArgs),
214
215    /// Send a text message to a node, as this tool's own identity.
216    Send(message::SendArgs),
217
218    /// Print text messages addressed to this tool, until interrupted.
219    Listen(message::ListenArgs),
220
221    /// Show or forget the routes this tool has learned to other nodes.
222    ///
223    /// Learned from replies and remembered between invocations, so a
224    /// script does not re-flood the mesh for a path it was told a second
225    /// ago. Needs no radio.
226    Routes {
227        #[command(subcommand)]
228        op: Option<routes_cmd::RoutesOp>,
229    },
230
231    /// Show the administrator identity this tool manages devices with.
232    AdminKey,
233
234    /// Listen on the device's radio, decoding frames and optionally
235    /// writing a Wireshark-compatible capture.
236    Capture(capture::CaptureArgs),
237
238    /// List nearby ULCP radios over BLE without connecting.
239    ///
240    /// This finds radios to attach *this tool* to. `discover` is the one
241    /// that finds nodes out on the mesh.
242    BleScan {
243        /// Seconds to listen.
244        #[arg(long, default_value_t = 2, value_name = "SECS")]
245        timeout: u64,
246    },
247
248    /// Show, set, or clear the radio this tool reaches for when the
249    /// command line names none.
250    Default {
251        #[command(subcommand)]
252        op: Option<DefaultOp>,
253    },
254}
255
256#[derive(Debug, clap::Subcommand)]
257pub enum DefaultOp {
258    /// Print the saved default radio.
259    Show,
260    /// Save a default radio. With no selector, saves the attached one.
261    Set {
262        #[arg(value_name = "SELECTOR")]
263        selector: Option<String>,
264    },
265    /// Forget the saved default radio.
266    Clear,
267}
268
269impl Command {
270    /// Whether this command needs an attached device.
271    pub fn needs_device(&self) -> bool {
272        match self {
273            Self::BleScan { .. } => false,
274            // The administrator identity is this tool's own; no radio is
275            // involved in reading it out.
276            Self::AdminKey => false,
277            // Learned routes are this tool's own notes, and reading them
278            // is exactly what you want to do with nothing attached.
279            Self::Routes { .. } => false,
280            // Saving the attached radio as the default needs one; naming
281            // a selector outright does not.
282            Self::Default { op } => matches!(op, Some(DefaultOp::Set { selector: None })),
283            _ => true,
284        }
285    }
286
287    /// Why this command cannot run against a device reached over the
288    /// mesh, if it cannot.
289    ///
290    /// Most of the tool works unchanged over a mesh session: the handle
291    /// is an ordinary one and the properties behind it are the same. The
292    /// exceptions are the commands that need something the Node
293    /// Management binding does not carry, and the ones that would want
294    /// the radio this session has already borrowed. Refusing them here
295    /// beats letting each fail in its own way somewhere over the air.
296    pub fn mesh_refusal(&self) -> Option<&'static str> {
297        match self {
298            // Captured frames arrive as unsolicited stream traffic, and
299            // the binding carries nothing unsolicited — there is no
300            // remote form of this to reach for.
301            Self::Capture(_) => Some(
302                "capture listens on the attached radio's own receiver, which a mesh session \
303                 cannot reach; `disconnect` first",
304            ),
305            // Each of these becomes a node on the mesh, and this session
306            // is already using the only radio there is.
307            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    /// Check whatever clap's grammar cannot — combinations of flags —
321    /// *before* a device is opened.
322    ///
323    /// Connecting takes seconds over BLE and disturbs a radio that was
324    /// minding its own business; an argument mistake should cost
325    /// neither.
326    pub fn validate(&self) -> Result<()> {
327        match self {
328            Self::Capture(args) => args.validate(),
329            // A value the property cannot hold is a typing mistake, and
330            // finding out after a BLE discovery pass and a handshake is
331            // no way to learn it.
332            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    // Retained so `connect <N>` can refer to this listing by number.
405    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
464/// Finish a mutating command: persist by default, or report the
465/// live-only state under `--no-save`.
466///
467/// Both modes behave the same. A REPL session killed by a dropped link
468/// would otherwise silently lose everything since the last manual
469/// `save`, and the device treats saving as cheap.
470pub 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
488/// Percentage of the hour a raw 0-65535 duty figure represents.
489fn duty_percent(raw: u16) -> f64 {
490    f64::from(raw) * 100.0 / 65535.0
491}
492
493/// A span of seconds at human scale, to at most two units: `45s`, `20m`,
494/// `1h30m`, `12d6h`.
495///
496/// Two units is the point where more precision stops helping — nobody
497/// reading an uptime of `12d6h` wanted the seconds. Days matter because
498/// this also renders uptimes, where hour counts run into the hundreds.
499fn 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        // Days, which is where uptimes live and advert intervals do not.
528        assert_eq!(format_duration(86400), "1d");
529        assert_eq!(format_duration(1_058_400), "12d6h");
530        // A day boundary with no leftover hours still reads as whole days.
531        assert_eq!(format_duration(172_800), "2d");
532    }
533}