1pub 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 #[arg(long, value_name = "KEY")]
32 pub expect_host_key: Option<KeyArg>,
33
34 #[arg(long)]
36 pub env: bool,
37
38 #[arg(value_name = "TOPIC", value_parser = topic_parser())]
43 pub topic: Option<String>,
44}
45
46fn 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 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 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 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 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 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
179pub 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
199pub 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 #[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 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 #[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 assert_eq!(value(&lines, "tx power").as_deref(), Some("-9 dBm"));
331 }
332
333 #[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 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 #[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 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}