umshctl/command/
lifecycle.rs

1//! State lifecycle and one-off device settings: `save`, `restore`,
2//! `clear`, `reset`, `factory-reset`, `pin`, `identity`, `name`, and
3//! `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 AlertOp {
28    /// Print the locate-alert state.
29    Show,
30    /// Make the radio conspicuous so it can be found.
31    #[command(alias = "on", alias = "find")]
32    Locate,
33    /// Stop the locate alert.
34    #[command(name = "none", alias = "off", alias = "stop")]
35    None,
36}
37
38pub async fn save<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
39    device.save().await?;
40    println!("saved: live state persists across reboots");
41    Ok(())
42}
43
44pub async fn restore<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
45    let completion = device.restore().await?;
46    println!("restored live state from the saved snapshot ({completion:?} form)");
47    Ok(())
48}
49
50pub async fn clear<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
51    device.clear().await?;
52    println!(
53        "cleared: persisted state erased; live state keeps running until reboot \
54         (BLE bonds and pairing PIN are retained)"
55    );
56    Ok(())
57}
58
59pub async fn reset<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
60    let status = device.reset().await?;
61    println!("reset complete ({status:?})");
62    Ok(())
63}
64
65const FACTORY_RESET_WARNING: &str = "factory-reset erases ALL mutable state — persisted provisioning, the device identity, \
66     BLE bonds, and the pairing PIN — then reboots";
67
68pub async fn factory_reset(app: &mut App, yes: bool) -> Result<()> {
69    if !yes {
70        if !app.interactive {
71            bail!("{FACTORY_RESET_WARNING}; re-run with --yes to confirm");
72        }
73        println!("{FACTORY_RESET_WARNING}.");
74        if !confirm("erase everything and reboot?")? {
75            println!("cancelled");
76            return Ok(());
77        }
78    }
79    let device = app.device()?;
80    device.factory_reset().await?;
81    println!(
82        "factory reset sent; the radio is erasing ALL state (provisioning, device identity, \
83         BLE bonds, pairing PIN) and rebooting. The link will drop; re-pair to use it again."
84    );
85    // The device is rebooting, so the handle is worthless. Say so once
86    // rather than letting the next command fail obscurely.
87    app.detach();
88    Ok(())
89}
90
91pub async fn pin<L: FrameLink>(device: &mut UlcpDevice<L>, value: PinArg) -> Result<()> {
92    device.set_ble_pairing_pin(value.0).await?;
93    match value.0 {
94        Some(_) => println!("BLE pairing PIN set (persisted; applies to new pairings)"),
95        None => println!("BLE pairing PIN cleared"),
96    }
97    Ok(())
98}
99
100pub async fn identity<L: FrameLink>(
101    device: &mut UlcpDevice<L>,
102    op: Option<IdentityOp>,
103) -> Result<()> {
104    match op.unwrap_or(IdentityOp::Show) {
105        IdentityOp::Show => {
106            let value = device.get_prop(prop::DEV_KEY).await?;
107            match <[u8; 32]>::try_from(value.as_slice()) {
108                Ok(key) => field("device identity", PublicKey(key)),
109                Err(_) if value.is_empty() => {
110                    println!("no device identity configured (run `identity generate`)");
111                }
112                Err(_) => bail!("malformed PROP_DEV_KEY"),
113            }
114            Ok(())
115        }
116        IdentityOp::Generate => {
117            let value = device.get_prop(prop::DEV_KEY).await?;
118            if let Ok(key) = <[u8; 32]>::try_from(value.as_slice()) {
119                println!("device identity already exists: {}", PublicKey(key));
120                println!("(identities are never regenerated in place; factory-reset discards one)");
121                return Ok(());
122            }
123            let key = device.ensure_device_identity().await?;
124            println!("generated device identity: {}", PublicKey(key));
125            println!("(persisted immediately; device identities are independent of save/restore)");
126            Ok(())
127        }
128    }
129}
130
131pub async fn name(app: &mut App, name: Option<String>) -> Result<()> {
132    let no_save = app.no_save;
133    let Some(name) = name else {
134        let device = app.device()?;
135        let current = device.device_name().await?;
136        field("device name", format!("{current:?}"));
137        return Ok(());
138    };
139    let device = app.device()?;
140    device.set_device_name(&name).await?;
141    println!("device name set to {name:?}");
142    persist(device, no_save).await?;
143    // The prompt follows the device, so it has to follow a rename.
144    app.rename(name);
145    Ok(())
146}
147
148/// Report or drive the locate alert (`PROP_ALERT`).
149///
150/// Deliberately outside the auto-save path: the alert is live behavior
151/// that no snapshot carries, so there is nothing to persist.
152pub async fn alert<L: FrameLink>(device: &mut UlcpDevice<L>, op: Option<AlertOp>) -> Result<()> {
153    let desired = match op.unwrap_or(AlertOp::Show) {
154        AlertOp::Show => {
155            match device.alert().await? {
156                Some(state) => field("alert", display(state)),
157                None => field("alert", "unsupported (no CAP_ALERT)"),
158            }
159            return Ok(());
160        }
161        AlertOp::Locate => AlertState::Locate,
162        AlertOp::None => AlertState::None,
163    };
164    match device.set_alert(desired).await? {
165        AlertState::Locate => println!(
166            "locate alert started. It stops when you send `alert none`, when someone \
167             cancels it at the radio, or when the radio's own deadline expires — \
168             re-send `alert locate` to keep it going."
169        ),
170        AlertState::None => println!("locate alert stopped"),
171    }
172    Ok(())
173}
174
175/// Human-readable rendering of a `PROP_ALERT` state.
176fn display(state: AlertState) -> &'static str {
177    match state {
178        AlertState::None => "none",
179        AlertState::Locate => "locate (the radio is making itself conspicuous)",
180    }
181}