umshctl/command/
manage.rs

1//! `manage`: administering another device over the mesh.
2//!
3//! One Node Management exchange at a time against a device across the
4//! room, carried over the borrowed radio by the host stack in
5//! [`crate::mesh`] — which is also where the administrator identity these
6//! exchanges are signed with lives.
7
8use std::cell::RefCell;
9use std::rc::Rc;
10use std::time::Duration;
11
12use anyhow::{Result, anyhow, bail};
13use rand::{Rng as _, rng};
14use tokio::time::Instant;
15
16use umsh::core::PublicKey;
17use umsh::crypto::software::SoftwareIdentity;
18use umsh::hal::Radio;
19use umsh::node_mgmt::NodeManager;
20use umsh::node_mgmt::admin::Outcome;
21use umsh::ulcp_wire::ids::prop;
22use umsh::ulcp_wire::{Status, capability_name, frame, reply};
23use umsh_sync::AsyncRefCell;
24
25use super::lifecycle::PairState;
26use super::props::PropArg;
27use super::tables::TableOp;
28use super::values::{AssignArg, BytesArg, KeyArg};
29use crate::App;
30use crate::connection::confirm;
31use crate::mesh::{self, CtlHandle, CtlMac, NodeStack, OPERATION_TIMEOUT, describe};
32use crate::output::{address, field, hex, note, subfield};
33
34// ─── Command surface ─────────────────────────────────────────────────────────
35
36#[derive(Debug, clap::Subcommand)]
37pub enum ManageOp {
38    /// Read the device's capabilities and versions in one exchange.
39    Info,
40
41    /// Read one property, by name or number.
42    Get {
43        #[arg(value_name = "PROP")]
44        key: PropArg,
45    },
46
47    /// Write one property, by name or number.
48    Set {
49        #[arg(value_name = "PROP")]
50        key: PropArg,
51        #[arg(value_name = "VALUE")]
52        value: String,
53    },
54
55    /// Add an entry to a table property.
56    Insert {
57        #[arg(value_name = "PROP")]
58        key: PropArg,
59        #[arg(value_name = "HEX")]
60        item: BytesArg,
61    },
62
63    /// Remove an entry from a table property.
64    Remove {
65        #[arg(value_name = "PROP")]
66        key: PropArg,
67        #[arg(value_name = "HEX")]
68        selector: BytesArg,
69    },
70
71    /// Read several properties in one exchange.
72    GetMany {
73        #[arg(value_name = "PROP", required = true)]
74        keys: Vec<PropArg>,
75    },
76
77    /// Write several properties, in order, in one exchange. A failure
78    /// stops the sequence where it happened.
79    SetMany {
80        #[arg(value_name = "PROP=HEX", required = true)]
81        entries: Vec<AssignArg>,
82    },
83
84    /// Persist the device's live state across reboots (CMD_SAVE).
85    Save,
86
87    /// Protocol reset (CMD_RST). Answered by no response: delivery is the
88    /// acknowledgment.
89    Reset,
90
91    /// Restart the device (CMD_REBOOT), keeping everything it has
92    /// persisted. Answered by no response: delivery is the
93    /// acknowledgment, and a device that cannot restart says so.
94    Reboot,
95
96    /// Manage the device's Bluetooth bonds (CAP_BLE). Unlike the
97    /// resets above these answer with a status: an administrator is
98    /// addressing the device's node, not one of its Bluetooth hosts, so
99    /// the link survives even a clear.
100    Ble {
101        #[command(subcommand)]
102        op: BleOp,
103    },
104
105    /// Nodes authorized to manage the device — including this tool.
106    Admins {
107        #[command(subcommand)]
108        op: Option<TableOp>,
109    },
110}
111
112/// Bond management over the mesh.
113#[derive(Debug, clap::Subcommand)]
114pub enum BleOp {
115    /// Open or close the pairing window.
116    Pair {
117        /// The window state to set.
118        #[arg(value_enum, default_value_t = PairState::On)]
119        state: PairState,
120    },
121    /// Forget every paired host, the pairing PIN, and the pairing
122    /// lockout, then open a pairing window.
123    Clear {
124        /// Confirm the wipe. Required outside the REPL, which asks.
125        #[arg(long)]
126        yes: bool,
127    },
128}
129
130// ─── Bring-up ────────────────────────────────────────────────────────────────
131
132/// What the borrowed radio is being lent out for.
133///
134/// Administering a device and asking a repeater who its neighbors are are
135/// different conversations — one is authorized node management, the other a
136/// plain MAC command anyone may send — but both need this tool to stop being
137/// a serial client and become a node on the mesh. That apparatus is what this
138/// enum is here to share.
139#[derive(Debug)]
140pub enum Operation {
141    Manage(ManageOp),
142    /// Ask the target for the repeaters it knows of.
143    PeerRepeaters,
144    /// Measure the path to the target.
145    Ping(super::ping::PingArgs),
146}
147
148/// One operation against one device, waiting for a radio to run on.
149struct Errand {
150    target: PublicKey,
151    op: Operation,
152    no_save: bool,
153}
154
155impl mesh::RadioErrand for Errand {
156    async fn run<R: Radio>(
157        self,
158        mac: &AsyncRefCell<CtlMac<R>>,
159        identity: SoftwareIdentity,
160    ) -> Result<()>
161    where
162        R::Error: core::fmt::Debug,
163    {
164        operate(mac, identity, self.target, self.op, self.no_save).await
165    }
166}
167
168/// Take the attachment over as this tool's radio, run `op` against
169/// `target`, and hand the attachment back.
170pub async fn run(app: &mut App, target: KeyArg, op: Operation) -> Result<()> {
171    // Ask before borrowing the radio: a cancelled wipe should not have
172    // cost an attach, and this is the last point where the terminal is
173    // still ours.
174    if let Operation::Manage(ManageOp::Ble {
175        op: BleOp::Clear { yes: false },
176    }) = &op
177    {
178        const WARNING: &str = "manage ble clear forgets every paired host, the pairing PIN, \
179             and the pairing lockout on the target";
180        if !app.interactive {
181            bail!("{WARNING}; re-run with --yes to confirm");
182        }
183        println!("{WARNING}.");
184        if !confirm("forget every paired host?")? {
185            println!("cancelled");
186            return Ok(());
187        }
188    }
189    let errand = Errand {
190        target: PublicKey(target.0),
191        op,
192        no_save: app.no_save,
193    };
194    mesh::borrowing_the_radio(app, errand).await
195}
196
197async fn operate<R: Radio>(
198    mac: &AsyncRefCell<CtlMac<R>>,
199    identity: SoftwareIdentity,
200    target: PublicKey,
201    op: Operation,
202    no_save: bool,
203) -> Result<()>
204where
205    R::Error: core::fmt::Debug,
206{
207    let (stack, local_key) = NodeStack::build(mac, identity).await?;
208    let peer = stack
209        .node
210        .peer(target)
211        .await
212        .map_err(|error| anyhow!("registering the device as a peer: {error:?}"))?;
213    // What an earlier invocation learned about reaching this node, put
214    // back before the first frame. This is the path that matters most:
215    // `manage` and `ping` are run from scripts, over and over, and each
216    // one used to start by flooding for a route it had just been told.
217    let mut routes = crate::routes::RouteCache::load();
218    if let Some(record) = routes.get(&target) {
219        peer.restore_route(record.route.clone()).await;
220    }
221
222    // The first token has only to be unpredictable; the manager keeps
223    // every one after it distinct from all of its own, and the device
224    // forgets this process's tokens long before another random seed
225    // could land on them.
226    let mut seed = [0u8; 2];
227    rng().fill_bytes(&mut seed);
228    let manager = NodeManager::new(peer, u16::from_be_bytes(seed));
229
230    field("administrator", local_key.to_string());
231    field(
232        match op {
233            Operation::Manage(_) => "device",
234            Operation::PeerRepeaters => "repeater",
235            Operation::Ping(_) => "target",
236        },
237        target.to_string(),
238    );
239
240    let mut ctl = Ctl {
241        stack,
242        target,
243        manager,
244    };
245    let result = run_op(&mut ctl, op, no_save).await;
246    let _ = ctl.stack.handle.service_counter_persistence().await;
247    routes.harvest(&ctl.stack.handle).await;
248    if let Err(error) = routes.store() {
249        crate::output::warn(format!("could not save learned routes: {error:#}"));
250    }
251    result
252}
253
254/// The tool as a node, for the duration of one operation.
255pub struct Ctl<'a, R: Radio> {
256    pub(super) stack: NodeStack<'a, R>,
257    pub(super) target: PublicKey,
258    manager: NodeManager<CtlHandle<'a, R>>,
259}
260
261impl<'a, R: Radio> Ctl<'a, R>
262where
263    R::Error: core::fmt::Debug,
264{
265    /// Carry one exchange to its end, within what is left of the
266    /// operation's budget.
267    async fn exchange(&mut self, request: &[u8]) -> Result<Outcome> {
268        let give_up = self.stack.started() + OPERATION_TIMEOUT;
269        self.stack
270            .exchange(&mut self.manager, request, give_up)
271            .await
272    }
273
274    /// Carry one exchange and insist on a reply frame.
275    async fn reply(&mut self, request: &[u8]) -> Result<Vec<u8>> {
276        match self.exchange(request).await? {
277            Outcome::Replied { .. } => Ok(self.manager.reply().to_vec()),
278            Outcome::NoResponse => bail!("the device answered nothing where a reply was due"),
279            Outcome::Failed(failure) => Err(describe(failure)),
280        }
281    }
282}
283
284// ─── Operations ──────────────────────────────────────────────────────────────
285
286async fn run_op<R: Radio>(ctl: &mut Ctl<'_, R>, op: Operation, no_save: bool) -> Result<()>
287where
288    R::Error: core::fmt::Debug,
289{
290    let op = match op {
291        Operation::Manage(op) => op,
292        Operation::PeerRepeaters => return peer_repeaters(ctl).await,
293        // A ping is bounded by its own count, interval, and per-ping
294        // timeout, so the exchange engine's deadline does not apply.
295        Operation::Ping(args) => return super::ping::run(ctl, args).await,
296    };
297    match op {
298        ManageOp::Info => info(ctl).await,
299        ManageOp::Get { key } => {
300            let reply = ctl
301                .reply(&encode(|buf| frame::prop_get(buf, 0, key.0))?)
302                .await?;
303            report_value(key.0, &reply)
304        }
305        ManageOp::Set { key, value } => {
306            let encoded = super::props::encode_value(key.0, &value)?;
307            let reply = ctl
308                .reply(&encode(|buf| frame::prop_set(buf, 0, key.0, &encoded))?)
309                .await?;
310            report_value(key.0, &reply)?;
311            save_if_asked(ctl, no_save).await
312        }
313        ManageOp::Insert { key, item } => {
314            let reply = ctl
315                .reply(&encode(|buf| frame::prop_insert(buf, 0, key.0, &item.0))?)
316                .await?;
317            report_value(key.0, &reply)?;
318            save_if_asked(ctl, no_save).await
319        }
320        ManageOp::Remove { key, selector } => {
321            let reply = ctl
322                .reply(&encode(|buf| {
323                    frame::prop_remove(buf, 0, key.0, &selector.0)
324                })?)
325                .await?;
326            report_value(key.0, &reply)?;
327            save_if_asked(ctl, no_save).await
328        }
329        ManageOp::GetMany { keys } => {
330            let numbers: Vec<u32> = keys.iter().map(|key| key.0).collect();
331            let reply = ctl
332                .reply(&encode(|buf| frame::prop_multi_get(buf, 0, &numbers))?)
333                .await?;
334            report_entries(&numbers, &reply, "reissue the rest")
335        }
336        ManageOp::SetMany { entries } => {
337            let keys: Vec<u32> = entries.iter().map(|entry| entry.0).collect();
338            let borrowed: Vec<(u32, &[u8])> = entries
339                .iter()
340                .map(|entry| (entry.0, entry.1.as_slice()))
341                .collect();
342            let reply = ctl
343                .reply(&encode(|buf| frame::prop_multi_set(buf, 0, &borrowed))?)
344                .await?;
345            report_entries(
346                &keys,
347                &reply,
348                "the sequence stopped there; reissue the remainder",
349            )?;
350            save_if_asked(ctl, no_save).await
351        }
352        ManageOp::Save => {
353            let reply = ctl.reply(&encode(|buf| frame::save(buf, 0))?).await?;
354            report_value(prop::LAST_STATUS, &reply)
355        }
356        ManageOp::Reset => {
357            match ctl.exchange(&encode(|buf| frame::reset(buf, 0))?).await? {
358                Outcome::NoResponse => {
359                    println!("reset delivered; the device answers a reset with nothing");
360                    Ok(())
361                }
362                // `CMD_RESTORE` on a device with no snapshot resets
363                // nothing and answers like any other command, so a reply
364                // is not a protocol violation — it is an answer.
365                Outcome::Replied { .. } => report_value(prop::LAST_STATUS, ctl.manager.reply()),
366                Outcome::Failed(failure) => Err(describe(failure)),
367            }
368        }
369        ManageOp::Reboot => {
370            match ctl.exchange(&encode(|buf| frame::reboot(buf, 0))?).await? {
371                Outcome::NoResponse => {
372                    println!("reboot delivered; the device is restarting");
373                    Ok(())
374                }
375                // The one answer this command produces: a device without
376                // `CAP_REBOOT` saying it cannot restart itself.
377                Outcome::Replied { .. } => report_value(prop::LAST_STATUS, ctl.manager.reply()),
378                Outcome::Failed(failure) => Err(describe(failure)),
379            }
380        }
381        ManageOp::Ble { op } => match op {
382            // The window is a property, so opening and closing it is an
383            // ordinary write the reply quotes back.
384            BleOp::Pair { state } => {
385                let open = state == PairState::On;
386                let reply = ctl
387                    .reply(&encode(|buf| {
388                        frame::prop_set(buf, 0, prop::BLE_PAIRING, &[open as u8])
389                    })?)
390                    .await?;
391                report_value(prop::BLE_PAIRING, &reply)
392            }
393            BleOp::Clear { .. } => {
394                // Confirmed in `run`, before the radio was borrowed.
395                let frame = encode(|buf| frame::ble_clear_bonds(buf, 0))?;
396                match ctl.exchange(&frame).await? {
397                    Outcome::Replied { .. } => report_value(prop::LAST_STATUS, ctl.manager.reply()),
398                    Outcome::NoResponse => {
399                        bail!("the device answered nothing; this command reports a status")
400                    }
401                    Outcome::Failed(failure) => Err(describe(failure)),
402                }
403            }
404        },
405        ManageOp::Admins { op } => admins(ctl, op.unwrap_or(TableOp::List), no_save).await,
406    }
407}
408
409/// `peer-repeaters`: ask the target which repeaters it knows of.
410///
411/// Not node management — it is a plain MAC command any node may send, and
412/// the target need not list this tool as an administrator. It reuses the
413/// same borrowed radio because the tool still has to be a node to ask.
414async fn peer_repeaters<R: Radio>(ctl: &mut Ctl<'_, R>) -> Result<()>
415where
416    R::Error: core::fmt::Debug,
417{
418    use umsh::node::OwnedMacCommand;
419    use umsh::node::mac_command::PeerRepeatersResponseView;
420
421    // Answers arrive asynchronously on the receive path, so they are
422    // collected by a subscription and matched to the page that asked.
423    let pages: Rc<RefCell<Vec<Vec<u8>>>> = Rc::new(RefCell::new(Vec::new()));
424    let sink = pages.clone();
425    let target = ctl.target;
426    let _subscription = ctl.stack.node.on_mac_command(move |from, command| {
427        if from != target {
428            return;
429        }
430        if let OwnedMacCommand::PeerRepeatersResponse { body } = command {
431            sink.borrow_mut().push(body.clone());
432        }
433    });
434
435    let peer = ctl
436        .stack
437        .node
438        .peer(target)
439        .await
440        .map_err(|error| anyhow!("registering the repeater as a peer: {error:?}"))?;
441
442    let mut seed = [0u8; 2];
443    rng().fill_bytes(&mut seed);
444    let mut nonce = u16::from_be_bytes(seed);
445    let mut cursor: Option<Vec<u8>> = None;
446    let mut listed = 0usize;
447    let mut total: Option<u8> = None;
448
449    loop {
450        pages.borrow_mut().clear();
451        peer.request_peer_repeaters(nonce, cursor.as_deref(), &Default::default())
452            .await
453            .map_err(|error| anyhow!("asking for the listing: {error:?}"))?;
454
455        let deadline = Instant::now() + PEER_REPEATERS_PAGE_TIMEOUT;
456        let body = loop {
457            if let Some(body) = pages
458                .borrow()
459                .iter()
460                .find(|body| PeerRepeatersResponseView::new(body).nonce() == Some(nonce))
461                .cloned()
462            {
463                break Some(body);
464            }
465            if Instant::now() >= deadline {
466                break None;
467            }
468            ctl.stack.pump_until(deadline).await?;
469        };
470
471        let Some(body) = body else {
472            if listed == 0 {
473                bail!(
474                    "no answer — the repeater may be out of range, or may not answer peer-repeater \
475                     requests"
476                );
477            }
478            note("the listing stopped part way; run the command again to start over");
479            break;
480        };
481
482        let view = PeerRepeatersResponseView::new(&body);
483        total = total.or_else(|| view.total());
484        for entry in view.entries() {
485            print_peer_repeater(&entry);
486            listed += 1;
487        }
488        match view.cursor() {
489            Some(next) => cursor = Some(next.to_vec()),
490            None => break,
491        }
492        // A fresh nonce per page, so a late copy of the previous answer
493        // cannot be mistaken for this one.
494        nonce = nonce.wrapping_add(1);
495    }
496
497    match total {
498        Some(total) if usize::from(total) != listed => {
499            note(format!("listed {listed} of {total} peer repeaters"))
500        }
501        _ if listed == 0 => note("the repeater knows of no peers"),
502        _ => {}
503    }
504    Ok(())
505}
506
507/// How long one page may take before the tool gives up on it.
508const PEER_REPEATERS_PAGE_TIMEOUT: Duration = Duration::from_secs(30);
509
510fn print_peer_repeater(entry: &umsh::node::mac_command::PeerRepeaterEntryView<'_>) {
511    let Some(hint) = entry.hint() else {
512        // An entry that names nobody is not an entry; the responder is
513        // still describing a real neighborhood around it.
514        return;
515    };
516    field(
517        "peer",
518        match entry.name() {
519            Some(name) => format!("{} ({name})", hex(hint)),
520            None => hex(hint),
521        },
522    );
523    if let Some((rssi, snr)) = entry.rssi_snr() {
524        subfield("signal", format!("{rssi} dBm, {snr}"));
525    }
526    if let Some(minutes) = entry.last_heard_min() {
527        subfield("last heard", format!("{minutes} min ago"));
528    }
529    if let Some(location) = entry.location() {
530        let (lat, lon) = location.center();
531        subfield("location", format!("{lat:.4}, {lon:.4}"));
532    }
533    let regions: Vec<String> = entry
534        .regions()
535        .map(|code| umsh::core::RegionCode::from_bytes(code).to_string())
536        .collect();
537    if !regions.is_empty() {
538        subfield("regions", regions.join(", "));
539    }
540}
541
542async fn info<R: Radio>(ctl: &mut Ctl<'_, R>) -> Result<()>
543where
544    R::Error: core::fmt::Debug,
545{
546    let keys = [
547        prop::PROTOCOL_VERSION,
548        prop::DEV_VERSION,
549        prop::DEV_MODEL,
550        prop::DEV_NAME,
551        prop::DEV_KEY,
552        prop::CAPS,
553    ];
554    let reply = ctl
555        .reply(&encode(|buf| frame::prop_multi_get(buf, 0, &keys))?)
556        .await?;
557    for (key, answer) in zip_answers(&keys, &reply)? {
558        match answer {
559            Err(status) => field(&label(key), format!("{status:?}")),
560            Ok(value) => match key {
561                prop::CAPS => {
562                    field("capabilities", format!("{} advertised", value.len()));
563                    for &number in &value {
564                        match capability_name(u32::from(number)) {
565                            Some(name) => subfield(&format!("{number}"), name),
566                            None => subfield(&format!("{number}"), "unnamed"),
567                        }
568                    }
569                }
570                prop::DEV_VERSION | prop::DEV_MODEL | prop::DEV_NAME => field(
571                    &label(key),
572                    String::from_utf8_lossy(&value)
573                        .trim_end_matches('\0')
574                        .to_owned(),
575                ),
576                prop::DEV_KEY => field(&label(key), address(&value)),
577                _ => field(&label(key), hex(&value)),
578            },
579        }
580    }
581    Ok(())
582}
583
584async fn admins<R: Radio>(ctl: &mut Ctl<'_, R>, op: TableOp, no_save: bool) -> Result<()>
585where
586    R::Error: core::fmt::Debug,
587{
588    match op {
589        TableOp::List => {
590            let reply = ctl
591                .reply(&encode(|buf| frame::prop_get(buf, 0, prop::DEV_ADMINS))?)
592                .await?;
593            let value = value_of(prop::DEV_ADMINS, &reply)?;
594            if value.is_empty() {
595                println!("no administrators listed");
596            } else if !value.len().is_multiple_of(32) {
597                bail!("malformed administrator listing");
598            } else {
599                for key in value.chunks(32) {
600                    println!("{}", address(key));
601                }
602            }
603            Ok(())
604        }
605        TableOp::Add { key } => {
606            let reply = ctl
607                .reply(&encode(|buf| {
608                    frame::prop_insert(buf, 0, prop::DEV_ADMINS, &key.0)
609                })?)
610                .await?;
611            let digest = value_of(prop::DEV_ADMINS, &reply)?;
612            println!("administrator added (digest {})", address(&digest));
613            save_if_asked(ctl, no_save).await
614        }
615        TableOp::Remove { key } => {
616            // Removing the administrator this tool is speaking as ends
617            // the conversation, which is legitimate — handing a device
618            // over is exactly this — but it should not be a surprise.
619            if key.0 == ctl.manager.device().0 {
620                note("that key is the device's own, not an administrator's");
621            }
622            let reply = ctl
623                .reply(&encode(|buf| {
624                    frame::prop_remove(buf, 0, prop::DEV_ADMINS, &key.0)
625                })?)
626                .await?;
627            let digest = value_of(prop::DEV_ADMINS, &reply)?;
628            println!("administrator removed (digest {})", address(&digest));
629            save_if_asked(ctl, no_save).await
630        }
631    }
632}
633
634/// Persist a mutation, matching what the local commands do.
635async fn save_if_asked<R: Radio>(ctl: &mut Ctl<'_, R>, no_save: bool) -> Result<()>
636where
637    R::Error: core::fmt::Debug,
638{
639    if no_save {
640        note("--no-save — changes are live only; `manage <KEY> save` persists them");
641        return Ok(());
642    }
643    let reply = ctl.reply(&encode(|buf| frame::save(buf, 0))?).await?;
644    match reply::status_of(&reply) {
645        Some(Status::OK) | None => {
646            println!("saved: changes persist across reboots");
647            Ok(())
648        }
649        Some(status) => bail!("the device refused to save ({status:?})"),
650    }
651}
652
653// ─── Frames in, values out ───────────────────────────────────────────────────
654
655/// Build a request frame into a buffer large enough for anything the
656/// binding carries.
657fn encode(
658    build: impl FnOnce(&mut [u8]) -> Result<usize, umsh::ulcp_wire::frame::WriteError>,
659) -> Result<Vec<u8>> {
660    let mut buf = vec![0u8; umsh::node_mgmt::REQUEST_MAX];
661    let len = build(&mut buf).map_err(|error| {
662        anyhow!("the request does not fit one Node Management payload: {error:?}")
663    })?;
664    buf.truncate(len);
665    Ok(buf)
666}
667
668fn label(key: u32) -> String {
669    super::props::spell(key)
670}
671
672/// The value a single-property reply carries, or the status that stands
673/// in its place.
674fn value_of(requested: u32, reply: &[u8]) -> Result<Vec<u8>> {
675    match reply::property(requested, reply)
676        .map_err(|error| anyhow!("unreadable reply: {error:?}"))?
677    {
678        reply::Answer::Value(value) => Ok(value.to_vec()),
679        reply::Answer::Refused(status) => bail!("the device refused: {status:?}"),
680    }
681}
682
683fn report_value(requested: u32, reply: &[u8]) -> Result<()> {
684    let value = value_of(requested, reply)?;
685    field(
686        &label(requested),
687        super::props::format_value(requested, &value),
688    );
689    Ok(())
690}
691
692/// One position of a `CMD_PROP_ARE`: the property that was asked for, and
693/// either its value or the status that occupied the slot instead.
694type Answer = (u32, Result<Vec<u8>, Status>);
695
696/// Split a `CMD_PROP_ARE` into per-position outcomes, in the order asked
697/// for.
698fn zip_answers(keys: &[u32], reply: &[u8]) -> Result<Vec<Answer>> {
699    let entries = reply::entries(keys, reply).map_err(|error| match error {
700        // A device without `CAP_CMD_MULTI` answers the unrecognized
701        // command with a plain status rather than an entry list.
702        reply::EntriesError::NotEntries => match reply::status_of(reply) {
703            Some(status) => anyhow!("the device refused the multi-property request: {status:?}"),
704            None => anyhow!("the device answered a multi-property request with something else"),
705        },
706        reply::EntriesError::Unreadable(error) => anyhow!("unreadable reply: {error:?}"),
707    })?;
708    entries
709        .map(|entry| {
710            let (key, answer) = entry.map_err(|error| anyhow!("malformed entry: {error:?}"))?;
711            Ok(match answer {
712                reply::Answer::Value(value) => (key, Ok(value.to_vec())),
713                reply::Answer::Refused(status) => (key, Err(status)),
714            })
715        })
716        .collect()
717}
718
719fn report_entries(keys: &[u32], reply: &[u8], short_advice: &str) -> Result<()> {
720    let answers = zip_answers(keys, reply)?;
721    for (key, answer) in &answers {
722        match answer {
723            Ok(value) => field(&label(*key), super::props::format_value(*key, value)),
724            Err(status) => field(&label(*key), format!("refused: {status:?}")),
725        }
726    }
727    if answers.len() < keys.len() {
728        field(
729            "short",
730            format!(
731                "{} of {} answered; {short_advice}",
732                answers.len(),
733                keys.len()
734            ),
735        );
736    }
737    Ok(())
738}