umshctl/command/
lifecycle.rs

1//! State lifecycle and one-off device settings: `save`, `restore`,
2//! `clear`, `reset`, `factory-reset`, `pin`, `ble`, `identity`, `name`,
3//! and `alert`.
4
5use anyhow::{Result, bail};
6
7use umsh::core::PublicKey;
8use umsh::ulcp::{FrameLink, UlcpDevice};
9use umsh::ulcp_wire::alert::AlertState;
10use umsh::ulcp_wire::ids::prop;
11
12use super::persist;
13use super::values::PinArg;
14use crate::App;
15use crate::connection::confirm;
16use crate::output::field;
17
18#[derive(Debug, clap::Subcommand)]
19pub enum IdentityOp {
20    /// Print the device identity public key.
21    Show,
22    /// Generate a device identity if none exists.
23    Generate,
24}
25
26#[derive(Debug, clap::Subcommand)]
27pub enum BleOp {
28    /// Report how many hosts are paired with this device.
29    Bonds,
30    /// Open or close the pairing window.
31    Pair {
32        /// The window state to set.
33        #[arg(value_enum, default_value_t = PairState::On)]
34        state: PairState,
35    },
36    /// Forget every paired host, the pairing PIN, and the pairing
37    /// lockout, then open a pairing window.
38    Clear {
39        /// Confirm the wipe. Required outside the REPL, which asks.
40        #[arg(long)]
41        yes: bool,
42    },
43}
44
45/// Whether the pairing window should be open. The window is a state,
46/// not an act — `PROP_BLE_PAIRING` — so it can be set in either
47/// direction and read back.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
49pub enum PairState {
50    On,
51    Off,
52}
53
54#[derive(Debug, clap::Subcommand)]
55pub enum AlertOp {
56    /// Print the locate-alert state.
57    Show,
58    /// Make the radio conspicuous so it can be found.
59    #[command(alias = "on", alias = "find")]
60    Locate,
61    /// Stop the locate alert.
62    #[command(name = "none", alias = "off", alias = "stop")]
63    None,
64}
65
66pub async fn save<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
67    device.save().await?;
68    println!("saved: live state persists across reboots");
69    Ok(())
70}
71
72pub async fn restore<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
73    let completion = device.restore().await?;
74    println!("restored live state from the saved snapshot ({completion:?} form)");
75    Ok(())
76}
77
78pub async fn clear<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
79    device.clear().await?;
80    println!(
81        "cleared: persisted state erased; live state keeps running until reboot \
82         (BLE bonds and pairing PIN are retained)"
83    );
84    Ok(())
85}
86
87pub async fn reset<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
88    let status = device.reset().await?;
89    println!("reset complete ({status:?})");
90    Ok(())
91}
92
93/// Restart the device, keeping everything it has persisted.
94///
95/// No confirmation: unlike a factory reset this destroys nothing, and the
96/// device comes back as itself. The handle is worthless afterward either
97/// way, so detach for the same reason `factory_reset` does.
98pub async fn reboot(app: &mut App) -> Result<()> {
99    let device = app.device()?;
100    if !device.reboot().await? {
101        bail!("this device does not advertise CAP_REBOOT; it cannot restart on command");
102    }
103    println!("reboot sent; the radio is restarting and the link will drop");
104    app.detach().await;
105    Ok(())
106}
107
108const FACTORY_RESET_WARNING: &str = "factory-reset erases ALL mutable state — persisted provisioning, the device identity, \
109     BLE bonds, and the pairing PIN — then reboots";
110
111pub async fn factory_reset(app: &mut App, yes: bool) -> Result<()> {
112    if !yes {
113        if !app.interactive {
114            bail!("{FACTORY_RESET_WARNING}; re-run with --yes to confirm");
115        }
116        println!("{FACTORY_RESET_WARNING}.");
117        if !confirm("erase everything and reboot?")? {
118            println!("cancelled");
119            return Ok(());
120        }
121    }
122    let device = app.device()?;
123    device.factory_reset().await?;
124    println!(
125        "factory reset sent; the radio is erasing ALL state (provisioning, device identity, \
126         BLE bonds, pairing PIN) and rebooting. The link will drop; re-pair to use it again."
127    );
128    // The device is rebooting, so the handle is worthless. Say so once
129    // rather than letting the next command fail obscurely. Over the mesh
130    // this also ends the session, which hands the borrowed radio back.
131    app.detach().await;
132    Ok(())
133}
134
135pub async fn pin<L: FrameLink>(device: &mut UlcpDevice<L>, value: PinArg) -> Result<()> {
136    device.set_ble_pairing_pin(value.0).await?;
137    match value.0 {
138        Some(_) => println!("BLE pairing PIN set (persisted; applies to new pairings)"),
139        None => println!("BLE pairing PIN cleared"),
140    }
141    Ok(())
142}
143
144const CLEAR_BONDS_WARNING: &str = "ble clear forgets every paired host, the pairing PIN, and the pairing lockout; \
145     the device then opens a pairing window";
146
147/// Read or manage the device's Bluetooth bonds.
148///
149/// Over Bluetooth, clearing severs this very link — the bond that
150/// carried the command is one of the bonds deleted — so the handle is
151/// detached afterward for the same reason `reboot` detaches. Over a
152/// cable or the mesh nothing is disturbed, but detaching costs only a
153/// reattach and keeps one rule instead of two.
154pub async fn ble(app: &mut App, op: BleOp) -> Result<()> {
155    match op {
156        BleOp::Bonds => {
157            let value = app.device()?.get_prop(prop::BLE_BOND_COUNT).await?;
158            let [count] = value[..] else {
159                bail!("malformed PROP_BLE_BOND_COUNT");
160            };
161            field("paired hosts", count);
162            Ok(())
163        }
164        BleOp::Pair { state } => {
165            let open = state == PairState::On;
166            if app.device()?.set_ble_pairing(open).await?.is_none() {
167                bail!("this device does not advertise CAP_BLE; it has no Bluetooth transport");
168            }
169            if open {
170                println!("pairing window open; pair from the other host now");
171            } else {
172                println!("pairing window closed");
173            }
174            Ok(())
175        }
176        BleOp::Clear { yes } => {
177            if !yes {
178                if !app.interactive {
179                    bail!("{CLEAR_BONDS_WARNING}; re-run with --yes to confirm");
180                }
181                println!("{CLEAR_BONDS_WARNING}.");
182                if !confirm("forget every paired host?")? {
183                    println!("cancelled");
184                    return Ok(());
185                }
186            }
187            if !app.device()?.ble_clear_bonds().await? {
188                bail!("this device does not advertise CAP_BLE; it has no Bluetooth transport");
189            }
190            println!("bonds cleared; the device is in a pairing window");
191            app.detach().await;
192            Ok(())
193        }
194    }
195}
196
197pub async fn identity<L: FrameLink>(
198    device: &mut UlcpDevice<L>,
199    op: Option<IdentityOp>,
200) -> Result<()> {
201    match op.unwrap_or(IdentityOp::Show) {
202        IdentityOp::Show => {
203            let value = device.get_prop(prop::DEV_KEY).await?;
204            match <[u8; 32]>::try_from(value.as_slice()) {
205                Ok(key) => field("device identity", PublicKey(key)),
206                Err(_) if value.is_empty() => {
207                    println!("no device identity configured (run `identity generate`)");
208                }
209                Err(_) => bail!("malformed PROP_DEV_KEY"),
210            }
211            Ok(())
212        }
213        IdentityOp::Generate => {
214            let value = device.get_prop(prop::DEV_KEY).await?;
215            if let Ok(key) = <[u8; 32]>::try_from(value.as_slice()) {
216                println!("device identity already exists: {}", PublicKey(key));
217                println!("(identities are never regenerated in place; factory-reset discards one)");
218                return Ok(());
219            }
220            let key = device.ensure_device_identity().await?;
221            println!("generated device identity: {}", PublicKey(key));
222            println!("(persisted immediately; device identities are independent of save/restore)");
223            Ok(())
224        }
225    }
226}
227
228pub async fn name(app: &mut App, name: Option<String>) -> Result<()> {
229    let no_save = app.no_save;
230    let Some(name) = name else {
231        let device = app.device()?;
232        let current = device.device_name().await?;
233        field("device name", format!("{current:?}"));
234        return Ok(());
235    };
236    let device = app.device()?;
237    device.set_device_name(&name).await?;
238    println!("device name set to {name:?}");
239    persist(device, no_save).await?;
240    // The prompt follows the device, so it has to follow a rename.
241    app.rename(name);
242    Ok(())
243}
244
245/// Report or drive the locate alert (`PROP_ALERT`).
246///
247/// Deliberately outside the auto-save path: the alert is live behavior
248/// that no snapshot carries, so there is nothing to persist.
249pub async fn alert<L: FrameLink>(device: &mut UlcpDevice<L>, op: Option<AlertOp>) -> Result<()> {
250    let desired = match op.unwrap_or(AlertOp::Show) {
251        AlertOp::Show => {
252            match device.alert().await? {
253                Some(state) => field("alert", display(state)),
254                None => field("alert", "unsupported (no CAP_ALERT)"),
255            }
256            return Ok(());
257        }
258        AlertOp::Locate => AlertState::Locate,
259        AlertOp::None => AlertState::None,
260    };
261    match device.set_alert(desired).await? {
262        AlertState::Locate => println!(
263            "locate alert started. It stops when you send `alert none`, when someone \
264             cancels it at the radio, or when the radio's own deadline expires — \
265             re-send `alert locate` to keep it going."
266        ),
267        AlertState::None => println!("locate alert stopped"),
268    }
269    Ok(())
270}
271
272/// Human-readable rendering of a `PROP_ALERT` state.
273fn display(state: AlertState) -> &'static str {
274    match state {
275        AlertState::None => "none",
276        AlertState::Locate => "locate (the radio is making itself conspicuous)",
277    }
278}