umshctl/command/
info.rs

1//! `info`: everything the device will say about itself, in one report,
2//! without changing any of it.
3
4use 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    /// Report ownership relative to this host identity public key.
19    #[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        // The policy gates only matter once forwarding is on, so an
97        // enabled repeater gets the whole line and a disabled one stays
98        // a single word.
99        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        // Live telemetry: each GET performs a measurement, so this is
126        // fetched here rather than inside sync, and a sampling failure
127        // must not abort the rest of the report.
128        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        // Live telemetry, like the battery: reading it takes a sample.
136        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
244/// `illuminance`: one ambient light reading, on its own.
245///
246/// Separate from `info` because calibrating the sensor against a
247/// reference meter means taking readings in a tight loop, and a full
248/// report per data point is unusable for that.
249pub 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
264/// Millilux as lux to three decimal places. Fixed rather than adaptive
265/// because the readings this is used to calibrate span moonlight to
266/// daylight, and a column that keeps its shape is easier to compare
267/// against a meter than one that keeps rescaling.
268pub 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}