1use 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#[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
57fn 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
65fn 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
89fn 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
112pub 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
124pub fn format_value(key: u32, value: &[u8]) -> String {
131 if value.is_empty() {
132 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
171fn 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
197pub 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 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
241pub async fn get<L: FrameLink>(
245 device: &mut UlcpDevice<L>,
246 keys: &[PropArg],
247 raw: bool,
248) -> Result<()> {
249 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
285pub 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 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 assert_eq!("35".parse::<PropArg>().unwrap().0, prop::PHY_FREQ);
336 assert_eq!("0x23".parse::<PropArg>().unwrap().0, prop::PHY_FREQ);
337 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 let error = "zzz".parse::<PropArg>().unwrap_err();
347 assert!(!error.contains("did you mean"), "{error}");
348 }
349
350 #[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 assert_eq!(format_value(prop::BATTERY, &[0b101, 0x74, 0x0E]), "05740e");
378 assert_eq!(format_value(prop::GNSS_LOCATION, &[]), "(empty)");
380 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 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 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 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}