umshctl/command/info/
mod.rs

1//! `info`: what the device says about itself, in as few exchanges as it
2//! can be asked in.
3//!
4//! The report is a list of topics ([`topics::TOPICS`]), each a set of
5//! properties and two renderings of them. Everything a run needs is
6//! fetched in one `CMD_PROP_MULTI_GET` — plus the two that cannot share
7//! one — so a whole report costs about what a single property used to.
8//!
9//! Naming a topic asks for that topic alone, which over the mesh is the
10//! difference between a question and an errand.
11
12pub mod props;
13pub mod topics;
14
15use anyhow::{Result, bail};
16
17use umsh::ulcp::{FrameLink, UlcpDevice};
18use umsh::ulcp_wire::battery::{BatteryChargeState, BatteryStatus};
19use umsh::ulcp_wire::ids::{cap, prop};
20use umsh::ulcp_wire::property_name;
21
22use props::PropSet;
23use topics::{Context, Topic};
24
25use super::values::KeyArg;
26use crate::output::{field, subfield};
27
28#[derive(Debug, clap::Args)]
29pub struct InfoArgs {
30    /// Report host ownership relative to this host identity public key.
31    #[arg(long, value_name = "KEY")]
32    pub expect_host_key: Option<KeyArg>,
33
34    /// Print `NAME=VALUE` lines a shell can `eval`, rather than prose.
35    #[arg(long)]
36    pub env: bool,
37
38    /// Report one subject rather than all of them.
39    ///
40    /// With no topic every subject the device supports is reported, and
41    /// each one named here reports that subject alone.
42    #[arg(value_name = "TOPIC", value_parser = topic_parser())]
43    pub topic: Option<String>,
44}
45
46/// The topic names, as a value parser — so clap rejects a misspelling,
47/// `--help` lists what there is, and the shell completes them, all from
48/// the one table that defines them.
49fn topic_parser() -> clap::builder::PossibleValuesParser {
50    clap::builder::PossibleValuesParser::new(topics::TOPICS.iter().map(|topic| topic.name))
51}
52
53pub async fn run<L: FrameLink>(device: &mut UlcpDevice<L>, args: InfoArgs) -> Result<()> {
54    // Two properties cannot travel with the rest and must come first.
55    //
56    // `PROP_LAST_STATUS` reports a refused position by putting itself
57    // into it, so its value and a refusal are the same bytes; asking for
58    // it alone is what tells them apart. `PROP_CAPS` decides which
59    // topics exist and therefore what the batch contains, so it cannot
60    // be in that batch.
61    let mut set = PropSet::new();
62    set.insert(
63        prop::LAST_STATUS,
64        Ok(device.get_prop(prop::LAST_STATUS).await?),
65    );
66    let raw_caps = device.get_prop(prop::CAPS).await?;
67    let caps = umsh::ulcp::decode_capabilities(&raw_caps)?;
68    set.insert(prop::CAPS, Ok(raw_caps));
69
70    let ctx = Context {
71        caps,
72        remote: device.is_remote(),
73        expect_host_key: args.expect_host_key.map(|key| key.0),
74        dev_version: device.dev_version().to_owned(),
75        // A device that answers the optional model property with an
76        // empty string has not named its hardware any more than one that
77        // refuses the read has.
78        dev_model: device
79            .dev_model()
80            .filter(|model| !model.is_empty())
81            .map(str::to_owned),
82    };
83
84    let selected: Vec<&Topic> = match &args.topic {
85        // clap has already checked the name against the same table.
86        Some(name) => {
87            let topic = topics::topic(name).expect("clap accepted an unknown topic");
88            if !(topic.gate)(&ctx) {
89                bail!(
90                    "this device has nothing to report under {name:?} — see `info` for what it \
91                     does report"
92                );
93            }
94            vec![topic]
95        }
96        None => topics::TOPICS
97            .iter()
98            .filter(|topic| (topic.gate)(&ctx))
99            .collect(),
100    };
101
102    // Every property every selected topic wants, asked for at once.
103    let mut keys: Vec<u32> = Vec::new();
104    for topic in &selected {
105        for key in (topic.keys)(&ctx) {
106            if !keys.contains(&key) {
107                keys.push(key);
108            }
109        }
110    }
111    let fetched = props::fetch(device, &keys, props::batched(&ctx.caps)).await?;
112    for &key in &keys {
113        if let Some(value) = fetched.bytes(key) {
114            set.insert(key, Ok(value.to_vec()));
115        } else if let Some(status) = fetched.refusal(key) {
116            set.insert(key, Err(status));
117        }
118    }
119
120    if args.env {
121        for topic in &selected {
122            for (name, value) in (topic.env)(&set, &ctx) {
123                println!("{}_{name}={}", topic.prefix, topics::shell_quote(&value));
124            }
125        }
126        return Ok(());
127    }
128
129    // One topic on its own reads as a report about that subject, so its
130    // lines stand at the left margin. A whole report needs the subject
131    // named, and its lines indented under it.
132    let single = selected.len() == 1;
133    for topic in &selected {
134        let lines = (topic.render)(&set, &ctx);
135        if single {
136            for (label, value) in lines {
137                field(&label, value);
138            }
139        } else {
140            println!("{}:", topic.name);
141            for (label, value) in lines {
142                subfield(&label, value);
143            }
144        }
145        for (key, status) in topics::refusals(&set, &(topic.keys)(&ctx)) {
146            let label = property_name(key).map_or_else(|| format!("prop {key}"), str::to_owned);
147            let refused = format!("refused: {status:?}");
148            if single {
149                field(&label, refused);
150            } else {
151                subfield(&label, refused);
152            }
153        }
154    }
155    Ok(())
156}
157
158pub fn battery_display(status: &BatteryStatus) -> String {
159    if status.is_empty() {
160        return "unsupported reporting".to_string();
161    }
162    let voltage = status
163        .voltage_mv
164        .map_or("voltage unsupported".to_string(), |mv| format!("{mv} mV"));
165    let level = status
166        .level_percent
167        .map_or("level unsupported".to_string(), |percent| {
168            format!("{percent}%")
169        });
170    let state = match status.charge_state {
171        Some(BatteryChargeState::Discharging) => "discharging",
172        Some(BatteryChargeState::Charging) => "charging",
173        Some(BatteryChargeState::Charged) => "charged",
174        None => "charge state unsupported",
175    };
176    format!("{voltage}, {level}, {state}")
177}
178
179/// `illuminance`: one ambient light reading, on its own.
180///
181/// Separate from `info` because calibrating the sensor against a
182/// reference meter means taking readings in a tight loop, and a full
183/// report per data point is unusable for that.
184pub async fn illuminance<L: FrameLink>(device: &mut UlcpDevice<L>) -> Result<()> {
185    if !device.capabilities().await?.contains(&cap::ILLUMINANCE) {
186        field("illuminance", "unsupported (no CAP_ILLUMINANCE)");
187        return Ok(());
188    }
189    match device.illuminance().await? {
190        Some(millilux) => field(
191            "illuminance",
192            format!("{} ({millilux} mlux)", format_millilux(millilux)),
193        ),
194        None => field("illuminance", "no reading".to_string()),
195    }
196    Ok(())
197}
198
199/// Millilux as lux to three decimal places. Fixed rather than adaptive
200/// because the readings this is used to calibrate span moonlight to
201/// daylight, and a column that keeps its shape is easier to compare
202/// against a meter than one that keeps rescaling.
203pub fn format_millilux(millilux: u32) -> String {
204    format!("{}.{:03} lux", millilux / 1000, millilux % 1000)
205}
206
207#[cfg(test)]
208mod tests {
209    use super::topics::{Context, shell_quote, topic};
210    use super::*;
211    use umsh::ulcp_wire::ids::{cap, prop};
212
213    fn context(caps: &[u32]) -> Context {
214        Context {
215            caps: caps.to_vec(),
216            remote: false,
217            expect_host_key: None,
218            dev_version: "test/0.1".to_string(),
219            dev_model: Some("Fake Board".to_string()),
220        }
221    }
222
223    fn props_of(entries: &[(u32, &[u8])]) -> PropSet {
224        let mut set = PropSet::new();
225        for (key, value) in entries {
226            set.insert(*key, Ok(value.to_vec()));
227        }
228        set
229    }
230
231    fn value(lines: &[(String, String)], label: &str) -> Option<String> {
232        lines
233            .iter()
234            .find(|(name, _)| name == label)
235            .map(|(_, value)| value.clone())
236    }
237
238    #[test]
239    fn every_topic_has_a_distinct_name_and_prefix() {
240        for (index, topic) in topics::TOPICS.iter().enumerate() {
241            let earlier = &topics::TOPICS[..index];
242            assert!(
243                !earlier.iter().any(|other| other.name == topic.name),
244                "two topics named {}",
245                topic.name
246            );
247            assert!(
248                !earlier.iter().any(|other| other.prefix == topic.prefix),
249                "two topics prefixed {}",
250                topic.prefix
251            );
252        }
253    }
254
255    /// `PROP_LAST_STATUS` reports a refused position by putting itself
256    /// into it, so it cannot travel in a batch. No topic may name it.
257    #[test]
258    fn no_topic_asks_for_the_status_property_in_a_batch() {
259        let ctx = context(&[
260            cap::PHY_LORA,
261            cap::PHY_DUTY_LIMIT,
262            cap::BATTERY,
263            cap::REPEATER,
264            cap::GNSS,
265            cap::TIME,
266            cap::ADVERT,
267            cap::ILLUMINANCE,
268            cap::DEV_IDENTITY,
269            cap::IDENT,
270            cap::HOST_FILTER,
271            cap::HOST_KEYS,
272        ]);
273        for topic in topics::TOPICS {
274            let keys = (topic.keys)(&ctx);
275            assert!(
276                !keys.contains(&prop::LAST_STATUS),
277                "{} asks for PROP_LAST_STATUS in a batch",
278                topic.name
279            );
280            assert!(
281                !keys.contains(&prop::CAPS),
282                "{} asks for PROP_CAPS, which gates the batch it would be in",
283                topic.name
284            );
285        }
286    }
287
288    #[test]
289    fn a_topic_reports_only_what_its_device_supports() {
290        let bare = context(&[]);
291        assert!(!(topic("gnss").unwrap().gate)(&bare));
292        assert!(!(topic("battery").unwrap().gate)(&bare));
293        // The device and its radio are always worth asking about.
294        assert!((topic("device").unwrap().gate)(&bare));
295        assert!((topic("radio").unwrap().gate)(&bare));
296
297        let gnss = context(&[cap::GNSS]);
298        assert!((topic("gnss").unwrap().gate)(&gnss));
299    }
300
301    /// Over the mesh the host domain is not visible at all, so the topic
302    /// is withheld rather than offered and refused nine times.
303    #[test]
304    fn the_host_topic_is_absent_over_the_mesh() {
305        let mut ctx = context(&[cap::HOST_FILTER, cap::HOST_KEYS]);
306        assert!((topic("host").unwrap().gate)(&ctx));
307        ctx.remote = true;
308        assert!(!(topic("host").unwrap().gate)(&ctx));
309    }
310
311    #[test]
312    fn the_radio_topic_reads_the_lora_parameters() {
313        let ctx = context(&[cap::PHY_LORA]);
314        let set = props_of(&[
315            (prop::PHY_ENABLED, &[1]),
316            (prop::PHY_FREQ, &906_875u32.to_le_bytes()),
317            (prop::PHY_TX_POWER, &[0xF7]),
318            (prop::PHY_LORA_BW, &250_000u32.to_le_bytes()),
319            (prop::PHY_LORA_SF, &[11]),
320            (prop::PHY_LORA_CR, &[5]),
321        ]);
322        let lines = (topic("radio").unwrap().render)(&set, &ctx);
323        assert_eq!(value(&lines, "phy").as_deref(), Some("enabled"));
324        assert_eq!(value(&lines, "frequency").as_deref(), Some("906875 kHz"));
325        assert_eq!(
326            value(&lines, "modulation").as_deref(),
327            Some("BW 250000 Hz, SF11, CR 4/5")
328        );
329        // Negative transmit powers are real, and read as signed.
330        assert_eq!(value(&lines, "tx power").as_deref(), Some("-9 dBm"));
331    }
332
333    /// The gates below an off repeater do nothing, and printing them
334    /// invites reading them as if they did.
335    #[test]
336    fn a_disabled_repeater_is_one_line() {
337        let ctx = context(&[cap::REPEATER]);
338        let off = props_of(&[(prop::MAC_REPEATER_ENABLED, &[0])]);
339        let lines = (topic("repeater").unwrap().render)(&off, &ctx);
340        assert_eq!(lines.len(), 1);
341        assert_eq!(value(&lines, "forwarding").as_deref(), Some("off"));
342
343        let on = props_of(&[
344            (prop::MAC_REPEATER_ENABLED, &[1]),
345            (prop::MAC_REPEATER_REGIONS, &[]),
346            (prop::MAC_REPEATER_MIN_RSSI, &(-110i16).to_le_bytes()),
347            (prop::MAC_REPEATER_MIN_SNR, &[]),
348        ]);
349        let lines = (topic("repeater").unwrap().render)(&on, &ctx);
350        assert_eq!(value(&lines, "floor").as_deref(), Some("-110 dBm/any"));
351        assert_eq!(value(&lines, "tags").as_deref(), Some("untagged"));
352    }
353
354    #[test]
355    fn the_battery_environment_is_something_a_shell_can_eval() {
356        let ctx = context(&[cap::BATTERY]);
357        let status = BatteryStatus {
358            voltage_mv: Some(4100),
359            level_percent: Some(95),
360            charge_state: Some(BatteryChargeState::Discharging),
361        };
362        let mut encoded = [0u8; 8];
363        let len = status.encode(&mut encoded).unwrap();
364        let set = props_of(&[(prop::BATTERY, &encoded[..len])]);
365        let env = (topic("battery").unwrap().env)(&set, &ctx);
366        assert_eq!(
367            env,
368            vec![
369                ("PRESENT".to_string(), "1".to_string()),
370                ("LEVEL".to_string(), "0.95".to_string()),
371                ("VOLTS".to_string(), "4.100".to_string()),
372                ("STATE".to_string(), "DISCHARGING".to_string()),
373            ]
374        );
375    }
376
377    #[test]
378    fn an_unreported_component_is_an_absent_variable_not_an_empty_one() {
379        let ctx = context(&[cap::BATTERY]);
380        let status = BatteryStatus {
381            voltage_mv: None,
382            level_percent: Some(7),
383            charge_state: None,
384        };
385        let mut encoded = [0u8; 8];
386        let len = status.encode(&mut encoded).unwrap();
387        let set = props_of(&[(prop::BATTERY, &encoded[..len])]);
388        assert_eq!(
389            (topic("battery").unwrap().env)(&set, &ctx),
390            vec![
391                ("PRESENT".to_string(), "1".to_string()),
392                ("LEVEL".to_string(), "0.07".to_string()),
393            ]
394        );
395
396        // A device with no battery answers with no octets at all, so a
397        // script that `eval`s this always has something to test.
398        let absent = props_of(&[(prop::BATTERY, &[])]);
399        assert_eq!(
400            (topic("battery").unwrap().env)(&absent, &ctx),
401            vec![("PRESENT".to_string(), "0".to_string())]
402        );
403    }
404
405    #[test]
406    fn battery_names_each_unsupported_component() {
407        let status = BatteryStatus {
408            voltage_mv: Some(4150),
409            level_percent: None,
410            charge_state: Some(BatteryChargeState::Charging),
411        };
412        assert_eq!(
413            battery_display(&status),
414            "4150 mV, level unsupported, charging"
415        );
416    }
417
418    /// A device name is whatever somebody typed into it, and the output
419    /// is meant to be `eval`ed.
420    #[test]
421    fn env_values_are_safe_for_a_shell_to_evaluate() {
422        assert_eq!(shell_quote("T-Echo"), "T-Echo");
423        assert_eq!(shell_quote("Repeater 3"), "'Repeater 3'");
424        assert_eq!(shell_quote("it's"), r"'it'\''s'");
425        assert_eq!(shell_quote(""), "''");
426        assert_eq!(shell_quote("$(rm -rf /)"), "'$(rm -rf /)'");
427    }
428
429    #[test]
430    fn a_minimal_length_signed_altitude_reads_both_ways() {
431        let ctx = context(&[cap::IDENT]);
432        let up = props_of(&[(prop::IDENT_ALTITUDE, &[0x04, 0xD2])]);
433        let lines = (topic("identity").unwrap().render)(&up, &ctx);
434        assert_eq!(value(&lines, "altitude").as_deref(), Some("1234 m"));
435
436        // One octet, sign-extended: below sea level is a real altitude.
437        let down = props_of(&[(prop::IDENT_ALTITUDE, &[0xF0])]);
438        let lines = (topic("identity").unwrap().render)(&down, &ctx);
439        assert_eq!(value(&lines, "altitude").as_deref(), Some("-16 m"));
440    }
441}