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 duty;
10pub mod gnss;
11pub mod info;
12pub mod lifecycle;
13pub mod phy;
14pub mod provision;
15pub mod repeater;
16pub mod tables;
17pub mod time;
18pub mod values;
19
20use anyhow::{Result, bail};
21
22use umsh::ulcp::{FrameLink, UlcpDevice};
23use umsh::ulcp_wire::ids::prop;
24
25use crate::App;
26use crate::connection::{self, Target};
27use crate::output;
28use values::PinArg;
29
30#[derive(Debug, clap::Subcommand)]
31pub enum Command {
32    /// Print capabilities, ownership, PHY state, and provisioning
33    /// digests. Changes nothing.
34    Info(info::InfoArgs),
35
36    /// Establish host provisioning: keys, filters, and the delegation
37    /// policy for the host that will tether to this device.
38    Provision(provision::ProvisionArgs),
39
40    /// Show or generate the device identity public key.
41    Identity {
42        #[command(subcommand)]
43        op: Option<lifecycle::IdentityOp>,
44    },
45
46    /// Show or set the human-readable device name.
47    Name {
48        #[arg(value_name = "NEW-NAME")]
49        name: Option<String>,
50    },
51
52    /// Persist live state across reboots (CMD_SAVE).
53    Save,
54
55    /// Revert live state to the saved snapshot.
56    Restore,
57
58    /// Erase persisted state; live state keeps running.
59    Clear,
60
61    /// Protocol reset (CMD_RST): state returns to its post-reset values,
62    /// restoring any saved snapshot. The MCU does not reboot.
63    Reset,
64
65    /// Erase ALL state including BLE bonds and the pairing PIN, then
66    /// reboot into a blank factory state.
67    FactoryReset {
68        /// Confirm the wipe. Required outside the REPL, which asks.
69        #[arg(long)]
70        yes: bool,
71    },
72
73    /// Set or clear the persisted BLE pairing PIN.
74    Pin {
75        #[arg(value_name = "6-DIGITS|clear")]
76        value: PinArg,
77    },
78
79    /// Show or set the PHY enable state and LoRa parameters.
80    Phy {
81        #[command(subcommand)]
82        op: Option<phy::PhyOp>,
83    },
84
85    /// Show duty-cycle usage, or bound it.
86    Duty {
87        #[command(subcommand)]
88        op: Option<duty::DutyOp>,
89    },
90
91    /// Show or set the autonomous repeater forwarding policy.
92    Repeater {
93        #[command(subcommand)]
94        op: Option<repeater::RepeaterOp>,
95    },
96
97    /// Show or set the device's wall clock and time zone.
98    Time {
99        #[command(subcommand)]
100        op: Option<time::TimeOp>,
101    },
102
103    /// Show or set the GNSS receiver and what is done with its fixes.
104    Gnss {
105        #[command(subcommand)]
106        op: Option<gnss::GnssOp>,
107    },
108
109    /// Show or set what the device announces on its own schedule.
110    Advert {
111        #[command(subcommand)]
112        op: Option<advert::AdvertOp>,
113    },
114
115    /// Device-identity channel keys: the multicast this device's own
116    /// node joins.
117    DevChannel {
118        #[command(subcommand)]
119        op: Option<tables::TableOp>,
120    },
121
122    /// Device-identity peer public keys.
123    DevPeer {
124        #[command(subcommand)]
125        op: Option<tables::TableOp>,
126    },
127
128    /// Take one ambient light reading.
129    Illuminance,
130
131    /// Show or drive the locate alert: make the radio conspicuous so it
132    /// can be found.
133    Alert {
134        #[command(subcommand)]
135        op: Option<lifecycle::AlertOp>,
136    },
137
138    /// Listen on the device's radio, decoding frames and optionally
139    /// writing a Wireshark-compatible capture.
140    Capture(capture::CaptureArgs),
141
142    /// List nearby ULCP radios without connecting.
143    Scan {
144        /// Seconds to listen.
145        #[arg(long, default_value_t = 2, value_name = "SECS")]
146        timeout: u64,
147    },
148
149    /// Show, set, or clear the radio this tool reaches for when the
150    /// command line names none.
151    Default {
152        #[command(subcommand)]
153        op: Option<DefaultOp>,
154    },
155}
156
157#[derive(Debug, clap::Subcommand)]
158pub enum DefaultOp {
159    /// Print the saved default radio.
160    Show,
161    /// Save a default radio. With no selector, saves the attached one.
162    Set {
163        #[arg(value_name = "SELECTOR")]
164        selector: Option<String>,
165    },
166    /// Forget the saved default radio.
167    Clear,
168}
169
170impl Command {
171    /// Whether this command needs an attached device.
172    pub fn needs_device(&self) -> bool {
173        match self {
174            Self::Scan { .. } => false,
175            // Saving the attached radio as the default needs one; naming
176            // a selector outright does not.
177            Self::Default { op } => matches!(op, Some(DefaultOp::Set { selector: None })),
178            _ => true,
179        }
180    }
181
182    /// Whether this command needs a tethered attach rather than the
183    /// administrative one everything else uses.
184    pub fn needs_tethered(&self) -> bool {
185        matches!(self, Self::Provision(_))
186    }
187
188    /// Check whatever clap's grammar cannot — combinations of flags,
189    /// and the provisioning file — *before* a device is opened.
190    ///
191    /// Connecting takes seconds over BLE and disturbs a radio that was
192    /// minding its own business; an argument mistake should cost
193    /// neither.
194    pub fn validate(&self) -> Result<()> {
195        match self {
196            Self::Capture(args) => args.validate(),
197            Self::Provision(args) => args.desired().map(drop),
198            _ => Ok(()),
199        }
200    }
201
202    pub async fn run(self, app: &mut App) -> Result<()> {
203        match self {
204            Self::Info(args) => info::run(app.device()?, args).await,
205            Self::Provision(args) => provision::run(app, args).await,
206            Self::Identity { op } => lifecycle::identity(app.device()?, op).await,
207            Self::Name { name } => lifecycle::name(app, name).await,
208            Self::Save => lifecycle::save(app.device()?).await,
209            Self::Restore => lifecycle::restore(app.device()?).await,
210            Self::Clear => lifecycle::clear(app.device()?).await,
211            Self::Reset => lifecycle::reset(app.device()?).await,
212            Self::FactoryReset { yes } => lifecycle::factory_reset(app, yes).await,
213            Self::Pin { value } => lifecycle::pin(app.device()?, value).await,
214            Self::Phy { op } => phy::run(app, op).await,
215            Self::Duty { op } => duty::run(app, op).await,
216            Self::Repeater { op } => repeater::run(app, op).await,
217            Self::Time { op } => time::run(app, op).await,
218            Self::Gnss { op } => gnss::run(app, op).await,
219            Self::Advert { op } => advert::run(app, op).await,
220            Self::DevChannel { op } => {
221                tables::run(app, prop::DEV_CHANNEL_KEYS, "channel", op).await
222            }
223            Self::DevPeer { op } => tables::run(app, prop::DEV_PEERS, "peer", op).await,
224            Self::Illuminance => info::illuminance(app.device()?).await,
225            Self::Alert { op } => lifecycle::alert(app.device()?, op).await,
226            Self::Capture(args) => capture::run(app, args).await,
227            Self::Scan { timeout } => scan(app, timeout).await,
228            Self::Default { op } => default(app, op.unwrap_or(DefaultOp::Show)),
229        }
230    }
231}
232
233async fn scan(app: &mut App, timeout: u64) -> Result<()> {
234    println!("scanning for ULCP radios ({timeout} s) ...");
235    let found = connection::scan(std::time::Duration::from_secs(timeout)).await?;
236    connection::render_found(&found);
237    // Retained so `connect <N>` can refer to this listing by number.
238    app.last_scan = found;
239    Ok(())
240}
241
242fn default(app: &mut App, op: DefaultOp) -> Result<()> {
243    match op {
244        DefaultOp::Show => {
245            match &app.prefs.default_device {
246                Some(device) => output::field("default", device.display()),
247                None => println!("no default radio saved"),
248            }
249            if let Some(path) = connection::config_path() {
250                output::field("settings", path.display());
251            }
252            Ok(())
253        }
254        DefaultOp::Set { selector } => {
255            let device = match selector {
256                Some(selector) => connection::DefaultDevice {
257                    selector,
258                    name: None,
259                },
260                None => {
261                    let session = app.session()?;
262                    match &session.target {
263                        Target::Ble { selector, .. } => connection::DefaultDevice {
264                            selector: selector.clone(),
265                            name: Some(session.label.clone()),
266                        },
267                        Target::Serial { .. } => bail!(
268                            "the saved default is a BLE radio: serial port names change between \
269                             plug-ins, so name one with --port or set UMSHCTL_PORT instead"
270                        ),
271                    }
272                }
273            };
274            app.prefs.default_device = Some(device.clone());
275            let path = app.prefs.store()?;
276            println!("default radio set to {}", device.display());
277            output::field("settings", path.display());
278            Ok(())
279        }
280        DefaultOp::Clear => {
281            app.prefs.default_device = None;
282            app.prefs.store()?;
283            println!("default radio cleared");
284            Ok(())
285        }
286    }
287}
288
289/// Finish a mutating command: persist by default, or report the
290/// live-only state under `--no-save`.
291///
292/// Both modes behave the same. A REPL session killed by a dropped link
293/// would otherwise silently lose everything since the last manual
294/// `save`, and the device treats saving as cheap.
295pub async fn persist<L: FrameLink>(device: &mut UlcpDevice<L>, no_save: bool) -> Result<()> {
296    if no_save {
297        output::note("--no-save — changes are live only; the save command persists them");
298    } else {
299        device.save().await?;
300        println!("saved: changes persist across reboots");
301    }
302    Ok(())
303}
304
305fn decode_u16(value: &[u8]) -> Option<u16> {
306    <[u8; 2]>::try_from(value).ok().map(u16::from_le_bytes)
307}
308
309fn decode_u32(value: &[u8]) -> Option<u32> {
310    <[u8; 4]>::try_from(value).ok().map(u32::from_le_bytes)
311}
312
313/// Percentage of the hour a raw 0-65535 duty figure represents.
314fn duty_percent(raw: u16) -> f64 {
315    f64::from(raw) * 100.0 / 65535.0
316}