umshctl/command/
lifecycle.rs1use 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 Show,
22 Generate,
24}
25
26#[derive(Debug, clap::Subcommand)]
27pub enum BleOp {
28 Bonds,
30 Pair {
32 #[arg(value_enum, default_value_t = PairState::On)]
34 state: PairState,
35 },
36 Clear {
39 #[arg(long)]
41 yes: bool,
42 },
43}
44
45#[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 Show,
58 #[command(alias = "on", alias = "find")]
60 Locate,
61 #[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
93pub 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 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
147pub 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 app.rename(name);
242 Ok(())
243}
244
245pub 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
272fn display(state: AlertState) -> &'static str {
274 match state {
275 AlertState::None => "none",
276 AlertState::Locate => "locate (the radio is making itself conspicuous)",
277 }
278}