umshctl/command/info/
topics.rs

1//! What `info` reports, subject by subject.
2//!
3//! Each topic names the properties it needs, renders them as report
4//! lines, and renders them again as shell assignments. Nothing here
5//! touches a device: a topic is a function from fetched octets to text,
6//! which is what lets the whole report cost one exchange and lets every
7//! rendering be tested without a radio.
8
9use 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
23/// What the renderers know beyond the properties themselves.
24pub struct Context {
25    /// The device's advertised capability codes.
26    pub caps: Vec<u32>,
27    /// Whether this device is reached over the mesh, where the
28    /// host domain is not visible at all.
29    pub remote: bool,
30    /// `--expect-host-key`, which turns the host ownership line from a
31    /// statement into a verdict.
32    pub expect_host_key: Option<[u8; 32]>,
33    /// Read during the attach handshake, so free to print.
34    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
44/// One line of a report: a label and what it says.
45pub type Line = (String, String);
46
47/// One subject the device can be asked about.
48pub struct Topic {
49    /// What to type after `info`.
50    pub name: &'static str,
51    /// What `--env` prefixes this topic's variables with.
52    pub prefix: &'static str,
53    /// Whether this device has anything to say on the subject.
54    pub gate: fn(&Context) -> bool,
55    /// The properties the renderers below read.
56    pub keys: fn(&Context) -> Vec<u32>,
57    pub render: fn(&PropSet, &Context) -> Vec<Line>,
58    /// `NAME=VALUE` pairs, unprefixed — the caller adds [`Self::prefix`].
59    ///
60    /// A component the device did not report is an *absent* variable
61    /// rather than an empty one, so `${RADIO_SF:-}` is how a script asks
62    /// whether there is a spreading factor at all.
63    pub env: fn(&PropSet, &Context) -> Vec<Line>,
64}
65
66/// Every subject, in the order a bare report prints them.
67pub 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        // The host domain is a wire relationship: the mesh binding
199        // cannot see it, so over a mesh session the topic is not offered
200        // rather than offered and refused.
201        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
209/// The topic of that name, if there is one.
210pub fn topic(name: &str) -> Option<&'static Topic> {
211    TOPICS.iter().find(|topic| topic.name == name)
212}
213
214// ─── device ──────────────────────────────────────────────────────────
215
216fn 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
291/// A status as the bare mnemonic, without the Rust path a debug format
292/// carries. `[ "$DEVICE_STATUS" = RESET_POWER_ON ]` is what a script
293/// wants to write, and a status this build cannot name still reads as
294/// something a script can compare.
295fn 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
314/// The spec mnemonic, or the bare number for a capability this build has
315/// never heard of — a device newer than the tool still reports honestly.
316fn capability_name(code: u32) -> String {
317    umsh::ulcp_wire::capability_name(code).map_or_else(|| code.to_string(), str::to_owned)
318}
319
320// ─── radio ───────────────────────────────────────────────────────────
321
322fn 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
437// ─── statistics ──────────────────────────────────────────────────────
438
439const 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
503// ─── battery ─────────────────────────────────────────────────────────
504
505fn 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
512/// Kept exactly as it has always been spelled: scripts read these names,
513/// and the point of an interface a shell `eval`s is that it does not move.
514fn 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
544// ─── identity ────────────────────────────────────────────────────────
545
546fn 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
633/// Decode the minimal-length signed integer `PROP_IDENT_ALTITUDE` uses:
634/// as many octets as the value needs, big-endian, sign-extended from the
635/// first.
636fn 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
648/// The approximate cell size one precision names, at the equator. What
649/// makes a precision meaningful is how large an area it discloses.
650fn 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
663// ─── repeater ────────────────────────────────────────────────────────
664
665fn 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    // The gates are inert while forwarding is off, and printing five
669    // settings that do nothing invites reading them as if they did.
670    if !enabled {
671        return lines;
672    }
673    lines.push((
674        "regions".into(),
675        crate::command::repeater::format_regions(&regions(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
728// ─── advert ──────────────────────────────────────────────────────────
729
730fn 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
765// ─── gnss ────────────────────────────────────────────────────────────
766
767fn 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
856// ─── time ────────────────────────────────────────────────────────────
857
858fn 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        // Empty is the device saying it does not know, which is a
867        // different answer from a device without a clock at all.
868        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
885// ─── sensors ─────────────────────────────────────────────────────────
886
887fn 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
901// ─── host ────────────────────────────────────────────────────────────
902
903fn 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    // An unclaimed device has no key to report, so the variable is
971    // absent and CLAIMED is what a script tests.
972    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
996/// Who this device answers to, and whether that is who was expected.
997fn 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
1022/// Split a digest table of fixed-width items. A table whose length is
1023/// not a multiple of the item width is not one this can read, and saying
1024/// so beats printing a plausible-looking prefix.
1025fn 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
1033// ─── shared spellings ────────────────────────────────────────────────
1034
1035fn on_off(value: bool) -> &'static str {
1036    if value { "on" } else { "off" }
1037}
1038
1039/// Booleans in `--env` output are 0 or 1: `[ "$RADIO_ENABLED" = 1 ]` is
1040/// the shell's natural test, and "on" would only invite string
1041/// comparison against a word that might change.
1042fn bit(value: bool) -> String {
1043    u8::from(value).to_string()
1044}
1045
1046/// Status codes and keys are safe by construction, but a device name is
1047/// whatever somebody typed into it. Everything that could carry a space
1048/// or a quote goes through this before a shell sees it.
1049pub 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
1060/// A refusal worth reporting rather than passing over in silence.
1061///
1062/// Absent is the ordinary answer for an optional property, so a topic
1063/// prints nothing for one. A device that refused for a reason of its own
1064/// is different, and worth one line.
1065pub 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}