1use anyhow::Result;
5
6use umsh::core::PublicKey;
7use umsh::ulcp::{FrameLink, HostOwnership, SavedSnapshot, UlcpDevice};
8use umsh::ulcp_wire::battery::{BatteryChargeState, BatteryStatus};
9use umsh::ulcp_wire::ids::{DUTY_LIMIT_DISABLED, cap, prop};
10use umsh::ulcp_wire::items::Filter;
11
12use super::values::{FilterArg, KeyArg};
13use super::{decode_u16, duty_percent, phy, repeater};
14use crate::output::{field, hex};
15
16#[derive(Debug, clap::Args)]
17pub struct InfoArgs {
18 #[arg(long, value_name = "KEY")]
20 pub expect_host_key: Option<KeyArg>,
21}
22
23pub async fn run<L: FrameLink>(device: &mut UlcpDevice<L>, args: InfoArgs) -> Result<()> {
24 let expected = args.expect_host_key.map(|key| key.0);
25 let sync = device.sync(expected.as_ref()).await?;
26
27 field("device name", format!("{:?}", sync.device_name));
28 match (sync.has_capability(cap::DEV_IDENTITY), sync.dev_key) {
29 (true, Some(key)) => field("identity", PublicKey(key)),
30 (true, None) => field("identity", "none (run `identity generate`)"),
31 (false, _) => field("identity", "unsupported"),
32 }
33 if sync.reset_since_last_contact {
34 field(
35 "status",
36 format!("{:?} (reset since last host contact)", sync.last_status),
37 );
38 } else {
39 field("status", format!("{:?}", sync.last_status));
40 }
41 field(
42 "capabilities",
43 sync.capabilities
44 .iter()
45 .map(|&code| cap_name(code))
46 .collect::<Vec<_>>()
47 .join(" "),
48 );
49 let ownership = match (sync.ownership, expected.is_some()) {
50 (HostOwnership::Ours, _) => "configured host key matches --expect-host-key".to_string(),
51 (HostOwnership::Unclaimed, _) => "unclaimed (no host provisioned)".to_string(),
52 (HostOwnership::Unsupported, _) => "unsupported (minimal protocol)".to_string(),
53 (HostOwnership::OtherHost(key), true) => format!("ANOTHER HOST: {}", PublicKey(key)),
54 (HostOwnership::OtherHost(key), false) => PublicKey(key).to_string(),
55 };
56 field("host", ownership);
57
58 let mut parts = vec![
59 if sync.phy_enabled {
60 "enabled".to_string()
61 } else {
62 "disabled".to_string()
63 },
64 format!("{} kHz", sync.freq_khz),
65 ];
66 if sync.has_capability(cap::PHY_LORA) {
67 parts.extend(phy::lora_parts(device).await);
68 }
69 parts.extend(phy::power_part(device).await);
70 field("phy", parts.join(", "));
71
72 if sync.has_capability(cap::PHY_DUTY_LIMIT) {
73 let now = device
74 .get_prop(prop::PHY_DUTY_NOW)
75 .await
76 .ok()
77 .and_then(|v| decode_u16(&v));
78 let limit = device
79 .get_prop(prop::PHY_DUTY_LIMIT)
80 .await
81 .ok()
82 .and_then(|v| decode_u16(&v));
83 let now = now.map_or("unknown".to_string(), |raw| {
84 format!("{:.1}%", duty_percent(raw))
85 });
86 let limit = limit.map_or("unknown".to_string(), |raw| {
87 if raw == DUTY_LIMIT_DISABLED {
88 "disabled".to_string()
89 } else {
90 format!("{:.1}%", duty_percent(raw))
91 }
92 });
93 field("duty", format!("now {now}, limit {limit}"));
94 }
95 if sync.has_capability(cap::REPEATER) {
96 match device.repeater_policy().await {
100 Ok(Some(policy)) if policy.enabled => {
101 let default = policy
102 .default_region
103 .map_or("untagged".to_string(), |code| code.to_string());
104 let floor = match (policy.min_rssi, policy.min_snr) {
105 (None, None) => String::new(),
106 (rssi, snr) => {
107 let rssi = rssi.map_or("any".to_string(), |dbm| format!("{dbm} dBm"));
108 let snr = snr.map_or("any".to_string(), |db| format!("{db} dB"));
109 format!(", floor {rssi}/{snr}")
110 }
111 };
112 field(
113 "repeater",
114 format!(
115 "on, regions {}, tags {default}{floor}",
116 repeater::format_regions(&policy.regions)
117 ),
118 );
119 }
120 Ok(Some(_)) => field("repeater", "off"),
121 Ok(None) | Err(_) => field("repeater", "unknown"),
122 }
123 }
124 if sync.has_capability(cap::BATTERY) {
125 match device.battery_status().await {
129 Ok(Some(status)) => field("battery", battery_display(&status)),
130 Ok(None) => {}
131 Err(error) => field("battery", format!("unavailable ({error})")),
132 }
133 }
134 if sync.has_capability(cap::ILLUMINANCE) {
135 match device.illuminance().await {
137 Ok(Some(millilux)) => field("illuminance", format_millilux(millilux)),
138 Ok(None) => field("illuminance", "no reading".to_string()),
139 Err(error) => field("illuminance", format!("unavailable ({error})")),
140 }
141 }
142 if let Some(saved) = sync.saved {
143 field(
144 "saved",
145 match saved {
146 SavedSnapshot::None => "no",
147 SavedSnapshot::Current => "yes",
148 SavedSnapshot::Fallback => "yes, but running on an older generation (re-save)",
149 SavedSnapshot::Unreadable => "unreadable — booted with defaults",
150 },
151 );
152 }
153 if let (Some(count), Some(dropped)) = (sync.queue_count, sync.queue_dropped) {
154 let capacity = device
155 .get_prop(prop::HOST_RX_QUEUE_CAPACITY)
156 .await
157 .ok()
158 .and_then(|v| decode_u16(&v))
159 .map_or("?".to_string(), |capacity| capacity.to_string());
160 field(
161 "rx queue",
162 format!("{count} buffered of {capacity}, {dropped} dropped since boot"),
163 );
164 }
165 if let Some(filters) = &sync.filters {
166 field("filters", filter_list(filters));
167 }
168 if let Some(ids) = &sync.host_channel_ids {
169 let display = if ids.is_empty() {
170 "none".to_string()
171 } else {
172 ids.iter().map(|id| hex(id)).collect::<Vec<_>>().join(", ")
173 };
174 field("channel keys", format!("{} (ids: {display})", ids.len()));
175 }
176 if let Some(peers) = &sync.host_peer_keys {
177 let display = if peers.is_empty() {
178 "none".to_string()
179 } else {
180 peers
181 .iter()
182 .map(|key| PublicKey(*key).to_string())
183 .collect::<Vec<_>>()
184 .join(", ")
185 };
186 field("peer keys", format!("{} ({display})", peers.len()));
187 }
188 if let Some(auto_ack) = sync.auto_ack {
189 field("auto-ack", if auto_ack { "on" } else { "off" });
190 }
191 Ok(())
192}
193
194fn filter_list(filters: &[Filter]) -> String {
195 if filters.is_empty() {
196 return "none".to_string();
197 }
198 filters
199 .iter()
200 .map(|filter| FilterArg(*filter).to_string())
201 .collect::<Vec<_>>()
202 .join(", ")
203}
204
205fn cap_name(code: u32) -> String {
206 match code {
207 cap::WRITABLE_RAW_STREAM => "WRITABLE_RAW_STREAM".into(),
208 cap::PHY_DUTY_LIMIT => "PHY_DUTY_LIMIT".into(),
209 cap::PHY_LORA => "PHY_LORA".into(),
210 cap::HOST_FILTER => "HOST_FILTER".into(),
211 cap::HOST_RX_QUEUE => "HOST_RX_QUEUE".into(),
212 cap::HOST_KEYS => "HOST_KEYS".into(),
213 cap::HOST_AUTO_ACK => "HOST_AUTO_ACK".into(),
214 cap::SAVE => "SAVE".into(),
215 cap::DEV_IDENTITY => "DEV_IDENTITY".into(),
216 cap::DEV_NAME => "DEV_NAME".into(),
217 cap::BATTERY => "BATTERY".into(),
218 cap::REPEATER => "REPEATER".into(),
219 other => other.to_string(),
220 }
221}
222
223fn battery_display(status: &BatteryStatus) -> String {
224 if status.is_empty() {
225 return "unsupported reporting".to_string();
226 }
227 let voltage = status
228 .voltage_mv
229 .map_or("voltage unsupported".to_string(), |mv| format!("{mv} mV"));
230 let level = status
231 .level_percent
232 .map_or("level unsupported".to_string(), |percent| {
233 format!("{percent}%")
234 });
235 let state = match status.charge_state {
236 Some(BatteryChargeState::Discharging) => "discharging",
237 Some(BatteryChargeState::Charging) => "charging",
238 Some(BatteryChargeState::Charged) => "charged",
239 None => "charge state unsupported",
240 };
241 format!("{voltage}, {level}, {state}")
242}
243
244pub async fn illuminance<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
250 if !device.capabilities().await?.contains(&cap::ILLUMINANCE) {
251 field("illuminance", "unsupported (no CAP_ILLUMINANCE)");
252 return Ok(());
253 }
254 match device.illuminance().await? {
255 Some(millilux) => field(
256 "illuminance",
257 format!("{} ({millilux} mlux)", format_millilux(millilux)),
258 ),
259 None => field("illuminance", "no reading".to_string()),
260 }
261 Ok(())
262}
263
264pub fn format_millilux(millilux: u32) -> String {
269 format!("{}.{:03} lux", millilux / 1000, millilux % 1000)
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn filters_render_in_the_spelling_the_flag_accepts() {
278 let filters = [Filter::PktType(1), Filter::ChannelId([0x9B, 0x68])];
279 assert_eq!(filter_list(&filters), "pkt-type:1, channel-id:9b68");
280 assert_eq!(filter_list(&[]), "none");
281 }
282
283 #[test]
284 fn unknown_capability_codes_survive_as_numbers() {
285 assert_eq!(cap_name(cap::SAVE), "SAVE");
286 assert_eq!(cap_name(9999), "9999");
287 }
288
289 #[test]
290 fn battery_names_each_unsupported_component() {
291 let status = BatteryStatus {
292 voltage_mv: Some(4150),
293 level_percent: None,
294 charge_state: Some(BatteryChargeState::Charging),
295 };
296 assert_eq!(
297 battery_display(&status),
298 "4150 mV, level unsupported, charging"
299 );
300 }
301}