umshctl/command/
props.rs

1//! Properties by name, and their values in a form a person can read and
2//! type.
3//!
4//! One vocabulary for the whole tool: `get`, `set`, and the `manage`
5//! forms of both take the same spellings and render values the same way.
6//! The names and types come from `umsh_ulcp::describe`, so this file
7//! holds no second copy of the table it reads.
8
9use std::fmt;
10use std::str::FromStr;
11
12use anyhow::{Result, bail};
13
14use umsh::ulcp::{FrameLink, UlcpDevice};
15use umsh::ulcp_wire::describe::{PropertyType, property_type};
16use umsh::ulcp_wire::ids::saved;
17use umsh::ulcp_wire::{PROPERTIES, property_name};
18
19use super::values::{BytesArg, parse_key32, parse_u32};
20use crate::output::{address, field, hex};
21
22/// A property named by its mnemonic or its number.
23///
24/// `phy-freq`, `PHY_FREQ`, `PROP_PHY_FREQ`, `35`, and `0x23` all name
25/// the same property. The spelling with dashes is what fits a command
26/// line; the others are what the spec and the traces call it, and
27/// refusing them would only make somebody transliterate.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct PropArg(pub u32);
30
31impl FromStr for PropArg {
32    type Err = String;
33
34    fn from_str(text: &str) -> Result<Self, Self::Err> {
35        if let Ok(key) = parse_u32(text) {
36            return Ok(Self(key));
37        }
38        resolve(text).map(Self).ok_or_else(|| {
39            let mut message = format!("no property named {text:?}");
40            if let Some(near) = nearest(text) {
41                message.push_str(&format!("; did you mean {}?", spell(near)));
42            }
43            message
44        })
45    }
46}
47
48impl fmt::Display for PropArg {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match property_name(self.0) {
51            Some(name) => write!(f, "{name}"),
52            None => write!(f, "prop {}", self.0),
53        }
54    }
55}
56
57/// The identifier a mnemonic names, however it was written.
58fn resolve(text: &str) -> Option<u32> {
59    PROPERTIES
60        .iter()
61        .copied()
62        .find(|&key| property_name(key).is_some_and(|name| same_name(name, text)))
63}
64
65/// Whether `written` names the property `name` spells, ignoring case and
66/// the difference between a dash and an underscore, with the `PROP_`
67/// prefix optional.
68fn same_name(name: &str, written: &str) -> bool {
69    let canonical = name.strip_prefix("PROP_").unwrap_or(name);
70    let written = written
71        .strip_prefix("PROP_")
72        .or_else(|| written.strip_prefix("prop_"))
73        .or_else(|| written.strip_prefix("prop-"))
74        .unwrap_or(written);
75    canonical.len() == written.len()
76        && canonical
77            .bytes()
78            .zip(written.bytes())
79            .all(|(a, b)| letter(a) == letter(b))
80}
81
82fn letter(byte: u8) -> u8 {
83    match byte {
84        b'-' => b'_',
85        other => other.to_ascii_lowercase(),
86    }
87}
88
89/// The name that shares the longest run of leading characters with what
90/// was typed, when that run is long enough to be a typo rather than a
91/// coincidence.
92fn nearest(text: &str) -> Option<u32> {
93    let typed: Vec<u8> = text.bytes().map(letter).collect();
94    PROPERTIES
95        .iter()
96        .copied()
97        .filter_map(|key| {
98            let name = property_name(key)?;
99            let canonical = name.strip_prefix("PROP_").unwrap_or(name);
100            let shared = canonical
101                .bytes()
102                .map(letter)
103                .zip(typed.iter().copied())
104                .take_while(|(a, b)| a == b)
105                .count();
106            (shared >= 4).then_some((shared, key))
107        })
108        .max_by_key(|(shared, _)| *shared)
109        .map(|(_, key)| key)
110}
111
112/// How this tool writes a property's name on a command line.
113pub fn spell(key: u32) -> String {
114    match property_name(key) {
115        Some(name) => name
116            .strip_prefix("PROP_")
117            .unwrap_or(name)
118            .to_ascii_lowercase()
119            .replace('_', "-"),
120        None => key.to_string(),
121    }
122}
123
124/// A property's value as something worth reading.
125///
126/// The scalar types read as numbers, booleans as on and off, keys as
127/// addresses, text as text. Anything whose shape is a structure rather
128/// than a scalar reads as hex, which is honest: the octets are the
129/// value, and a topic of `info` is where one gets interpreted.
130pub fn format_value(key: u32, value: &[u8]) -> String {
131    if value.is_empty() {
132        // Empty is a real value for a good many properties — no fix, no
133        // threshold, no name — and reads better as a word than as the
134        // blank line an empty hex string would leave.
135        return "(empty)".to_string();
136    }
137    match property_type(key) {
138        Some(PropertyType::Bool) => match value[0] {
139            0 => "off".to_string(),
140            1 => "on".to_string(),
141            other => format!("{other} (neither on nor off)"),
142        },
143        Some(PropertyType::U8) => scalar(key, u32::from(value[0])),
144        Some(PropertyType::I8) => format!("{}", value[0] as i8),
145        Some(PropertyType::U16) => match <[u8; 2]>::try_from(value) {
146            Ok(bytes) => scalar(key, u32::from(u16::from_le_bytes(bytes))),
147            Err(_) => malformed(value),
148        },
149        Some(PropertyType::I16) => match <[u8; 2]>::try_from(value) {
150            Ok(bytes) => format!("{}", i16::from_le_bytes(bytes)),
151            Err(_) => malformed(value),
152        },
153        Some(PropertyType::U32) => match <[u8; 4]>::try_from(value) {
154            Ok(bytes) => scalar(key, u32::from_le_bytes(bytes)),
155            Err(_) => malformed(value),
156        },
157        Some(PropertyType::I32) => match <[u8; 4]>::try_from(value) {
158            Ok(bytes) => format!("{}", i32::from_le_bytes(bytes)),
159            Err(_) => malformed(value),
160        },
161        Some(PropertyType::Text) => format!(
162            "{:?}",
163            String::from_utf8_lossy(value).trim_end_matches('\0')
164        ),
165        Some(PropertyType::Key32) => address(value),
166        Some(PropertyType::Status) => format!("{:?}", umsh::ulcp::decode_status(value)),
167        Some(PropertyType::Bytes) | None => hex(value),
168    }
169}
170
171/// A number with the unit the property carries, where it has one worth
172/// saying. The bare number stays visible: it is what a `set` takes back.
173fn scalar(key: u32, value: u32) -> String {
174    use umsh::ulcp_wire::ids::prop;
175    match key {
176        prop::PHY_FREQ => format!("{value} kHz"),
177        prop::PHY_LORA_BW => format!("{value} Hz"),
178        prop::PHY_MTU => format!("{value} bytes"),
179        prop::UPTIME | prop::ADVERT_INTERVAL | prop::BEACON_INTERVAL => {
180            format!("{value} s ({})", super::format_duration(value))
181        }
182        prop::SAVED => match value as u8 {
183            saved::NONE => "0 (nothing saved)".to_string(),
184            saved::CURRENT => "1 (current)".to_string(),
185            saved::FALLBACK => "2 (running an older generation)".to_string(),
186            saved::UNREADABLE => "3 (unreadable)".to_string(),
187            _ => value.to_string(),
188        },
189        _ => value.to_string(),
190    }
191}
192
193fn malformed(value: &[u8]) -> String {
194    format!("{} (unexpected length)", hex(value))
195}
196
197/// Turn what somebody typed into the octets a property takes.
198///
199/// A property whose shape this tool does not know takes hex and nothing
200/// else: guessing at the encoding of a structure would write something
201/// plausible and wrong.
202pub fn encode_value(key: u32, text: &str) -> Result<Vec<u8>> {
203    let name = spell(key);
204    match property_type(key) {
205        Some(PropertyType::Bool) => match text {
206            "on" | "true" | "1" => Ok(vec![1]),
207            "off" | "false" | "0" => Ok(vec![0]),
208            other => bail!("{name} is on or off, not {other:?}"),
209        },
210        Some(PropertyType::U8) => Ok(vec![number::<u8>(&name, text)?]),
211        Some(PropertyType::I8) => Ok(vec![number::<i8>(&name, text)? as u8]),
212        Some(PropertyType::U16) => Ok(number::<u16>(&name, text)?.to_le_bytes().to_vec()),
213        Some(PropertyType::I16) => Ok(number::<i16>(&name, text)?.to_le_bytes().to_vec()),
214        Some(PropertyType::U32) => Ok(number::<u32>(&name, text)?.to_le_bytes().to_vec()),
215        Some(PropertyType::I32) => Ok(number::<i32>(&name, text)?.to_le_bytes().to_vec()),
216        // The device adds the terminator the wire requires; a string
217        // written here is the string meant.
218        Some(PropertyType::Text) => Ok(text.as_bytes().to_vec()),
219        Some(PropertyType::Key32) => parse_key32(text)
220            .map(|key| key.to_vec())
221            .map_err(|error| anyhow::anyhow!("{name}: {error}")),
222        Some(PropertyType::Status) => {
223            bail!("{name} reports what the device last did; it is not written")
224        }
225        Some(PropertyType::Bytes) | None => text
226            .parse::<BytesArg>()
227            .map(|bytes| bytes.0)
228            .map_err(|error| anyhow::anyhow!("{name} takes hex octets: {error}")),
229    }
230}
231
232fn number<T>(name: &str, text: &str) -> Result<T>
233where
234    T: FromStr,
235    T::Err: fmt::Display,
236{
237    text.parse::<T>()
238        .map_err(|error| anyhow::anyhow!("{name}: {error}"))
239}
240
241// ─── The commands ────────────────────────────────────────────────────
242
243/// Read properties, named or numbered.
244pub async fn get<L: FrameLink>(
245    device: &mut UlcpDevice<L>,
246    keys: &[PropArg],
247    raw: bool,
248) -> Result<()> {
249    // One key is one exchange either way, and a device that never
250    // learned the multi-property commands must still answer.
251    let batched = keys.len() > 1
252        && device
253            .capabilities()
254            .await
255            .is_ok_and(|caps| caps.contains(&umsh::ulcp_wire::ids::cap::CMD_MULTI));
256    let numbers: Vec<u32> = keys.iter().map(|key| key.0).collect();
257
258    if batched {
259        let answers = device.read_each(&numbers).await?;
260        for (key, answer) in keys.iter().zip(answers) {
261            report(*key, answer.as_deref().map_err(|status| *status), raw);
262        }
263        return Ok(());
264    }
265    for key in keys {
266        let answer = match device.get_prop(key.0).await {
267            Ok(value) => Ok(value),
268            Err(umsh::ulcp::UlcpError::Status(status)) => Err(status),
269            Err(error) => return Err(error.into()),
270        };
271        report(*key, answer.as_deref().map_err(|status| *status), raw);
272    }
273    Ok(())
274}
275
276fn report(key: PropArg, answer: Result<&[u8], umsh::ulcp_wire::Status>, raw: bool) {
277    let label = spell(key.0);
278    match answer {
279        Ok(value) if raw => field(&label, hex(value)),
280        Ok(value) => field(&label, format_value(key.0, value)),
281        Err(status) => field(&label, format!("refused: {status:?}")),
282    }
283}
284
285/// Write one property, reporting what the device stored.
286pub async fn set<L: FrameLink>(
287    device: &mut UlcpDevice<L>,
288    key: PropArg,
289    value: &str,
290    raw: bool,
291) -> Result<()> {
292    let encoded = encode_value(key.0, value)?;
293    let stored = device.set_prop(key.0, &encoded).await?;
294    let label = spell(key.0);
295    if raw {
296        field(&label, hex(&stored));
297    } else {
298        field(&label, format_value(key.0, &stored));
299    }
300    // The device's echo is authoritative: a value it clamped or ignored
301    // is worth seeing said so, not assumed.
302    if stored != encoded {
303        crate::output::note(format!(
304            "the device stored {}, not {}",
305            format_value(key.0, &stored),
306            format_value(key.0, &encoded)
307        ));
308    }
309    Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use umsh::core::PublicKey;
316    use umsh::ulcp_wire::ids::prop;
317
318    #[test]
319    fn a_property_is_named_however_it_is_written() {
320        for spelling in [
321            "phy-freq",
322            "PHY_FREQ",
323            "phy_freq",
324            "PROP_PHY_FREQ",
325            "prop-phy-freq",
326            "Phy-Freq",
327        ] {
328            assert_eq!(
329                spelling.parse::<PropArg>().unwrap().0,
330                prop::PHY_FREQ,
331                "{spelling}"
332            );
333        }
334        // And by number, in either base.
335        assert_eq!("35".parse::<PropArg>().unwrap().0, prop::PHY_FREQ);
336        assert_eq!("0x23".parse::<PropArg>().unwrap().0, prop::PHY_FREQ);
337        // A number this build cannot name is still a property.
338        assert_eq!("60000".parse::<PropArg>().unwrap().0, 60_000);
339    }
340
341    #[test]
342    fn a_misspelling_is_refused_with_a_suggestion() {
343        let error = "phy-frequency".parse::<PropArg>().unwrap_err();
344        assert!(error.contains("phy-freq"), "{error}");
345        // Nothing close enough is just unknown.
346        let error = "zzz".parse::<PropArg>().unwrap_err();
347        assert!(!error.contains("did you mean"), "{error}");
348    }
349
350    /// A name must not match a different property that merely starts the
351    /// same way.
352    #[test]
353    fn a_prefix_is_not_a_name() {
354        assert!("phy".parse::<PropArg>().is_err());
355        assert!("gnss".parse::<PropArg>().is_err());
356        assert_eq!(
357            "gnss-enabled".parse::<PropArg>().unwrap().0,
358            prop::GNSS_ENABLED
359        );
360    }
361
362    #[test]
363    fn values_read_as_what_they_are() {
364        assert_eq!(format_value(prop::PHY_ENABLED, &[1]), "on");
365        assert_eq!(format_value(prop::PHY_ENABLED, &[0]), "off");
366        assert_eq!(
367            format_value(prop::PHY_FREQ, &906_875u32.to_le_bytes()),
368            "906875 kHz"
369        );
370        assert_eq!(format_value(prop::PHY_TX_POWER, &[0xF7]), "-9");
371        assert_eq!(format_value(prop::DEV_NAME, b"T-Echo\0"), "\"T-Echo\"");
372        assert_eq!(
373            format_value(prop::DEV_KEY, &[0xC4; 32]),
374            PublicKey([0xC4; 32]).to_string()
375        );
376        // Structured values stay octets rather than being guessed at.
377        assert_eq!(format_value(prop::BATTERY, &[0b101, 0x74, 0x0E]), "05740e");
378        // Empty is a real answer for a great many properties.
379        assert_eq!(format_value(prop::GNSS_LOCATION, &[]), "(empty)");
380        // A scalar of the wrong width says so rather than reading as
381        // some other number.
382        assert!(format_value(prop::PHY_FREQ, &[1, 2]).contains("unexpected length"));
383    }
384
385    #[test]
386    fn values_are_written_in_the_form_they_are_read() {
387        assert_eq!(encode_value(prop::PHY_ENABLED, "on").unwrap(), vec![1]);
388        assert_eq!(encode_value(prop::PHY_ENABLED, "0").unwrap(), vec![0]);
389        assert_eq!(
390            encode_value(prop::PHY_FREQ, "906875").unwrap(),
391            906_875u32.to_le_bytes()
392        );
393        assert_eq!(encode_value(prop::PHY_TX_POWER, "-9").unwrap(), vec![0xF7]);
394        assert_eq!(
395            encode_value(prop::TZ_OFFSET, "-480").unwrap(),
396            (-480i16).to_le_bytes()
397        );
398        assert_eq!(
399            encode_value(prop::DEV_NAME, "Repeater 3").unwrap(),
400            b"Repeater 3".to_vec()
401        );
402        assert_eq!(
403            encode_value(prop::DEV_KEY, &"c4".repeat(32)).unwrap(),
404            vec![0xC4; 32]
405        );
406        // Hex for anything whose shape this tool does not know.
407        assert_eq!(
408            encode_value(prop::IDENT_LOCATION, "8a1f4c").unwrap(),
409            vec![0x8A, 0x1F, 0x4C]
410        );
411    }
412
413    #[test]
414    fn a_value_out_of_range_is_refused_rather_than_wrapped() {
415        assert!(encode_value(prop::PHY_LORA_SF, "300").is_err());
416        assert!(encode_value(prop::PHY_TX_POWER, "200").is_err());
417        assert!(encode_value(prop::PHY_ENABLED, "maybe").is_err());
418        assert!(encode_value(prop::DEV_KEY, "nonsense").is_err());
419        // The status is a report, not a setting.
420        assert!(encode_value(prop::LAST_STATUS, "0").is_err());
421    }
422
423    #[test]
424    fn a_property_spells_itself_the_way_it_is_typed() {
425        assert_eq!(spell(prop::PHY_FREQ), "phy-freq");
426        assert_eq!(spell(prop::GNSS_TIME_TRUST), "gnss-time-trust");
427        assert_eq!(spell(60_000), "60000");
428        // And every spelling round-trips back to its own property.
429        for &key in PROPERTIES {
430            assert_eq!(
431                spell(key).parse::<PropArg>().map(|arg| arg.0).ok(),
432                Some(key),
433                "{}",
434                spell(key)
435            );
436        }
437    }
438}