1use umsh::core::{PublicKey, RegionCode};
10use umsh::node::location::NodeLocation;
11use umsh::ulcp::{decode_capabilities, decode_filter_table, decode_status};
12use umsh::ulcp_wire::Status;
13use umsh::ulcp_wire::battery::BatteryStatus;
14use umsh::ulcp_wire::ids::{DUTY_LIMIT_DISABLED, cap, prop, saved};
15use umsh::ulcp_wire::items;
16
17use super::props::PropSet;
18use super::{battery_display, format_millilux};
19use crate::command::values::FilterArg;
20use crate::command::{duty_percent, format_duration};
21use crate::output::hex;
22
23pub struct Context {
25 pub caps: Vec<u32>,
27 pub remote: bool,
30 pub expect_host_key: Option<[u8; 32]>,
33 pub dev_version: String,
35 pub dev_model: Option<String>,
36}
37
38impl Context {
39 fn has(&self, capability: u32) -> bool {
40 self.caps.contains(&capability)
41 }
42}
43
44pub type Line = (String, String);
46
47pub struct Topic {
49 pub name: &'static str,
51 pub prefix: &'static str,
53 pub gate: fn(&Context) -> bool,
55 pub keys: fn(&Context) -> Vec<u32>,
57 pub render: fn(&PropSet, &Context) -> Vec<Line>,
58 pub env: fn(&PropSet, &Context) -> Vec<Line>,
64}
65
66pub const TOPICS: &[Topic] = &[
68 Topic {
69 name: "device",
70 prefix: "DEVICE",
71 gate: |_| true,
72 keys: |_| {
73 vec![
74 prop::DEV_NAME,
75 prop::PROTOCOL_VERSION,
76 prop::UPTIME,
77 prop::SAVED,
78 prop::BLE_ENABLED,
79 ]
80 },
81 render: render_device,
82 env: env_device,
83 },
84 Topic {
85 name: "radio",
86 prefix: "RADIO",
87 gate: |_| true,
88 keys: radio_keys,
89 render: render_radio,
90 env: env_radio,
91 },
92 Topic {
93 name: "stats",
94 prefix: "STATS",
95 gate: |ctx| ctx.has(cap::STATS),
96 keys: |_| {
97 vec![
98 prop::STAT_TX_PACKETS,
99 prop::STAT_TX_CHANNEL_BUSY,
100 prop::STAT_RX_PACKETS,
101 prop::STAT_RX_BAD_CRC,
102 prop::STAT_RX_NON_UMSH,
103 prop::STAT_RX_ACCEPTED,
104 prop::STAT_FORWARDED,
105 prop::STAT_FORWARD_DROPPED,
106 prop::STAT_FORWARD_CANCELLED,
107 prop::PHY_DUTY_NOW,
108 prop::UPTIME,
109 ]
110 },
111 render: render_stats,
112 env: env_stats,
113 },
114 Topic {
115 name: "battery",
116 prefix: "BATTERY",
117 gate: |ctx| ctx.has(cap::BATTERY),
118 keys: |_| vec![prop::BATTERY],
119 render: render_battery,
120 env: env_battery,
121 },
122 Topic {
123 name: "identity",
124 prefix: "IDENTITY",
125 gate: |ctx| ctx.has(cap::DEV_IDENTITY) || ctx.has(cap::IDENT),
126 keys: identity_keys,
127 render: render_identity,
128 env: env_identity,
129 },
130 Topic {
131 name: "repeater",
132 prefix: "REPEATER",
133 gate: |ctx| ctx.has(cap::REPEATER),
134 keys: |_| {
135 vec![
136 prop::MAC_REPEATER_ENABLED,
137 prop::MAC_REPEATER_REGIONS,
138 prop::MAC_REPEATER_DEFAULT_REGION,
139 prop::MAC_REPEATER_MIN_RSSI,
140 prop::MAC_REPEATER_MIN_SNR,
141 ]
142 },
143 render: render_repeater,
144 env: env_repeater,
145 },
146 Topic {
147 name: "advert",
148 prefix: "ADVERT",
149 gate: |ctx| ctx.has(cap::ADVERT),
150 keys: |_| {
151 vec![
152 prop::ADVERT_INTERVAL,
153 prop::BEACON_INTERVAL,
154 prop::STARTUP_BEACON,
155 ]
156 },
157 render: render_advert,
158 env: env_advert,
159 },
160 Topic {
161 name: "gnss",
162 prefix: "GNSS",
163 gate: |ctx| ctx.has(cap::GNSS),
164 keys: |_| {
165 vec![
166 prop::GNSS_ENABLED,
167 prop::GNSS_FIX,
168 prop::GNSS_SATELLITES,
169 prop::GNSS_LOCATION,
170 prop::GNSS_ALTITUDE,
171 prop::GNSS_PRECISION,
172 prop::GNSS_IDENT_UPDATE,
173 prop::GNSS_IDENT_PRECISION,
174 prop::GNSS_TIME_TRUST,
175 ]
176 },
177 render: render_gnss,
178 env: env_gnss,
179 },
180 Topic {
181 name: "time",
182 prefix: "TIME",
183 gate: |ctx| ctx.has(cap::TIME),
184 keys: |_| vec![prop::TIME, prop::TZ_OFFSET],
185 render: render_time,
186 env: env_time,
187 },
188 Topic {
189 name: "sensors",
190 prefix: "SENSORS",
191 gate: |ctx| ctx.has(cap::ILLUMINANCE),
192 keys: |_| vec![prop::ILLUMINANCE],
193 render: render_sensors,
194 env: env_sensors,
195 },
196 Topic {
197 name: "host",
198 prefix: "HOST",
202 gate: |ctx| !ctx.remote && (ctx.has(cap::HOST_FILTER) || ctx.has(cap::HOST_KEYS)),
203 keys: host_keys,
204 render: render_host,
205 env: env_host,
206 },
207];
208
209pub fn topic(name: &str) -> Option<&'static Topic> {
211 TOPICS.iter().find(|topic| topic.name == name)
212}
213
214fn render_device(set: &PropSet, ctx: &Context) -> Vec<Line> {
217 let mut lines = Vec::new();
218 if let Some(name) = set.text(prop::DEV_NAME) {
219 lines.push(("name".into(), format!("{name:?}")));
220 }
221 lines.push(("firmware".into(), ctx.dev_version.clone()));
222 if let Some(model) = &ctx.dev_model {
223 lines.push(("model".into(), model.clone()));
224 }
225 if let Some([major, minor, ..]) = set.bytes(prop::PROTOCOL_VERSION) {
226 lines.push(("protocol".into(), format!("{major}.{minor}")));
227 }
228 if let Some(status) = set.bytes(prop::LAST_STATUS) {
229 lines.push(("status".into(), format!("{:?}", decode_status(status))));
230 }
231 if let Some(seconds) = set.u32(prop::UPTIME) {
232 lines.push((
233 "uptime".into(),
234 format!("{} ({seconds} s)", format_duration(seconds)),
235 ));
236 }
237 if let Some(state) = set.u8(prop::SAVED) {
238 lines.push((
239 "saved".into(),
240 match state {
241 saved::NONE => "no".to_string(),
242 saved::CURRENT => "yes".to_string(),
243 saved::FALLBACK => "yes, but running on an older generation (re-save)".to_string(),
244 saved::UNREADABLE => "unreadable — booted with defaults".to_string(),
245 other => format!("unknown state {other}"),
246 },
247 ));
248 }
249 if let Some(enabled) = set.bool(prop::BLE_ENABLED) {
250 lines.push(("bluetooth".into(), on_off(enabled).into()));
251 }
252 lines.push(("capabilities".into(), capability_list(set)));
253 lines
254}
255
256fn env_device(set: &PropSet, ctx: &Context) -> Vec<Line> {
257 let mut env = Vec::new();
258 if let Some(name) = set.text(prop::DEV_NAME) {
259 env.push(("NAME".into(), name));
260 }
261 env.push(("FIRMWARE".into(), ctx.dev_version.clone()));
262 if let Some(model) = &ctx.dev_model {
263 env.push(("MODEL".into(), model.clone()));
264 }
265 if let Some(status) = set.bytes(prop::LAST_STATUS) {
266 env.push(("STATUS".into(), status_token(decode_status(status))));
267 }
268 if let Some(seconds) = set.u32(prop::UPTIME) {
269 env.push(("UPTIME_S".into(), seconds.to_string()));
270 }
271 if let Some(state) = set.u8(prop::SAVED) {
272 env.push((
273 "SAVED".into(),
274 u8::from(state == saved::CURRENT).to_string(),
275 ));
276 }
277 if let Some(enabled) = set.bool(prop::BLE_ENABLED) {
278 env.push(("BLUETOOTH".into(), bit(enabled)));
279 }
280 let caps = decode_capabilities(set.bytes(prop::CAPS).unwrap_or_default()).unwrap_or_default();
281 env.push((
282 "CAPABILITIES".into(),
283 caps.iter()
284 .map(|&code| capability_name(code))
285 .collect::<Vec<_>>()
286 .join(" "),
287 ));
288 env
289}
290
291fn status_token(status: Status) -> String {
296 let debug = format!("{status:?}");
297 debug
298 .rsplit_once("::")
299 .map_or(debug.clone(), |(_, name)| name.to_string())
300}
301
302fn capability_list(set: &PropSet) -> String {
303 match decode_capabilities(set.bytes(prop::CAPS).unwrap_or_default()) {
304 Ok(caps) if !caps.is_empty() => caps
305 .iter()
306 .map(|&code| capability_name(code))
307 .collect::<Vec<_>>()
308 .join(" "),
309 Ok(_) => "none".to_string(),
310 Err(_) => "malformed".to_string(),
311 }
312}
313
314fn capability_name(code: u32) -> String {
317 umsh::ulcp_wire::capability_name(code).map_or_else(|| code.to_string(), str::to_owned)
318}
319
320fn radio_keys(ctx: &Context) -> Vec<u32> {
323 let mut keys = vec![
324 prop::PHY_ENABLED,
325 prop::PHY_FREQ,
326 prop::PHY_TX_POWER,
327 prop::PHY_MTU,
328 ];
329 if ctx.has(cap::PHY_LORA) {
330 keys.extend([
331 prop::PHY_LORA_BW,
332 prop::PHY_LORA_SF,
333 prop::PHY_LORA_CR,
334 prop::PHY_LORA_SW,
335 ]);
336 }
337 if ctx.has(cap::PHY_DUTY_LIMIT) {
338 keys.extend([prop::PHY_DUTY_NOW, prop::PHY_DUTY_LIMIT]);
339 }
340 keys
341}
342
343fn render_radio(set: &PropSet, _ctx: &Context) -> Vec<Line> {
344 let mut lines = Vec::new();
345 if let Some(enabled) = set.bool(prop::PHY_ENABLED) {
346 lines.push((
347 "phy".into(),
348 if enabled { "enabled" } else { "disabled" }.into(),
349 ));
350 }
351 if let Some(khz) = set.u32(prop::PHY_FREQ) {
352 lines.push(("frequency".into(), format!("{khz} kHz")));
353 }
354 let mut modulation = Vec::new();
355 if let Some(bw) = set.u32(prop::PHY_LORA_BW) {
356 modulation.push(format!("BW {bw} Hz"));
357 }
358 if let Some(sf) = set.u8(prop::PHY_LORA_SF) {
359 modulation.push(format!("SF{sf}"));
360 }
361 if let Some(cr) = set.u8(prop::PHY_LORA_CR) {
362 modulation.push(format!("CR 4/{cr}"));
363 }
364 if let Some(sw) = set.u16(prop::PHY_LORA_SW) {
365 modulation.push(format!("sync 0x{sw:04x}"));
366 }
367 if !modulation.is_empty() {
368 lines.push(("modulation".into(), modulation.join(", ")));
369 }
370 if let Some(dbm) = set.i8(prop::PHY_TX_POWER) {
371 lines.push(("tx power".into(), format!("{dbm} dBm")));
372 }
373 if let Some(mtu) = set.u16(prop::PHY_MTU) {
374 lines.push(("mtu".into(), format!("{mtu} bytes")));
375 }
376 if set.answered(prop::PHY_DUTY_NOW) || set.answered(prop::PHY_DUTY_LIMIT) {
377 let now = set
378 .u16(prop::PHY_DUTY_NOW)
379 .map_or("unknown".to_string(), |raw| {
380 format!("{:.1}%", duty_percent(raw))
381 });
382 let limit = set
383 .u16(prop::PHY_DUTY_LIMIT)
384 .map_or("unknown".to_string(), duty_limit);
385 lines.push(("duty".into(), format!("now {now}, limit {limit}")));
386 }
387 lines
388}
389
390fn env_radio(set: &PropSet, _ctx: &Context) -> Vec<Line> {
391 let mut env = Vec::new();
392 if let Some(enabled) = set.bool(prop::PHY_ENABLED) {
393 env.push(("ENABLED".into(), bit(enabled)));
394 }
395 if let Some(khz) = set.u32(prop::PHY_FREQ) {
396 env.push(("FREQ_KHZ".into(), khz.to_string()));
397 }
398 if let Some(bw) = set.u32(prop::PHY_LORA_BW) {
399 env.push(("BW_HZ".into(), bw.to_string()));
400 }
401 if let Some(sf) = set.u8(prop::PHY_LORA_SF) {
402 env.push(("SF".into(), sf.to_string()));
403 }
404 if let Some(cr) = set.u8(prop::PHY_LORA_CR) {
405 env.push(("CR".into(), cr.to_string()));
406 }
407 if let Some(dbm) = set.i8(prop::PHY_TX_POWER) {
408 env.push(("TX_DBM".into(), dbm.to_string()));
409 }
410 if let Some(mtu) = set.u16(prop::PHY_MTU) {
411 env.push(("MTU".into(), mtu.to_string()));
412 }
413 if let Some(raw) = set.u16(prop::PHY_DUTY_NOW) {
414 env.push(("DUTY_PERCENT".into(), format!("{:.2}", duty_percent(raw))));
415 }
416 if let Some(raw) = set.u16(prop::PHY_DUTY_LIMIT) {
417 env.push((
418 "DUTY_LIMIT_PERCENT".into(),
419 if raw == DUTY_LIMIT_DISABLED {
420 "0".to_string()
421 } else {
422 format!("{:.2}", duty_percent(raw))
423 },
424 ));
425 }
426 env
427}
428
429fn duty_limit(raw: u16) -> String {
430 if raw == DUTY_LIMIT_DISABLED {
431 "disabled".to_string()
432 } else {
433 format!("{:.1}%", duty_percent(raw))
434 }
435}
436
437const STAT_FIELDS: &[(u32, &str, &str)] = &[
440 (prop::STAT_TX_PACKETS, "tx packets", "TX_PACKETS"),
441 (
442 prop::STAT_TX_CHANNEL_BUSY,
443 "tx channel busy",
444 "TX_CHANNEL_BUSY",
445 ),
446 (prop::STAT_RX_PACKETS, "rx UMSH", "RX_PACKETS"),
447 (prop::STAT_RX_BAD_CRC, "rx bad CRC", "RX_BAD_CRC"),
448 (prop::STAT_RX_NON_UMSH, "rx non-UMSH", "RX_NON_UMSH"),
449 (prop::STAT_RX_ACCEPTED, "rx accepted", "RX_ACCEPTED"),
450 (prop::STAT_FORWARDED, "forwarded", "FORWARDED"),
451 (
452 prop::STAT_FORWARD_DROPPED,
453 "forward policy drops",
454 "FORWARD_DROPPED",
455 ),
456 (
457 prop::STAT_FORWARD_CANCELLED,
458 "forwards cancelled",
459 "FORWARD_CANCELLED",
460 ),
461];
462
463fn render_stats(set: &PropSet, _ctx: &Context) -> Vec<Line> {
464 let mut lines = STAT_FIELDS
465 .iter()
466 .filter_map(|&(key, label, _)| {
467 set.u32(key)
468 .map(|value| (label.to_string(), value.to_string()))
469 })
470 .collect::<Vec<_>>();
471 if let Some(raw) = set.u16(prop::PHY_DUTY_NOW) {
472 lines.push(("tx duty cycle".into(), format!("{:.1}%", duty_percent(raw))));
473 }
474 if let Some(seconds) = set.u32(prop::UPTIME) {
475 lines.push((
476 "uptime".into(),
477 format!("{} ({seconds} s)", format_duration(seconds)),
478 ));
479 }
480 lines
481}
482
483fn env_stats(set: &PropSet, _ctx: &Context) -> Vec<Line> {
484 let mut env = STAT_FIELDS
485 .iter()
486 .filter_map(|&(key, _, name)| {
487 set.u32(key)
488 .map(|value| (name.to_string(), value.to_string()))
489 })
490 .collect::<Vec<_>>();
491 if let Some(raw) = set.u16(prop::PHY_DUTY_NOW) {
492 env.push((
493 "TX_DUTY_PERCENT".into(),
494 format!("{:.2}", duty_percent(raw)),
495 ));
496 }
497 if let Some(seconds) = set.u32(prop::UPTIME) {
498 env.push(("UPTIME_S".into(), seconds.to_string()));
499 }
500 env
501}
502
503fn render_battery(set: &PropSet, _ctx: &Context) -> Vec<Line> {
506 match battery_status(set) {
507 Some(status) => vec![("battery".into(), battery_display(&status))],
508 None => vec![("battery".into(), "not battery powered".into())],
509 }
510}
511
512fn env_battery(set: &PropSet, _ctx: &Context) -> Vec<Line> {
515 let Some(status) = battery_status(set) else {
516 return vec![("PRESENT".into(), "0".into())];
517 };
518 let mut env = vec![("PRESENT".into(), "1".into())];
519 if let Some(percent) = status.level_percent {
520 env.push(("LEVEL".into(), format!("{:.2}", f32::from(percent) / 100.0)));
521 }
522 if let Some(mv) = status.voltage_mv {
523 env.push(("VOLTS".into(), format!("{:.3}", f32::from(mv) / 1000.0)));
524 }
525 if let Some(state) = status.charge_state {
526 env.push(("STATE".into(), charge_state_name(state).into()));
527 }
528 env
529}
530
531fn battery_status(set: &PropSet) -> Option<BatteryStatus> {
532 BatteryStatus::decode(set.non_empty(prop::BATTERY)?).ok()
533}
534
535fn charge_state_name(state: umsh::ulcp_wire::battery::BatteryChargeState) -> &'static str {
536 use umsh::ulcp_wire::battery::BatteryChargeState::{Charged, Charging, Discharging};
537 match state {
538 Discharging => "DISCHARGING",
539 Charging => "CHARGING",
540 Charged => "CHARGED",
541 }
542}
543
544fn identity_keys(ctx: &Context) -> Vec<u32> {
547 let mut keys = Vec::new();
548 if ctx.has(cap::DEV_IDENTITY) {
549 keys.extend([prop::DEV_KEY, prop::DEV_DISCOVERABLE]);
550 }
551 if ctx.has(cap::IDENT) {
552 keys.extend([
553 prop::IDENT_ROLE,
554 prop::IDENT_MOBILE,
555 prop::IDENT_LOCATION,
556 prop::IDENT_ALTITUDE,
557 ]);
558 }
559 keys
560}
561
562fn render_identity(set: &PropSet, ctx: &Context) -> Vec<Line> {
563 let mut lines = Vec::new();
564 if ctx.has(cap::DEV_IDENTITY) {
565 lines.push((
566 "key".into(),
567 match set.key32(prop::DEV_KEY) {
568 Some(key) => PublicKey(key).to_string(),
569 None => "none (run `identity generate`)".to_string(),
570 },
571 ));
572 }
573 if let Some(role) = set.u8(prop::IDENT_ROLE) {
574 lines.push(("role".into(), role.to_string()));
575 } else if set.answered(prop::IDENT_ROLE) {
576 lines.push(("role".into(), "derived from what the device does".into()));
577 }
578 if let Some(mobile) = set.bool(prop::IDENT_MOBILE) {
579 lines.push((
580 "mobility".into(),
581 if mobile { "mobile" } else { "fixed" }.into(),
582 ));
583 }
584 if let Some(discoverable) = set.bool(prop::DEV_DISCOVERABLE) {
585 lines.push(("discoverable".into(), on_off(discoverable).into()));
586 }
587 match set.non_empty(prop::IDENT_LOCATION) {
588 Some(location) => {
589 lines.push((
590 "location".into(),
591 format!(
592 "{} ({} bytes, {})",
593 NodeLocation::from_bytes(location),
594 location.len(),
595 precision_cell(location.len() as u8)
596 ),
597 ));
598 }
599 None if set.answered(prop::IDENT_LOCATION) => {
600 lines.push(("location".into(), "none advertised".into()));
601 }
602 None => {}
603 }
604 if let Some(meters) = set.non_empty(prop::IDENT_ALTITUDE).and_then(decode_sint) {
605 lines.push(("altitude".into(), format!("{meters} m")));
606 }
607 lines
608}
609
610fn env_identity(set: &PropSet, _ctx: &Context) -> Vec<Line> {
611 let mut env = Vec::new();
612 if let Some(key) = set.key32(prop::DEV_KEY) {
613 env.push(("KEY".into(), PublicKey(key).to_string()));
614 }
615 if let Some(role) = set.u8(prop::IDENT_ROLE) {
616 env.push(("ROLE".into(), role.to_string()));
617 }
618 if let Some(mobile) = set.bool(prop::IDENT_MOBILE) {
619 env.push(("MOBILE".into(), bit(mobile)));
620 }
621 if let Some(discoverable) = set.bool(prop::DEV_DISCOVERABLE) {
622 env.push(("DISCOVERABLE".into(), bit(discoverable)));
623 }
624 if let Some(location) = set.non_empty(prop::IDENT_LOCATION) {
625 env.push(("LOCATION".into(), hex(location)));
626 }
627 if let Some(meters) = set.non_empty(prop::IDENT_ALTITUDE).and_then(decode_sint) {
628 env.push(("ALTITUDE_M".into(), meters.to_string()));
629 }
630 env
631}
632
633fn decode_sint(value: &[u8]) -> Option<i32> {
637 let (&first, _) = value.split_first()?;
638 if value.len() > 4 {
639 return None;
640 }
641 let mut wide = if first & 0x80 != 0 { -1i32 } else { 0 };
642 for &byte in value {
643 wide = (wide << 8) | i32::from(byte);
644 }
645 Some(wide)
646}
647
648fn precision_cell(bytes: u8) -> &'static str {
651 match bytes {
652 1 => "~2500 km",
653 2 => "~156 km",
654 3 => "~9.8 km",
655 4 => "~610 m",
656 5 => "~38 m",
657 6 => "~2.4 m",
658 7 => "~15 cm",
659 _ => "out of range",
660 }
661}
662
663fn render_repeater(set: &PropSet, _ctx: &Context) -> Vec<Line> {
666 let enabled = set.bool(prop::MAC_REPEATER_ENABLED).unwrap_or(false);
667 let mut lines = vec![("forwarding".into(), on_off(enabled).into())];
668 if !enabled {
671 return lines;
672 }
673 lines.push((
674 "regions".into(),
675 crate::command::repeater::format_regions(®ions(set)),
676 ));
677 lines.push((
678 "tags".into(),
679 match default_region(set) {
680 Some(code) => code.to_string(),
681 None => "untagged".to_string(),
682 },
683 ));
684 let rssi = set
685 .non_empty(prop::MAC_REPEATER_MIN_RSSI)
686 .and_then(|value| <[u8; 2]>::try_from(value).ok())
687 .map(i16::from_le_bytes);
688 let snr = set
689 .non_empty(prop::MAC_REPEATER_MIN_SNR)
690 .and_then(|value| value.first().copied())
691 .map(|byte| byte as i8);
692 lines.push((
693 "floor".into(),
694 format!(
695 "{}/{}",
696 rssi.map_or("any".to_string(), |dbm| format!("{dbm} dBm")),
697 snr.map_or("any".to_string(), |db| format!("{db} dB"))
698 ),
699 ));
700 lines
701}
702
703fn env_repeater(set: &PropSet, _ctx: &Context) -> Vec<Line> {
704 let enabled = set.bool(prop::MAC_REPEATER_ENABLED).unwrap_or(false);
705 let mut env = vec![("ENABLED".into(), bit(enabled))];
706 let regions = regions(set);
707 if !regions.is_empty() {
708 env.push(("REGIONS".into(), regions.join(" ")));
709 }
710 if let Some(code) = default_region(set) {
711 env.push(("DEFAULT_REGION".into(), code.to_string()));
712 }
713 env
714}
715
716fn regions(set: &PropSet) -> Vec<String> {
717 set.bytes(prop::MAC_REPEATER_REGIONS)
718 .and_then(|value| umsh::ulcp::decode_region_list(value).ok())
719 .unwrap_or_default()
720}
721
722fn default_region(set: &PropSet) -> Option<RegionCode> {
723 let value = set.non_empty(prop::MAC_REPEATER_DEFAULT_REGION)?;
724 let bytes = <[u8; 2]>::try_from(value).ok()?;
725 Some(RegionCode::from_bytes(bytes))
726}
727
728fn render_advert(set: &PropSet, _ctx: &Context) -> Vec<Line> {
731 let mut lines = Vec::new();
732 if let Some(seconds) = set.u32(prop::ADVERT_INTERVAL) {
733 lines.push(("interval".into(), interval(seconds)));
734 }
735 if let Some(seconds) = set.u32(prop::BEACON_INTERVAL) {
736 lines.push(("beacon".into(), interval(seconds)));
737 }
738 if let Some(startup) = set.bool(prop::STARTUP_BEACON) {
739 lines.push(("at startup".into(), on_off(startup).into()));
740 }
741 lines
742}
743
744fn env_advert(set: &PropSet, _ctx: &Context) -> Vec<Line> {
745 let mut env = Vec::new();
746 if let Some(seconds) = set.u32(prop::ADVERT_INTERVAL) {
747 env.push(("INTERVAL_S".into(), seconds.to_string()));
748 }
749 if let Some(seconds) = set.u32(prop::BEACON_INTERVAL) {
750 env.push(("BEACON_S".into(), seconds.to_string()));
751 }
752 if let Some(startup) = set.bool(prop::STARTUP_BEACON) {
753 env.push(("STARTUP_BEACON".into(), bit(startup)));
754 }
755 env
756}
757
758fn interval(seconds: u32) -> String {
759 if seconds == 0 {
760 return "off".to_string();
761 }
762 format!("every {seconds} s ({})", format_duration(seconds))
763}
764
765fn render_gnss(set: &PropSet, _ctx: &Context) -> Vec<Line> {
768 let enabled = set.bool(prop::GNSS_ENABLED).unwrap_or(false);
769 let mut lines = vec![("receiver".into(), on_off(enabled).into())];
770 if let Some(fix) = set.u8(prop::GNSS_FIX) {
771 lines.push((
772 "fix".into(),
773 match fix {
774 0 if enabled => "none (searching)".to_string(),
775 0 => "none (receiver off)".to_string(),
776 1 => "2D".to_string(),
777 2 => "3D".to_string(),
778 other => format!("unknown quality {other}"),
779 },
780 ));
781 }
782 if let Some([used, rest @ ..]) = set.bytes(prop::GNSS_SATELLITES) {
783 lines.push((
784 "satellites".into(),
785 match rest.first() {
786 Some(in_view) => format!("{used} used of {in_view} in view"),
787 None => format!("{used} used"),
788 },
789 ));
790 }
791 if let Some(location) = set.non_empty(prop::GNSS_LOCATION) {
792 lines.push((
793 "location".into(),
794 format!(
795 "{} ({} bytes, {})",
796 NodeLocation::from_bytes(location),
797 location.len(),
798 precision_cell(location.len() as u8)
799 ),
800 ));
801 }
802 if let Some(meters) = set.i32(prop::GNSS_ALTITUDE) {
803 lines.push(("altitude".into(), format!("{meters} m")));
804 }
805 if let Some(dm) = set.u16(prop::GNSS_PRECISION) {
806 lines.push((
807 "precision".into(),
808 format!("~{}.{} m (estimated)", dm / 10, dm % 10),
809 ));
810 }
811 if let Some(update) = set.bool(prop::GNSS_IDENT_UPDATE) {
812 let clamp = set.u8(prop::GNSS_IDENT_PRECISION).unwrap_or(5);
813 lines.push((
814 "identity update".into(),
815 match update {
816 true => format!("on, clamped to {clamp} bytes ({})", precision_cell(clamp)),
817 false => format!("off (would clamp to {clamp} bytes)"),
818 },
819 ));
820 }
821 if let Some(trust) = set.bool(prop::GNSS_TIME_TRUST) {
822 lines.push((
823 "time trust".into(),
824 match trust {
825 true => "on (fixes set the clock)".to_string(),
826 false => "off (fixes never set the clock)".to_string(),
827 },
828 ));
829 }
830 lines
831}
832
833fn env_gnss(set: &PropSet, _ctx: &Context) -> Vec<Line> {
834 let mut env = Vec::new();
835 if let Some(enabled) = set.bool(prop::GNSS_ENABLED) {
836 env.push(("ENABLED".into(), bit(enabled)));
837 }
838 if let Some(fix) = set.u8(prop::GNSS_FIX) {
839 env.push(("FIX".into(), fix.to_string()));
840 }
841 if let Some(used) = set.u8(prop::GNSS_SATELLITES) {
842 env.push(("SATELLITES".into(), used.to_string()));
843 }
844 if let Some(location) = set.non_empty(prop::GNSS_LOCATION) {
845 env.push(("LOCATION".into(), hex(location)));
846 }
847 if let Some(meters) = set.i32(prop::GNSS_ALTITUDE) {
848 env.push(("ALTITUDE_M".into(), meters.to_string()));
849 }
850 if let Some(dm) = set.u16(prop::GNSS_PRECISION) {
851 env.push(("PRECISION_DM".into(), dm.to_string()));
852 }
853 env
854}
855
856fn render_time(set: &PropSet, _ctx: &Context) -> Vec<Line> {
859 let offset = set.i16(prop::TZ_OFFSET).unwrap_or(0);
860 let mut lines = Vec::new();
861 match set.u32(prop::TIME) {
862 Some(epoch) => {
863 lines.push(("clock".into(), crate::command::time::format_utc(epoch)));
864 lines.push(("epoch".into(), epoch.to_string()));
865 }
866 None => lines.push(("clock".into(), "not set".into())),
869 }
870 lines.push(("zone".into(), crate::command::time::format_tz(offset)));
871 lines
872}
873
874fn env_time(set: &PropSet, _ctx: &Context) -> Vec<Line> {
875 let mut env = Vec::new();
876 if let Some(epoch) = set.u32(prop::TIME) {
877 env.push(("EPOCH".into(), epoch.to_string()));
878 }
879 if let Some(offset) = set.i16(prop::TZ_OFFSET) {
880 env.push(("TZ_MINUTES".into(), offset.to_string()));
881 }
882 env
883}
884
885fn render_sensors(set: &PropSet, _ctx: &Context) -> Vec<Line> {
888 match set.u32(prop::ILLUMINANCE) {
889 Some(millilux) => vec![("illuminance".into(), format_millilux(millilux))],
890 None => vec![("illuminance".into(), "no reading".into())],
891 }
892}
893
894fn env_sensors(set: &PropSet, _ctx: &Context) -> Vec<Line> {
895 match set.u32(prop::ILLUMINANCE) {
896 Some(millilux) => vec![("ILLUMINANCE_MLUX".into(), millilux.to_string())],
897 None => Vec::new(),
898 }
899}
900
901fn host_keys(ctx: &Context) -> Vec<u32> {
904 let mut keys = vec![prop::HOST_KEY];
905 if ctx.has(cap::HOST_FILTER) {
906 keys.push(prop::HOST_RX_FILTERS);
907 }
908 if ctx.has(cap::HOST_KEYS) {
909 keys.extend([prop::HOST_CHANNEL_KEYS, prop::HOST_PEER_KEYS]);
910 }
911 if ctx.has(cap::HOST_AUTO_ACK) {
912 keys.push(prop::HOST_AUTO_ACK);
913 }
914 if ctx.has(cap::HOST_RX_QUEUE) {
915 keys.extend([
916 prop::HOST_RX_QUEUE_COUNT,
917 prop::HOST_RX_QUEUE_CAPACITY,
918 prop::HOST_RX_QUEUE_DROPPED,
919 ]);
920 }
921 keys
922}
923
924fn render_host(set: &PropSet, ctx: &Context) -> Vec<Line> {
925 let mut lines = vec![("owner".into(), ownership(set, ctx))];
926 if let Some(filters) = filters(set) {
927 lines.push(("filters".into(), filter_list(&filters)));
928 }
929 if let Some(ids) = digest_items(set, prop::HOST_CHANNEL_KEYS, 2) {
930 let display = match ids.is_empty() {
931 true => "none".to_string(),
932 false => ids.iter().map(|id| hex(id)).collect::<Vec<_>>().join(", "),
933 };
934 lines.push((
935 "channel keys".into(),
936 format!("{} (ids: {display})", ids.len()),
937 ));
938 }
939 if let Some(peers) = digest_items(set, prop::HOST_PEER_KEYS, 32) {
940 let display = match peers.is_empty() {
941 true => "none".to_string(),
942 false => peers
943 .iter()
944 .map(|key| crate::output::address(key))
945 .collect::<Vec<_>>()
946 .join(", "),
947 };
948 lines.push(("peer keys".into(), format!("{} ({display})", peers.len())));
949 }
950 if let Some(auto_ack) = set.bool(prop::HOST_AUTO_ACK) {
951 lines.push(("auto-ack".into(), on_off(auto_ack).into()));
952 }
953 if let (Some(count), Some(dropped)) = (
954 set.u16(prop::HOST_RX_QUEUE_COUNT),
955 set.u32(prop::HOST_RX_QUEUE_DROPPED),
956 ) {
957 let capacity = set
958 .u16(prop::HOST_RX_QUEUE_CAPACITY)
959 .map_or("?".to_string(), |capacity| capacity.to_string());
960 lines.push((
961 "rx queue".into(),
962 format!("{count} buffered of {capacity}, {dropped} dropped since boot"),
963 ));
964 }
965 lines
966}
967
968fn env_host(set: &PropSet, ctx: &Context) -> Vec<Line> {
969 let mut env = Vec::new();
970 if set.answered(prop::HOST_KEY) {
973 env.push((
974 "CLAIMED".into(),
975 bit(set.non_empty(prop::HOST_KEY).is_some()),
976 ));
977 }
978 if let Some(key) = set.key32(prop::HOST_KEY) {
979 env.push(("KEY".into(), PublicKey(key).to_string()));
980 if let Some(expected) = ctx.expect_host_key {
981 env.push(("OURS".into(), bit(expected == key)));
982 }
983 }
984 if let Some(auto_ack) = set.bool(prop::HOST_AUTO_ACK) {
985 env.push(("AUTO_ACK".into(), bit(auto_ack)));
986 }
987 if let Some(count) = set.u16(prop::HOST_RX_QUEUE_COUNT) {
988 env.push(("QUEUE_COUNT".into(), count.to_string()));
989 }
990 if let Some(dropped) = set.u32(prop::HOST_RX_QUEUE_DROPPED) {
991 env.push(("QUEUE_DROPPED".into(), dropped.to_string()));
992 }
993 env
994}
995
996fn ownership(set: &PropSet, ctx: &Context) -> String {
998 match (set.non_empty(prop::HOST_KEY), ctx.expect_host_key) {
999 (None, _) if set.answered(prop::HOST_KEY) => "unclaimed (no host)".to_string(),
1000 (None, _) => "unsupported".to_string(),
1001 (Some(key), Some(expected)) if key == expected => "matches --expect-host-key".to_string(),
1002 (Some(key), Some(_)) => format!("ANOTHER HOST: {}", crate::output::address(key)),
1003 (Some(key), None) => crate::output::address(key),
1004 }
1005}
1006
1007fn filters(set: &PropSet) -> Option<Vec<items::Filter>> {
1008 decode_filter_table(set.bytes(prop::HOST_RX_FILTERS)?).ok()
1009}
1010
1011fn filter_list(filters: &[items::Filter]) -> String {
1012 if filters.is_empty() {
1013 return "none".to_string();
1014 }
1015 filters
1016 .iter()
1017 .map(|filter| FilterArg(*filter).to_string())
1018 .collect::<Vec<_>>()
1019 .join(", ")
1020}
1021
1022fn digest_items(set: &PropSet, key: u32, width: usize) -> Option<Vec<&[u8]>> {
1026 let value = set.bytes(key)?;
1027 if !value.len().is_multiple_of(width) {
1028 return None;
1029 }
1030 Some(value.chunks_exact(width).collect())
1031}
1032
1033fn on_off(value: bool) -> &'static str {
1036 if value { "on" } else { "off" }
1037}
1038
1039fn bit(value: bool) -> String {
1043 u8::from(value).to_string()
1044}
1045
1046pub fn shell_quote(value: &str) -> String {
1050 let safe = !value.is_empty()
1051 && value
1052 .chars()
1053 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':' | '+'));
1054 if safe {
1055 return value.to_string();
1056 }
1057 format!("'{}'", value.replace('\'', r"'\''"))
1058}
1059
1060pub fn refusals(set: &PropSet, keys: &[u32]) -> Vec<(u32, Status)> {
1066 keys.iter()
1067 .filter_map(|&key| set.refusal(key).map(|status| (key, status)))
1068 .filter(|(_, status)| !matches!(*status, Status::UNIMPLEMENTED | Status::PROP_NOT_FOUND))
1069 .collect()
1070}