umsh/
ulcp.rs

1//! Host-side client for the UMSH Local Control Protocol (ULCP).
2//!
3//! [`UlcpDevice`] drives a device over a frame-oriented [`FrameLink`], and
4//! exposes the link as a [`umsh_hal::Radio`] so the host can run the
5//! full MAC/node stack with the device acting purely as the PHY.
6//!
7//! The wire format lives in [`umsh_ulcp`] (re-exported as
8//! [`crate::ulcp_wire`]); this module owns the host-side session
9//! behavior: the reset/configure handshake, request/response
10//! transactions, and queueing of frames that arrive while a command is
11//! in flight.
12//!
13//! See `docs/protocol/src/ulcp.md` and the chapters under it for the
14//! protocol.
15
16use std::collections::VecDeque;
17use std::io;
18use std::time::Duration;
19
20use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
21use tokio::time::Instant;
22
23use umsh_core::{ChannelKey, RegionCode};
24use umsh_crypto::CryptoEngine;
25use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
26use umsh_hal::{CadPolicy, Radio, RxInfo, RxOrigin, Snr, TxError, TxOptions};
27use umsh_ulcp::Status;
28use umsh_ulcp::airtime::lora_airtime_ms;
29use umsh_ulcp::alert::AlertState;
30use umsh_ulcp::battery::BatteryStatus;
31use umsh_ulcp::frame::{self, Cmd, Frame, MultiEntries, StreamPayload, TID_UNSOLICITED};
32use umsh_ulcp::gnss::GnssSnapshot;
33use umsh_ulcp::hdlc;
34use umsh_ulcp::host::{PropertyNotification, PropertyNotificationKind, TidAllocator};
35use umsh_ulcp::ids::{self, cap, prop, stream};
36use umsh_ulcp::items;
37use umsh_ulcp::meta::{BufferedRxMeta, RX_FLAG_SELF_TX, RxMeta, TX_FLAG_NOCCA, TxMeta};
38use umsh_ulcp::pui;
39
40/// Capacity of the HDLC reassembly buffer (unescaped frame + FCS).
41const WIRE_BUF: usize = 1024;
42/// Size of one read from the underlying stream.
43const READ_CHUNK: usize = 256;
44/// Received frames buffered while a command transaction is in flight.
45/// The oldest frame is dropped on overflow, matching radio-FIFO
46/// overrun semantics.
47const RX_QUEUE_DEPTH: usize = 8;
48/// Stale command responses retained before the oldest is dropped.
49const RESPONSE_QUEUE_DEPTH: usize = 8;
50/// Unsolicited property notifications retained before the oldest is
51/// dropped.
52const PROP_EVENT_DEPTH: usize = 16;
53/// Delay between transmit retries while CCA reports a busy channel.
54const CCA_RETRY_DELAY: Duration = Duration::from_millis(10);
55
56#[derive(Debug)]
57pub enum UlcpError {
58    Io(io::Error),
59    /// The stream reached end-of-file; the device link is gone.
60    Disconnected,
61    /// The device violated the ULCP.
62    Protocol(&'static str),
63    /// The device reported a failure status for a command.
64    Status(Status),
65    /// The device reset outside of an initialization handshake, losing
66    /// its configuration. The radio must be re-initialized.
67    UnexpectedReset(Status),
68    /// The frame exceeds the device's advertised MTU.
69    FrameTooLarge(usize),
70    /// The device did not answer a command in time.
71    Timeout,
72    /// A non-stream transport failed.
73    Transport(String),
74    /// A host-domain write was attempted on an administrative handle.
75    /// Administering a device configures the device; it does not claim
76    /// the device (see [`AttachMode`]).
77    AdministrativeAttach,
78}
79
80impl core::fmt::Display for UlcpError {
81    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
82        match self {
83            Self::Io(error) => write!(formatter, "io error: {error}"),
84            Self::Disconnected => write!(formatter, "ULCP link disconnected"),
85            Self::Protocol(message) => write!(formatter, "protocol error: {message}"),
86            Self::Status(status) => write!(formatter, "device reported {status:?}"),
87            Self::UnexpectedReset(status) => {
88                write!(formatter, "device reset unexpectedly ({status:?})")
89            }
90            Self::FrameTooLarge(len) => write!(formatter, "frame too large: {len} bytes"),
91            Self::Timeout => write!(formatter, "timed out waiting for device response"),
92            Self::Transport(message) => write!(formatter, "transport error: {message}"),
93            Self::AdministrativeAttach => write!(
94                formatter,
95                "host-domain writes need a tethered attach, not an administrative one"
96            ),
97        }
98    }
99}
100
101impl std::error::Error for UlcpError {}
102
103impl From<io::Error> for UlcpError {
104    fn from(error: io::Error) -> Self {
105        Self::Io(error)
106    }
107}
108
109/// RF and session configuration applied during initialization.
110#[derive(Clone, Debug)]
111pub struct UlcpDeviceConfig {
112    /// Center frequency in kHz (`PROP_PHY_FREQ`).
113    pub freq_khz: u32,
114    /// LoRa bandwidth in Hz (`PROP_PHY_LORA_BW`).
115    pub bandwidth_hz: u32,
116    /// LoRa spreading factor, 5-12 (`PROP_PHY_LORA_SF`).
117    pub spreading_factor: u8,
118    /// LoRa coding-rate denominator: 5 for 4/5 through 8 for 4/8
119    /// (`PROP_PHY_LORA_CR`).
120    pub coding_rate_denom: u8,
121    /// Transmit power in dBm (`PROP_PHY_TX_POWER`).
122    pub tx_power_dbm: i8,
123    /// SX126x-style 16-bit sync word (`PROP_PHY_LORA_SW`).
124    pub sync_word: u16,
125    /// How long to wait for the device to answer one command, excluding
126    /// airtime (transmit confirmations extend this by the frame
127    /// airtime).
128    pub response_timeout: Duration,
129}
130
131impl UlcpDeviceConfig {
132    /// Configuration with the given RF link parameters, 0 dBm transmit
133    /// power, the suggested default sync word, and a 2-second response
134    /// timeout.
135    pub fn new(
136        freq_khz: u32,
137        bandwidth_hz: u32,
138        spreading_factor: u8,
139        coding_rate_denom: u8,
140    ) -> Self {
141        Self {
142            freq_khz,
143            bandwidth_hz,
144            spreading_factor,
145            coding_rate_denom,
146            tx_power_dbm: 0,
147            sync_word: umsh_ulcp::profiles::DEFAULT_SYNC_WORD,
148            response_timeout: Duration::from_secs(2),
149        }
150    }
151}
152
153struct RxPacket {
154    data: Vec<u8>,
155    meta: RxMeta,
156    /// Raw trailing metadata bytes, preserving the full protocol's
157    /// buffered-frame extension for callers that decode it.
158    raw_meta: Vec<u8>,
159}
160
161/// Which device-to-host property command carried a payload.
162type ResponseKind = PropertyNotificationKind;
163
164/// One position in a `CMD_PROP_ARE`: the property and the value the
165/// device reported, or the status that stands in its place.
166pub type MultiValue = Result<(u32, Vec<u8>), Status>;
167
168/// A property notification received with a non-zero TID (a command
169/// response).
170struct Response {
171    tid: u8,
172    kind: ResponseKind,
173    key: u32,
174    value: Vec<u8>,
175}
176
177#[derive(Clone, Copy)]
178enum PropResponsePolicy {
179    Value,
180    StatusOnly,
181}
182
183/// An unsolicited property notification (TID zero) retained for the
184/// caller: device state can change for reasons the host did not initiate,
185/// and publication of the new authoritative value is how the protocol
186/// reports that. Multi-value payloads are in digest form and never
187/// contain key material.
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub enum PropEvent {
190    /// `CMD_PROP_IS`: the property now has this complete value.
191    Is { key: u32, value: Vec<u8> },
192    /// `CMD_PROP_INSERTED`: an item was added to a multi-value property.
193    Inserted { key: u32, digest: Vec<u8> },
194    /// `CMD_PROP_REMOVED`: an item was removed from a multi-value
195    /// property.
196    Removed { key: u32, digest: Vec<u8> },
197}
198
199/// Direction of a traced ULCP frame.
200#[derive(Clone, Copy, Debug, PartialEq, Eq)]
201pub enum TraceDirection {
202    HostToDevice,
203    DeviceToHost,
204}
205
206impl core::fmt::Display for TraceDirection {
207    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
208        formatter.write_str(match self {
209            Self::HostToDevice => "host→device",
210            Self::DeviceToHost => "device→host",
211        })
212    }
213}
214
215/// Sink for per-frame trace lines (see
216/// [`UlcpDevice::set_frame_trace`]).
217pub type FrameTrace = Box<dyn FnMut(TraceDirection, &str) + Send>;
218
219/// How the device reported a successful `CMD_RESTORE`. Both forms leave
220/// the device in the same configuration; they differ only in reporting and
221/// session-state handling.
222#[derive(Clone, Copy, Debug, PartialEq, Eq)]
223pub enum RestoreCompletion {
224    /// Update form: values reverted in place (each change published as
225    /// an unsolicited update; see [`UlcpDevice::pop_prop_event`]).
226    Updated,
227    /// Reset form (`STATUS_RESET_RESTORED`): the device also reset its
228    /// protocol session state. Cached property views are invalid;
229    /// saved properties hold their saved values.
230    Reset,
231}
232
233/// Verdict of comparing `PROP_HOST_KEY` against this host's identity
234/// (spec §Attach, Detach, and Synchronization, step 2).
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub enum HostOwnership {
237    /// The device is configured for this host: queued traffic and
238    /// provisioning are ours to use and drain.
239    Ours,
240    /// No host identity is configured.
241    Unclaimed,
242    /// Another host has taken the radio over; the queue and
243    /// provisioning belong to that identity and must not be treated as
244    /// ours without deliberately replacing it (see
245    /// [`UlcpDevice::provision`]).
246    OtherHost([u8; 32]),
247    /// The device does not implement host filtering (minimal protocol
248    /// only).
249    Unsupported,
250    /// The host domain is out of reach: this handle administers the
251    /// device over the mesh, and whatever host the device serves is that
252    /// host's business, not an administrator's.
253    Unreachable,
254}
255
256/// `PROP_SAVED`: what the device reports about its stored snapshot.
257///
258/// The two failure values are the point of the property. An unattended
259/// repeater that comes back on stale configuration, or on none at all,
260/// looks identical to a healthy one from the outside — this is how a
261/// host that does eventually attach finds out.
262#[derive(Clone, Copy, Debug, PartialEq, Eq)]
263pub enum SavedSnapshot {
264    /// Nothing is saved.
265    None,
266    /// The newest saved generation is in effect.
267    Current,
268    /// A newer generation was rejected at boot and an older one is in
269    /// effect: the device is working, on stale configuration. Re-save to
270    /// clear it.
271    Fallback,
272    /// A snapshot exists but no generation could be read; the device
273    /// booted with post-reset defaults.
274    Unreadable,
275}
276
277impl SavedSnapshot {
278    fn from_octet(value: &[u8]) -> Result<Self, UlcpError> {
279        match value {
280            [ids::saved::NONE] => Ok(Self::None),
281            [ids::saved::CURRENT] => Ok(Self::Current),
282            [ids::saved::FALLBACK] => Ok(Self::Fallback),
283            [ids::saved::UNREADABLE] => Ok(Self::Unreadable),
284            _ => Err(UlcpError::Protocol("malformed PROP_SAVED")),
285        }
286    }
287
288    /// Whether a saved snapshot is in effect at all, in either
289    /// generation.
290    pub fn is_saved(self) -> bool {
291        matches!(self, Self::Current | Self::Fallback)
292    }
293}
294
295/// Device state gathered by [`UlcpDevice::sync`]: the spec's
296/// post-attach synchronization procedure. Fields whose capability the
297/// device does not advertise are `None`; multi-value properties are the
298/// digest forms and never contain key material.
299#[derive(Clone, Debug)]
300pub struct DeviceSync {
301    /// Retained `PROP_LAST_STATUS`.
302    pub last_status: Status,
303    /// `last_status` was a reset code: the device has reset since the
304    /// last host command, so state not restored from a saved snapshot
305    /// (notably queue contents) has been lost.
306    pub reset_since_last_contact: bool,
307    /// Advertised `PROP_CAPS`.
308    pub capabilities: Vec<u32>,
309    /// Whether the queued data and provisioning belong to this host.
310    pub ownership: HostOwnership,
311    /// The configured host identity, when one exists.
312    pub host_key: Option<[u8; 32]>,
313    /// `PROP_PHY_ENABLED` — with a restored snapshot the PHY may
314    /// already be up.
315    pub phy_enabled: bool,
316    /// `PROP_PHY_FREQ` in kHz.
317    pub freq_khz: u32,
318    /// `PROP_DEV_NAME`.
319    pub device_name: String,
320    /// `PROP_SAVED` (`CAP_SAVE`).
321    pub saved: Option<SavedSnapshot>,
322    /// `PROP_HOST_RX_QUEUE_COUNT` (`CAP_HOST_RX_QUEUE`).
323    pub queue_count: Option<u16>,
324    /// `PROP_HOST_RX_QUEUE_DROPPED` (`CAP_HOST_RX_QUEUE`).
325    pub queue_dropped: Option<u32>,
326    /// `PROP_HOST_RX_FILTERS` (`CAP_HOST_FILTER`).
327    pub filters: Option<Vec<items::Filter>>,
328    /// Derived channel identifiers of `PROP_HOST_CHANNEL_KEYS`
329    /// (`CAP_HOST_KEYS`).
330    pub host_channel_ids: Option<Vec<[u8; items::CHANNEL_ID_LEN]>>,
331    /// Provisioned peer public keys of `PROP_HOST_PEER_KEYS`
332    /// (`CAP_HOST_KEYS`).
333    pub host_peer_keys: Option<Vec<[u8; items::PUBLIC_KEY_LEN]>>,
334    /// `PROP_HOST_AUTO_ACK` (`CAP_HOST_AUTO_ACK`).
335    pub auto_ack: Option<bool>,
336    /// The device identity public key (`CAP_DEV_IDENTITY`), when one
337    /// is configured.
338    pub dev_key: Option<[u8; 32]>,
339}
340
341impl DeviceSync {
342    /// Whether the device advertised this capability code.
343    pub fn has_capability(&self, capability: u32) -> bool {
344        self.capabilities.contains(&capability)
345    }
346}
347
348/// The repeater forwarding policy of a `CAP_REPEATER` device.
349///
350/// The four gates are independent and are written separately, so this is
351/// a report rather than a transaction: reading it back after a partial
352/// write shows exactly what the device holds.
353#[derive(Clone, Debug, Default, PartialEq, Eq)]
354pub struct RepeaterPolicy {
355    /// `PROP_MAC_REPEATER_ENABLED`: whether the on-board node forwards at
356    /// all. The remaining fields are inert while this is false.
357    pub enabled: bool,
358    /// `PROP_MAC_REPEATER_REGIONS`: which region-tagged floods to forward,
359    /// as the strings an operator wrote — a short code, a name, or a
360    /// literal `0x1234`. Empty imposes no regional restriction. The
361    /// device derives the 2-octet codes the filter actually compares;
362    /// pass a name through [`RegionCode::from_str`] to see them.
363    pub regions: Vec<String>,
364    /// `PROP_MAC_REPEATER_DEFAULT_REGION`: the tag inserted into an
365    /// untagged flood before forwarding it. `None` forwards untagged.
366    pub default_region: Option<RegionCode>,
367    /// `PROP_MAC_REPEATER_MIN_RSSI` in dBm: floor below which a frame is
368    /// not worth relaying. `None` accepts any.
369    pub min_rssi: Option<i16>,
370    /// `PROP_MAC_REPEATER_MIN_SNR` in dB. `None` accepts any.
371    pub min_snr: Option<i8>,
372}
373
374/// The wall clock of a `CAP_TIME` device.
375#[derive(Clone, Copy, Debug, PartialEq, Eq)]
376pub struct DeviceTime {
377    /// `PROP_TIME`: Unix seconds, or `None` when the device does not know
378    /// what time it is. Unsigned, so the encoding is wrap-free into 2106.
379    pub epoch: Option<u32>,
380    /// `PROP_TZ_OFFSET`: minutes east of UTC. Always present — where the
381    /// device is meant to be is known even when the time is not.
382    pub tz_offset_min: i16,
383}
384
385/// Everything a `CAP_GNSS` device reports about positioning: the switch,
386/// the current fix, and the policy that governs what is done with it.
387///
388/// A report rather than a transaction, like [`RepeaterPolicy`]: the
389/// properties are written separately, and reading this back after a
390/// partial write shows exactly what the device holds.
391#[derive(Clone, Copy, Debug, PartialEq, Eq)]
392pub struct GnssStatus {
393    /// `PROP_GNSS_ENABLED`: whether the receiver is powered. Everything in
394    /// `fix` reads as searching while this is false.
395    pub enabled: bool,
396    /// The five positioning telemetry properties, folded into one value.
397    pub fix: GnssSnapshot,
398    /// `PROP_GNSS_IDENT_UPDATE`: whether fixes refresh the advertised
399    /// node identity's location.
400    pub ident_update: bool,
401    /// `PROP_GNSS_IDENT_PRECISION`: location bytes the advertised
402    /// position is clamped to.
403    pub ident_precision: u8,
404    /// `PROP_GNSS_TIME_TRUST`: whether receiver-derived time may set the
405    /// wall clock.
406    pub time_trust: bool,
407}
408
409/// What a device announces without being asked.
410#[derive(Clone, Copy, Debug, PartialEq, Eq)]
411pub struct AdvertPolicy {
412    /// `PROP_ADVERT_INTERVAL`: seconds between signed identity
413    /// advertisements, 0 for none.
414    pub advert_interval_s: u32,
415    /// `PROP_BEACON_INTERVAL`: seconds between empty beacons, 0 for none.
416    pub beacon_interval_s: u32,
417    /// `PROP_STARTUP_BEACON`: whether one beacon goes out at bring-up.
418    pub startup_beacon: bool,
419}
420
421/// The host-domain state [`UlcpDevice::provision`] establishes on
422/// the device.
423#[derive(Clone, Debug)]
424pub struct HostProvisioning {
425    /// The host identity (`PROP_HOST_KEY`). Provisioning a key
426    /// different from the configured one replaces the host domain
427    /// (spec §Host Replacement).
428    pub host_key: [u8; 32],
429    /// Desired explicit receive filter set (`PROP_HOST_RX_FILTERS`).
430    pub filters: Vec<items::Filter>,
431    /// Desired channel keys (`PROP_HOST_CHANNEL_KEYS`).
432    pub channel_keys: Vec<[u8; items::CHANNEL_KEY_LEN]>,
433    /// Desired peer key entries (`PROP_HOST_PEER_KEYS`). Reconciled by
434    /// public-key membership: an entry whose public key the device
435    /// already reports is *not* re-sent, so rotated key material for
436    /// an existing peer must be re-inserted explicitly (insert
437    /// replaces).
438    pub peer_keys: Vec<items::PeerKeyEntry>,
439    /// Desired `PROP_HOST_AUTO_ACK`.
440    pub auto_ack: bool,
441}
442
443/// What [`UlcpDevice::provision`] wrote.
444///
445/// A count of writes issued, not of differences found: provisioning
446/// asserts the whole host domain unconditionally, so most of these are
447/// non-zero on every call whether or not the device already agreed. Only
448/// `host_replaced` and `peers_removed` report an observed difference.
449#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
450pub struct ProvisionReport {
451    /// `PROP_HOST_KEY` differed, so the device discarded the previous
452    /// host domain and every table below landed on an empty one.
453    pub host_replaced: bool,
454    /// The filter table was written whole.
455    pub filters_replaced: bool,
456    /// The channel-key table was written whole, because the device held
457    /// an identifier we have no key for and the remove selector is the
458    /// key itself.
459    pub channels_replaced: bool,
460    /// Channel keys inserted individually.
461    pub channels_inserted: usize,
462    /// Peer entries inserted.
463    pub peers_inserted: usize,
464    /// Peer entries removed because the desired set omits them.
465    pub peers_removed: usize,
466    /// `PROP_HOST_AUTO_ACK` was written.
467    pub auto_ack_changed: bool,
468}
469
470/// A cancel-safe, frame-oriented ULCP transport.
471#[allow(async_fn_in_trait)]
472pub trait FrameLink {
473    /// Send one complete ULCP frame.
474    async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError>;
475
476    /// Poll for the next complete ULCP frame.
477    ///
478    /// Implementations keep all partial state in `self`, so cancellation cannot
479    /// discard a partial frame or unread bytes following a completed frame.
480    fn poll_recv_frame(
481        &mut self,
482        cx: &mut core::task::Context<'_>,
483    ) -> core::task::Poll<Result<Vec<u8>, UlcpError>>;
484
485    /// Receive the next complete ULCP frame.
486    async fn recv_frame(&mut self) -> Result<Vec<u8>, UlcpError> {
487        core::future::poll_fn(|cx| self.poll_recv_frame(cx)).await
488    }
489}
490
491/// HDLC-Lite framing over a reliable asynchronous byte stream.
492pub struct SerialFrameLink<IO> {
493    io: IO,
494    decoder: hdlc::Decoder<WIRE_BUF>,
495    read_buf: [u8; READ_CHUNK],
496    read_pos: usize,
497    read_len: usize,
498}
499
500impl<IO> SerialFrameLink<IO> {
501    /// Wrap a byte stream in ULCP HDLC framing.
502    pub fn new(io: IO) -> Self {
503        Self {
504            io,
505            decoder: hdlc::Decoder::new(),
506            read_buf: [0; READ_CHUNK],
507            read_pos: 0,
508            read_len: 0,
509        }
510    }
511
512    /// Recover the underlying byte stream.
513    pub fn into_inner(self) -> IO {
514        self.io
515    }
516}
517
518impl<IO> FrameLink for SerialFrameLink<IO>
519where
520    IO: AsyncRead + AsyncWrite + Unpin,
521{
522    async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
523        let mut wire = vec![0u8; hdlc::max_encoded_len(frame.len())];
524        let len = hdlc::encode_frame(frame, &mut wire).expect("buffer sized with max_encoded_len");
525        self.io.write_all(&wire[..len]).await?;
526        self.io.flush().await?;
527        Ok(())
528    }
529
530    fn poll_recv_frame(
531        &mut self,
532        cx: &mut core::task::Context<'_>,
533    ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
534        loop {
535            while self.read_pos < self.read_len {
536                let byte = self.read_buf[self.read_pos];
537                self.read_pos += 1;
538                if let Some(Ok(frame)) = self.decoder.push(byte) {
539                    return core::task::Poll::Ready(Ok(frame.to_vec()));
540                }
541            }
542
543            self.read_pos = 0;
544            self.read_len = 0;
545            let mut read_buf = ReadBuf::new(&mut self.read_buf);
546            match core::pin::Pin::new(&mut self.io).poll_read(cx, &mut read_buf) {
547                core::task::Poll::Ready(Ok(())) => {
548                    self.read_len = read_buf.filled().len();
549                    if self.read_len == 0 {
550                        return core::task::Poll::Ready(Err(UlcpError::Disconnected));
551                    }
552                }
553                core::task::Poll::Ready(Err(error)) => {
554                    return core::task::Poll::Ready(Err(UlcpError::Io(error)));
555                }
556                core::task::Poll::Pending => return core::task::Poll::Pending,
557            }
558        }
559    }
560}
561
562/// BLE-specific link configuration.
563#[cfg(feature = "ble-radio")]
564#[derive(Clone, Copy, Debug)]
565pub struct BleFrameLinkConfig {
566    /// Frame bytes per GATT segment, excluding the SAR header.
567    pub segment_payload: usize,
568    /// How long discovery may run before reporting no matching peripheral.
569    pub discovery_timeout: Duration,
570    /// Maximum duration for each CoreBluetooth/BlueZ link operation.
571    pub operation_timeout: Duration,
572    /// Maximum duration for the protected Frame-Out subscription. Unlike an
573    /// ordinary GATT operation, this may include OS-mediated pairing and human
574    /// PIN entry.
575    pub pairing_timeout: Duration,
576}
577
578#[cfg(feature = "ble-radio")]
579impl Default for BleFrameLinkConfig {
580    fn default() -> Self {
581        Self {
582            // Correct for the mandatory ATT_MTU 23 floor on every platform.
583            segment_payload: 19,
584            discovery_timeout: Duration::from_secs(10),
585            operation_timeout: Duration::from_secs(10),
586            pairing_timeout: Duration::from_secs(90),
587        }
588    }
589}
590
591#[cfg(feature = "ble-radio")]
592impl BleFrameLinkConfig {
593    fn validate(&self) -> Result<(), UlcpError> {
594        if !(1..=511).contains(&self.segment_payload) {
595            return Err(UlcpError::Protocol(
596                "BLE segment payload must be in 1..=511",
597            ));
598        }
599        if self.discovery_timeout.is_zero()
600            || self.operation_timeout.is_zero()
601            || self.pairing_timeout.is_zero()
602        {
603            return Err(UlcpError::Protocol(
604                "BLE discovery, operation, and pairing timeouts must be nonzero",
605            ));
606        }
607        Ok(())
608    }
609}
610
611#[cfg(feature = "ble-radio")]
612struct BleNotificationReceiver {
613    notifications: tokio::sync::mpsc::Receiver<Vec<u8>>,
614    reassembler: umsh_ulcp::gatt::Reassembler<{ umsh_ulcp::gatt::MAX_FRAME }>,
615}
616
617#[cfg(feature = "ble-radio")]
618impl BleNotificationReceiver {
619    fn new(notifications: tokio::sync::mpsc::Receiver<Vec<u8>>) -> Self {
620        Self {
621            notifications,
622            reassembler: umsh_ulcp::gatt::Reassembler::new(),
623        }
624    }
625
626    fn poll_recv_frame(
627        &mut self,
628        cx: &mut core::task::Context<'_>,
629    ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
630        loop {
631            match self.notifications.poll_recv(cx) {
632                core::task::Poll::Ready(Some(segment)) => {
633                    if let Some(Ok(frame)) = self.reassembler.push(&segment) {
634                        return core::task::Poll::Ready(Ok(frame.to_vec()));
635                    }
636                    // Transport-level malformed/oversize segments are dropped.
637                }
638                core::task::Poll::Ready(None) => {
639                    self.reassembler.reset();
640                    return core::task::Poll::Ready(Err(UlcpError::Disconnected));
641                }
642                core::task::Poll::Pending => return core::task::Poll::Pending,
643            }
644        }
645    }
646}
647
648/// One ULCP GATT Service peripheral seen during a
649/// [`BleFrameLink::scan`].
650#[cfg(feature = "ble-radio")]
651#[derive(Clone, Debug)]
652pub struct BleScanResult {
653    /// Platform peripheral identifier, usable as a connect selector.
654    pub id: String,
655    /// Advertised local name, when present.
656    pub name: Option<String>,
657    /// Last advertisement RSSI in dBm, when the platform reports it.
658    pub rssi: Option<i16>,
659}
660
661/// GATT/SAR frame transport backed by `btleplug`.
662#[cfg(feature = "ble-radio")]
663pub struct BleFrameLink {
664    peripheral: btleplug::platform::Peripheral,
665    frame_in: btleplug::api::Characteristic,
666    receiver: BleNotificationReceiver,
667    segment_payload: usize,
668    operation_timeout: Duration,
669}
670
671#[cfg(feature = "ble-radio")]
672impl BleFrameLink {
673    /// Scan for ULCP GATT Service peripherals without connecting.
674    ///
675    /// Runs discovery for the full `timeout` and reports every matching
676    /// peripheral seen, so nearby radios all get listed (unlike
677    /// [`connect`](Self::connect), which returns as soon as a match
678    /// appears).
679    pub async fn scan(timeout: Duration) -> Result<Vec<BleScanResult>, UlcpError> {
680        use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
681
682        let manager = btleplug::platform::Manager::new()
683            .await
684            .map_err(ble_error)?;
685        let adapters = manager.adapters().await.map_err(ble_error)?;
686        let service = uuid::Uuid::from_u128(umsh_ulcp::gatt::SERVICE_UUID);
687        let deadline = Instant::now() + timeout;
688        let mut results: Vec<BleScanResult> = Vec::new();
689
690        for adapter in adapters {
691            adapter
692                .start_scan(ScanFilter {
693                    services: vec![service],
694                })
695                .await
696                .map_err(ble_error)?;
697            while Instant::now() < deadline {
698                tokio::time::sleep(Duration::from_millis(250)).await;
699                let peripherals =
700                    match tokio::time::timeout_at(deadline, adapter.peripherals()).await {
701                        Ok(result) => result.map_err(ble_error)?,
702                        Err(_) => break,
703                    };
704                for peripheral in peripherals {
705                    let properties =
706                        match tokio::time::timeout_at(deadline, peripheral.properties()).await {
707                            Ok(result) => result.map_err(ble_error)?,
708                            Err(_) => break,
709                        };
710                    let advertises_service = properties
711                        .as_ref()
712                        .is_some_and(|properties| properties.services.contains(&service));
713                    if !advertises_service {
714                        continue;
715                    }
716                    let id = peripheral.id().to_string();
717                    let name = properties
718                        .as_ref()
719                        .and_then(|properties| properties.local_name.clone());
720                    let rssi = properties.as_ref().and_then(|properties| properties.rssi);
721                    match results.iter_mut().find(|result| result.id == id) {
722                        Some(existing) => {
723                            existing.name = name.or(existing.name.take());
724                            existing.rssi = rssi.or(existing.rssi);
725                        }
726                        None => results.push(BleScanResult { id, name, rssi }),
727                    }
728                }
729            }
730            // CoreBluetooth operations can block indefinitely; bound the
731            // cleanup like connect does.
732            let _ = tokio::time::timeout(Duration::from_secs(1), adapter.stop_scan()).await;
733        }
734        Ok(results)
735    }
736
737    /// Discover and attach to a ULCP GATT Service peripheral.
738    ///
739    /// `selector` matches a local-name substring or the platform peripheral ID.
740    /// With no selector, discovery must yield exactly one companion radio.
741    pub async fn connect(
742        selector: Option<&str>,
743        config: BleFrameLinkConfig,
744    ) -> Result<Self, UlcpError> {
745        use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
746        use futures_util::StreamExt;
747
748        config.validate()?;
749
750        let manager = btleplug::platform::Manager::new()
751            .await
752            .map_err(ble_error)?;
753        let adapters = manager.adapters().await.map_err(ble_error)?;
754        let service = uuid::Uuid::from_u128(umsh_ulcp::gatt::SERVICE_UUID);
755        let deadline = Instant::now() + config.discovery_timeout;
756        let mut matches = Vec::new();
757
758        for adapter in adapters {
759            adapter
760                .start_scan(ScanFilter {
761                    services: vec![service],
762                })
763                .await
764                .map_err(ble_error)?;
765            loop {
766                if Instant::now() >= deadline {
767                    break;
768                }
769                tokio::time::sleep(Duration::from_millis(250)).await;
770                matches.clear();
771                let peripherals =
772                    match tokio::time::timeout_at(deadline, adapter.peripherals()).await {
773                        Ok(result) => result.map_err(ble_error)?,
774                        Err(_) => break,
775                    };
776                for peripheral in peripherals {
777                    let properties =
778                        match tokio::time::timeout_at(deadline, peripheral.properties()).await {
779                            Ok(result) => result.map_err(ble_error)?,
780                            Err(_) => break,
781                        };
782                    let id = peripheral.id().to_string();
783                    let name = properties
784                        .as_ref()
785                        .and_then(|properties| properties.local_name.as_deref());
786                    let selected = selector.is_none_or(|selector| {
787                        id == selector || name.is_some_and(|name| name.contains(selector))
788                    });
789                    let advertises_service = properties
790                        .as_ref()
791                        .is_some_and(|properties| properties.services.contains(&service));
792                    if selected && advertises_service {
793                        matches.push(peripheral);
794                    }
795                }
796                if !matches.is_empty() || Instant::now() >= deadline {
797                    break;
798                }
799            }
800            // CoreBluetooth operations can block indefinitely. Discovery's
801            // configured deadline applies to every await, including cleanup.
802            let _ = tokio::time::timeout(Duration::from_secs(1), adapter.stop_scan()).await;
803            if !matches.is_empty() {
804                break;
805            }
806        }
807
808        let peripheral = match matches.len() {
809            0 => {
810                return Err(UlcpError::Transport(
811                    "no ULCP GATT Service peripheral found".into(),
812                ));
813            }
814            1 => matches.pop().unwrap(),
815            _ => {
816                return Err(UlcpError::Transport(
817                    "multiple companion radios found; provide a selector".into(),
818                ));
819            }
820        };
821
822        let setup = async {
823            let is_connected =
824                tokio::time::timeout(config.operation_timeout, peripheral.is_connected())
825                    .await
826                    .map_err(|_| ble_timeout("querying connection state"))?
827                    .map_err(ble_error)?;
828            if !is_connected {
829                tokio::time::timeout(config.operation_timeout, peripheral.connect())
830                    .await
831                    .map_err(|_| ble_timeout("connecting"))?
832                    .map_err(ble_error)?;
833            }
834            tokio::time::timeout(config.operation_timeout, peripheral.discover_services())
835                .await
836                .map_err(|_| ble_timeout("discovering services"))?
837                .map_err(ble_error)?;
838
839            let frame_in_uuid = uuid::Uuid::from_u128(umsh_ulcp::gatt::FRAME_IN_UUID);
840            let frame_out_uuid = uuid::Uuid::from_u128(umsh_ulcp::gatt::FRAME_OUT_UUID);
841            let characteristics = peripheral.characteristics();
842            let frame_in = characteristics
843                .iter()
844                .find(|characteristic| characteristic.uuid == frame_in_uuid)
845                .cloned()
846                .ok_or(UlcpError::Protocol("missing BLE Frame In"))?;
847            let frame_out = characteristics
848                .iter()
849                .find(|characteristic| characteristic.uuid == frame_out_uuid)
850                .cloned()
851                .ok_or(UlcpError::Protocol("missing BLE Frame Out"))?;
852
853            let mut stream =
854                tokio::time::timeout(config.operation_timeout, peripheral.notifications())
855                    .await
856                    .map_err(|_| ble_timeout("opening notifications"))?
857                    .map_err(ble_error)?;
858            let (tx, notifications) = tokio::sync::mpsc::channel(32);
859            tokio::spawn(async move {
860                while let Some(notification) = stream.next().await {
861                    if notification.uuid == frame_out_uuid
862                        && tx.send(notification.value).await.is_err()
863                    {
864                        break;
865                    }
866                }
867            });
868            // This security-gated CCCD write is the protocol attach edge.
869            // Pairing prompts are mediated by the host OS.
870            tokio::time::timeout(config.pairing_timeout, peripheral.subscribe(&frame_out))
871                .await
872                .map_err(|_| ble_timeout("subscribing to Frame Out"))?
873                .map_err(ble_error)?;
874            Ok::<_, UlcpError>((frame_in, notifications))
875        }
876        .await;
877
878        let (frame_in, notifications) = match setup {
879            Ok(setup) => setup,
880            Err(error) => {
881                // Failed setup must not leave the single-connection device
882                // occupied and invisible to the next retry.
883                let _ = tokio::time::timeout(Duration::from_secs(1), peripheral.disconnect()).await;
884                return Err(error);
885            }
886        };
887
888        Ok(Self {
889            peripheral,
890            frame_in,
891            receiver: BleNotificationReceiver::new(notifications),
892            segment_payload: config.segment_payload,
893            operation_timeout: config.operation_timeout,
894        })
895    }
896
897    /// Capture the backend's view of a failed link, then make a bounded
898    /// best-effort disconnect so a subsequent discovery does not inherit a
899    /// stale CoreBluetooth/BlueZ connection object.
900    async fn diagnose_and_disconnect(&self, failure: String) -> UlcpError {
901        use btleplug::api::Peripheral as _;
902
903        let connected = match tokio::time::timeout(
904            Duration::from_secs(2),
905            self.peripheral.is_connected(),
906        )
907        .await
908        {
909            Ok(Ok(value)) => value.to_string(),
910            Ok(Err(error)) => format!("error({error})"),
911            Err(_) => "query-timeout".into(),
912        };
913        let cleanup = match tokio::time::timeout(
914            Duration::from_secs(2),
915            self.peripheral.disconnect(),
916        )
917        .await
918        {
919            Ok(Ok(())) => "ok".into(),
920            Ok(Err(error)) => format!("error({error})"),
921            Err(_) => "timeout".into(),
922        };
923        UlcpError::Transport(format!(
924            "{failure}; backend is_connected={connected}; disconnect cleanup={cleanup}"
925        ))
926    }
927}
928
929#[cfg(feature = "ble-radio")]
930impl FrameLink for BleFrameLink {
931    async fn send_frame(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
932        use btleplug::api::{Peripheral as _, WriteType};
933
934        for segment in umsh_ulcp::gatt::segments(frame, self.segment_payload) {
935            let mut value = vec![0; segment.payload().len() + 1];
936            segment
937                .write_to(&mut value)
938                .expect("segment destination is exactly sized");
939            let write = tokio::time::timeout(
940                self.operation_timeout,
941                self.peripheral
942                    .write(&self.frame_in, &value, WriteType::WithResponse),
943            )
944            .await;
945            match write {
946                Ok(Ok(())) => {}
947                Ok(Err(error)) => {
948                    return Err(self
949                        .diagnose_and_disconnect(format!("BLE Frame In write failed: {error}"))
950                        .await);
951                }
952                Err(_) => {
953                    return Err(self
954                        .diagnose_and_disconnect("BLE timed out while writing Frame In".into())
955                        .await);
956                }
957            }
958        }
959        Ok(())
960    }
961
962    fn poll_recv_frame(
963        &mut self,
964        cx: &mut core::task::Context<'_>,
965    ) -> core::task::Poll<Result<Vec<u8>, UlcpError>> {
966        self.receiver.poll_recv_frame(cx)
967    }
968}
969
970#[cfg(feature = "ble-radio")]
971fn ble_error(error: btleplug::Error) -> UlcpError {
972    UlcpError::Transport(error.to_string())
973}
974
975#[cfg(feature = "ble-radio")]
976fn ble_timeout(operation: &'static str) -> UlcpError {
977    UlcpError::Transport(format!("BLE timed out while {operation}"))
978}
979
980/// Companion radio attached over a frame link, usable as a
981/// [`umsh_hal::Radio`].
982pub struct UlcpDevice<L> {
983    link: L,
984    config: UlcpDeviceConfig,
985    rx_queue: VecDeque<RxPacket>,
986    responses: VecDeque<Response>,
987    prop_events: VecDeque<PropEvent>,
988    /// Unsolicited reset notification not yet surfaced to the caller.
989    seen_reset: Option<Status>,
990    max_frame_size: usize,
991    t_frame_ms: u32,
992    dev_version: String,
993    dev_model: Option<String>,
994    /// Hardware reset cause retained by the device before our protocol reset.
995    boot_status: Status,
996    tids: TidAllocator,
997    /// Optional per-frame trace sink for both directions.
998    trace: Option<FrameTrace>,
999    mode: AttachMode,
1000}
1001
1002/// The relationships a host can have with a device.
1003///
1004/// They are different things, and conflating them is how one phone
1005/// administering ten repeaters ends up claiming all of them. Tethering is
1006/// a transient local relationship — at most one at a time, re-established
1007/// on every attach, invisible to the mesh. Administration is
1008/// configuration of the device's own identity and behavior, which
1009/// outlives any particular host.
1010#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1011pub enum AttachMode {
1012    /// This host is the device's tethered host: it provisions the host
1013    /// domain and the device filters, queues and acknowledges for it.
1014    #[default]
1015    Tethered,
1016    /// This host is administering the device without tethering to it.
1017    /// Host-domain writes are refused.
1018    Administrative,
1019    /// This host is administering the device over the mesh, through the
1020    /// Node Management binding rather than a wire.
1021    ///
1022    /// Administrative in every respect, and additionally bounded by what
1023    /// that binding carries: the properties outside
1024    /// [`admin_reachable`](umsh_ulcp::ids::admin_reachable) are not
1025    /// refused by this handle but simply absent, because the device
1026    /// answers them `STATUS_PROP_NOT_FOUND` — an administrator learns the
1027    /// property does not exist for it, not that it exists and was
1028    /// withheld. Reading them would spend airtime to be told so.
1029    Remote,
1030}
1031
1032impl<L> UlcpDevice<L>
1033where
1034    L: FrameLink,
1035{
1036    fn bare(link: L, config: UlcpDeviceConfig) -> Self {
1037        Self {
1038            link,
1039            config,
1040            rx_queue: VecDeque::new(),
1041            responses: VecDeque::new(),
1042            prop_events: VecDeque::new(),
1043            seen_reset: None,
1044            max_frame_size: 0,
1045            t_frame_ms: 0,
1046            dev_version: String::new(),
1047            dev_model: None,
1048            boot_status: Status::RESET_UNKNOWN,
1049            tids: TidAllocator::new(),
1050            trace: None,
1051            mode: AttachMode::Tethered,
1052        }
1053    }
1054
1055    /// Which relationship this handle has with the device.
1056    pub fn attach_mode(&self) -> AttachMode {
1057        self.mode
1058    }
1059
1060    /// Whether this handle reaches the device over the mesh rather than a
1061    /// wire, and is therefore bounded by what the administrative binding
1062    /// carries.
1063    pub fn is_remote(&self) -> bool {
1064        self.mode == AttachMode::Remote
1065    }
1066
1067    /// Whether `key` is worth asking this device for at all.
1068    ///
1069    /// Over the mesh the unreachable half of the property space answers
1070    /// `STATUS_PROP_NOT_FOUND`, so a caller assembling a report treats
1071    /// those properties as absent rather than spending an exchange to be
1072    /// told they are.
1073    fn prop_reachable(&self, key: u32) -> bool {
1074        !self.is_remote() || ids::admin_reachable(key)
1075    }
1076
1077    /// Refuse a host-domain write on an administrative handle.
1078    fn require_tethered(&self, key: u32) -> Result<(), UlcpError> {
1079        let host_domain = matches!(
1080            key,
1081            prop::HOST_KEY
1082                | prop::HOST_CHANNEL_KEYS
1083                | prop::HOST_PEER_KEYS
1084                | prop::HOST_RX_FILTERS
1085                | prop::HOST_AUTO_ACK
1086        );
1087        match self.mode {
1088            AttachMode::Administrative | AttachMode::Remote if host_domain => {
1089                Err(UlcpError::AdministrativeAttach)
1090            }
1091            _ => Ok(()),
1092        }
1093    }
1094
1095    /// Attach to a device: reset it, verify the protocol version, apply
1096    /// the RF configuration, and enable the PHY.
1097    ///
1098    /// This is the minimal-protocol attach: `CMD_RST` discards a
1099    /// full-protocol device's session-independent state visibility (and
1100    /// with a saved snapshot the post-reset values come from the
1101    /// snapshot, not the documented defaults). A host cooperating with
1102    /// an autonomously operating device should use
1103    /// [`Self::attach_existing`] instead.
1104    pub async fn new(link: L, config: UlcpDeviceConfig) -> Result<Self, UlcpError> {
1105        let mut radio = Self::bare(link, config);
1106        radio.initialize().await?;
1107        Ok(radio)
1108    }
1109
1110    /// Attach to an already-operating device as its **tethered host**:
1111    /// the one host whose traffic it filters, queues and acknowledges.
1112    ///
1113    /// This is the full-protocol attach (spec §Attach, Detach, and
1114    /// Synchronization): attach implies no known state, so the host
1115    /// synchronizes by fetching. Only the identity handshake runs here
1116    /// — retained `PROP_LAST_STATUS` (the reset cause, preserved for
1117    /// [`Self::boot_status`] and [`Self::sync`]), the protocol version
1118    /// check, `PROP_DEV_VERSION`, and `PROP_PHY_MTU`. The PHY keeps
1119    /// whatever configuration and enable state it had; queued frames
1120    /// and provisioning are untouched. Follow with [`Self::sync`],
1121    /// [`Self::provision`], and drain the queue when ready.
1122    ///
1123    /// Use [`Self::attach_administrative`] to configure a device you do
1124    /// not intend to tether to — one phone administering ten repeaters
1125    /// must not write `PROP_HOST_KEY` on any of them.
1126    pub async fn attach_existing(link: L, config: UlcpDeviceConfig) -> Result<Self, UlcpError> {
1127        Self::attach_with_mode(link, config, AttachMode::Tethered).await
1128    }
1129
1130    /// Attach to an already-operating device to **administer** it:
1131    /// configure its own identity, radio, and behavior without becoming
1132    /// its host.
1133    ///
1134    /// Commissioning and tethering are different relationships and this
1135    /// is the difference made mechanical. The handle refuses every
1136    /// host-domain write — `PROP_HOST_KEY`, the host key tables, the
1137    /// filter table, the delegation policy, and [`Self::provision`] —
1138    /// with [`UlcpError::AdministrativeAttach`]. Everything
1139    /// else, including the device identity and the saved snapshot, works
1140    /// normally.
1141    ///
1142    /// A device may be administered by many hosts over its lifetime and
1143    /// tethered to at most one at a time; nothing about administering it
1144    /// disturbs whichever host it is currently serving.
1145    pub async fn attach_administrative(
1146        link: L,
1147        config: UlcpDeviceConfig,
1148    ) -> Result<Self, UlcpError> {
1149        Self::attach_with_mode(link, config, AttachMode::Administrative).await
1150    }
1151
1152    /// Open a handle on a device reached over the mesh, through the Node
1153    /// Management binding.
1154    ///
1155    /// Nothing goes on the air. An attach down a wire opens with a few
1156    /// reads because the link has just come up and the host knows
1157    /// nothing about what is on the other end; none of that is a
1158    /// handshake in the sense of establishing anything, because the
1159    /// device is told nothing and holds no state for it. Over the
1160    /// binding those reads would be exchanges on the air, minutes of
1161    /// them in bad conditions, spent before the command you actually
1162    /// asked for. A command that wants the firmware version or the boot
1163    /// status reads it, and pays for it then.
1164    ///
1165    /// So [`Self::dev_version`], [`Self::dev_model`], and
1166    /// [`Self::boot_status`] are empty on this handle, and the frame
1167    /// ceiling comes from the binding — [`umsh_node_mgmt::REQUEST_MAX`],
1168    /// which bounds a request here more tightly than the device's own
1169    /// PHY MTU does — rather than from asking. Give `config` the
1170    /// device's PHY and a response timeout that outlasts the binding's
1171    /// own retry budget, or a slow answer becomes a transport error here
1172    /// before the exchange engine has finished trying.
1173    pub fn open_remote(link: L, config: UlcpDeviceConfig) -> Self {
1174        let mut radio = Self::bare(link, config);
1175        radio.mode = AttachMode::Remote;
1176        radio.max_frame_size = umsh_node_mgmt::REQUEST_MAX;
1177        radio.t_frame_ms = lora_airtime_ms(
1178            radio.config.spreading_factor,
1179            radio.config.bandwidth_hz,
1180            radio.config.coding_rate_denom,
1181            radio.max_frame_size,
1182        )
1183        .max(1);
1184        radio
1185    }
1186
1187    async fn attach_with_mode(
1188        link: L,
1189        config: UlcpDeviceConfig,
1190        mode: AttachMode,
1191    ) -> Result<Self, UlcpError> {
1192        let mut radio = Self::bare(link, config);
1193        radio.mode = mode;
1194        // Reading LAST_STATUS does not overwrite it, so sync() still
1195        // sees a retained reset code after this handshake.
1196        //
1197        // It also cannot travel with the rest. A `CMD_PROP_ARE` reports a
1198        // refused position by putting `PROP_LAST_STATUS` in it, so a
1199        // status that *is* the answer and a status standing in for one
1200        // are the same bytes. Asking for it alone is what tells them
1201        // apart.
1202        let boot_status = radio.get_prop(prop::LAST_STATUS).await?;
1203        radio.boot_status = decode_status(&boot_status);
1204
1205        // Everything else the handshake wants, in one exchange.
1206        const REST: [u32; 4] = [
1207            prop::PROTOCOL_VERSION,
1208            prop::DEV_VERSION,
1209            prop::DEV_MODEL,
1210            prop::PHY_MTU,
1211        ];
1212        let answers = radio.read_each(&REST).await?;
1213        let [version, dev_version, dev_model, mtu] = answers.as_slice() else {
1214            return Err(UlcpError::Protocol("short answer to the attach handshake"));
1215        };
1216        let required = |answer: &Result<Vec<u8>, Status>| match answer {
1217            Ok(value) => Ok(value.clone()),
1218            Err(status) => Err(UlcpError::Status(*status)),
1219        };
1220
1221        let version = required(version)?;
1222        if version.first().copied() != Some(ids::PROTOCOL_MAJOR_VERSION) {
1223            return Err(UlcpError::Protocol("protocol major version mismatch"));
1224        }
1225        radio.dev_version = String::from_utf8_lossy(&required(dev_version)?)
1226            .trim_end_matches('\0')
1227            .to_owned();
1228        // `PROP_DEV_MODEL` is OPTIONAL, so a refusal is an answer — it
1229        // means "this device does not name its hardware" — and must not
1230        // fail the attach the way a missing DEV_VERSION would.
1231        radio.dev_model = dev_model.as_ref().ok().map(|value| {
1232            String::from_utf8_lossy(value)
1233                .trim_end_matches('\0')
1234                .to_owned()
1235        });
1236
1237        let mtu = required(mtu)?;
1238        let [mtu_lo, mtu_hi, ..] = mtu[..] else {
1239            return Err(UlcpError::Protocol("malformed PROP_PHY_MTU"));
1240        };
1241        radio.max_frame_size = usize::from(u16::from_le_bytes([mtu_lo, mtu_hi]));
1242        if radio.max_frame_size == 0 {
1243            return Err(UlcpError::Protocol("device advertised zero MTU"));
1244        }
1245        radio.t_frame_ms = lora_airtime_ms(
1246            radio.config.spreading_factor,
1247            radio.config.bandwidth_hz,
1248            radio.config.coding_rate_denom,
1249            radio.max_frame_size,
1250        )
1251        .max(1);
1252        Ok(radio)
1253    }
1254
1255    /// Give up this handle and recover the transport underneath it.
1256    ///
1257    /// The link stays open, so the device sees no detach and keeps its
1258    /// session-scoped state: this releases the *host's* bookkeeping, not
1259    /// the connection. Re-attaching the returned link produces a fresh
1260    /// handle, which is how a long-lived interactive host changes
1261    /// [`AttachMode`] — administrative for inspection, tethered for the
1262    /// one command that establishes a host domain — without making the
1263    /// user wait through a BLE reconnect.
1264    pub fn into_link(self) -> L {
1265        self.link
1266    }
1267
1268    /// Install (or clear) a per-frame trace sink. Every frame sent and
1269    /// every frame received is reported as a one-line summary (see
1270    /// [`describe_frame`]), so a failure can be placed at the host API,
1271    /// framing, session, storage, or radio boundary.
1272    pub fn set_frame_trace(&mut self, trace: Option<FrameTrace>) {
1273        self.trace = trace;
1274    }
1275
1276    /// Send one frame through the trace hook.
1277    async fn send(&mut self, frame: &[u8]) -> Result<(), UlcpError> {
1278        if let Some(trace) = &mut self.trace {
1279            trace(TraceDirection::HostToDevice, &describe_frame(frame));
1280        }
1281        self.link.send_frame(frame).await
1282    }
1283
1284    /// The device's firmware version string (`PROP_DEV_VERSION`).
1285    pub fn dev_version(&self) -> &str {
1286        &self.dev_version
1287    }
1288
1289    /// The device's hardware model (`PROP_DEV_MODEL`), or `None` on a
1290    /// device that does not implement the optional property — a
1291    /// simulator, or firmware predating it.
1292    pub fn dev_model(&self) -> Option<&str> {
1293        self.dev_model.as_deref()
1294    }
1295
1296    /// Fetch the device's human-readable `PROP_DEV_NAME`.
1297    pub async fn device_name(&mut self) -> Result<String, UlcpError> {
1298        let value = self.get_prop(prop::DEV_NAME).await?;
1299        let name = core::str::from_utf8(&value)
1300            .map_err(|_| UlcpError::Protocol("malformed PROP_DEV_NAME"))?;
1301        if name.is_empty() || value.len() > 64 || value.contains(&0) {
1302            return Err(UlcpError::Protocol("malformed PROP_DEV_NAME"));
1303        }
1304        Ok(name.to_owned())
1305    }
1306
1307    /// Set the device's human-readable `PROP_DEV_NAME`.
1308    pub async fn set_device_name(&mut self, name: &str) -> Result<(), UlcpError> {
1309        if name.is_empty() || name.len() > 64 || name.as_bytes().contains(&0) {
1310            return Err(UlcpError::Protocol("invalid PROP_DEV_NAME"));
1311        }
1312        let authoritative = self.set_prop(prop::DEV_NAME, name.as_bytes()).await?;
1313        if authoritative != name.as_bytes() {
1314            return Err(UlcpError::Protocol("PROP_DEV_NAME response mismatch"));
1315        }
1316        Ok(())
1317    }
1318
1319    /// Fetch a live battery status snapshot (`PROP_BATTERY`).
1320    ///
1321    /// `Ok(None)` means the device is not battery powered.
1322    /// `Ok(Some(status))` with every field `None` means battery powered
1323    /// with unsupported reporting. A measurement the device cannot
1324    /// currently obtain surfaces as a command failure, never as `None` —
1325    /// battery is live telemetry, so this is deliberately not part of
1326    /// [`UlcpDevice::sync`].
1327    ///
1328    /// One exchange: the property's own absence is the answer
1329    /// `CAP_BATTERY` would have given, and over the mesh a capability
1330    /// read to learn the same thing is a round trip on the air.
1331    pub async fn battery_status(&mut self) -> Result<Option<BatteryStatus>, UlcpError> {
1332        let value = match self.get_prop(prop::BATTERY).await {
1333            Ok(value) => value,
1334            Err(UlcpError::Status(Status::PROP_NOT_FOUND)) => return Ok(None),
1335            Err(error) => return Err(error),
1336        };
1337        match BatteryStatus::decode(&value) {
1338            Ok(status) => Ok(Some(status)),
1339            Err(_) => Err(UlcpError::Protocol("malformed PROP_BATTERY")),
1340        }
1341    }
1342
1343    /// Fetch a live ambient illuminance reading in millilux
1344    /// (`PROP_ILLUMINANCE`).
1345    ///
1346    /// `Ok(None)` means either that the device does not advertise
1347    /// `CAP_ILLUMINANCE` — no light sensor is fitted — or that a device
1348    /// which does could not read the sensor just now. Both are "there is
1349    /// no reading", which is what a caller acts on; neither is an error.
1350    /// Live telemetry, so deliberately not part of [`UlcpDevice::sync`].
1351    pub async fn illuminance(&mut self) -> Result<Option<u32>, UlcpError> {
1352        if !self.capabilities().await?.contains(&cap::ILLUMINANCE) {
1353            return Ok(None);
1354        }
1355        let value = self.get_prop(prop::ILLUMINANCE).await?;
1356        match value.len() {
1357            0 => Ok(None),
1358            4 => Ok(Some(u32::from_le_bytes(value[..4].try_into().unwrap()))),
1359            _ => Err(UlcpError::Protocol("malformed PROP_ILLUMINANCE")),
1360        }
1361    }
1362
1363    /// Read the device's locate-alert state (`PROP_ALERT`).
1364    ///
1365    /// `Ok(None)` means the device does not advertise `CAP_ALERT` — it has
1366    /// no way to make itself conspicuous.
1367    pub async fn alert(&mut self) -> Result<Option<AlertState>, UlcpError> {
1368        if !self.capabilities().await?.contains(&cap::ALERT) {
1369            return Ok(None);
1370        }
1371        let value = self.get_prop(prop::ALERT).await?;
1372        Ok(Some(decode_alert(&value)?))
1373    }
1374
1375    /// Start or stop the device's locate alert (`PROP_ALERT`).
1376    ///
1377    /// Setting [`AlertState::Locate`] while an alert is already running
1378    /// restarts the device's deadline rather than failing, so a host that
1379    /// wants an alert to outlast the board's own bound re-sends this.
1380    /// Returns the authoritative state the device reported.
1381    ///
1382    /// The alert also ends when someone cancels it at the device or the
1383    /// deadline expires; both arrive as an unsolicited `PROP_ALERT`
1384    /// update rather than as a response to this call.
1385    ///
1386    /// A device without `CAP_ALERT` answers `STATUS_PROP_NOT_FOUND`.
1387    pub async fn set_alert(&mut self, state: AlertState) -> Result<AlertState, UlcpError> {
1388        let mut value = [0u8; pui::MAX_LEN];
1389        let len = pui::encode(state.code(), &mut value)
1390            .map_err(|_| UlcpError::Protocol("PROP_ALERT encode"))?;
1391        let authoritative = self.set_prop(prop::ALERT, &value[..len]).await?;
1392        decode_alert(&authoritative)
1393    }
1394
1395    /// Reset cause reported by the device immediately after transport attach.
1396    pub fn boot_status(&self) -> Status {
1397        self.boot_status
1398    }
1399
1400    /// Read the device's full repeater forwarding policy.
1401    ///
1402    /// `Ok(None)` means the device does not advertise `CAP_REPEATER`.
1403    pub async fn repeater_policy(&mut self) -> Result<Option<RepeaterPolicy>, UlcpError> {
1404        if !self.capabilities().await?.contains(&cap::REPEATER) {
1405            return Ok(None);
1406        }
1407        let enabled = self.get_prop(prop::MAC_REPEATER_ENABLED).await?;
1408        let enabled = match enabled.first() {
1409            Some(&byte) => byte != 0,
1410            None => return Err(UlcpError::Protocol("malformed PROP_MAC_REPEATER_ENABLED")),
1411        };
1412        let regions = decode_region_list(&self.get_prop(prop::MAC_REPEATER_REGIONS).await?)?;
1413        let default_region =
1414            decode_region_code(&self.get_prop(prop::MAC_REPEATER_DEFAULT_REGION).await?)?;
1415        let min_rssi = decode_opt_i16(&self.get_prop(prop::MAC_REPEATER_MIN_RSSI).await?)
1416            .ok_or(UlcpError::Protocol("malformed PROP_MAC_REPEATER_MIN_RSSI"))?;
1417        let min_snr = decode_opt_i8(&self.get_prop(prop::MAC_REPEATER_MIN_SNR).await?)
1418            .ok_or(UlcpError::Protocol("malformed PROP_MAC_REPEATER_MIN_SNR"))?;
1419        Ok(Some(RepeaterPolicy {
1420            enabled,
1421            regions,
1422            default_region,
1423            min_rssi,
1424            min_snr,
1425        }))
1426    }
1427
1428    /// Read the forwarding filter's region strings on their own, without
1429    /// the four extra round trips a whole-policy read costs.
1430    pub async fn repeater_regions(&mut self) -> Result<Vec<String>, UlcpError> {
1431        decode_region_list(&self.get_prop(prop::MAC_REPEATER_REGIONS).await?)
1432    }
1433
1434    /// Set which region-tagged floods the device forwards
1435    /// (`PROP_MAC_REPEATER_REGIONS`), as region strings. An empty list
1436    /// clears the filter, which imposes no regional restriction rather
1437    /// than blocking every flood.
1438    ///
1439    /// Returns the list the device actually stored. A device with less
1440    /// capacity than the caller offered refuses the write outright, so a
1441    /// shorter return means it collapsed a repeat.
1442    pub async fn set_repeater_regions(
1443        &mut self,
1444        regions: &[String],
1445    ) -> Result<Vec<String>, UlcpError> {
1446        let mut value = Vec::new();
1447        for region in regions {
1448            check_region(region)?;
1449            let mut item = vec![0u8; region.len() + 4];
1450            let len = items::encode_prefixed_item(region.as_bytes(), &mut item)
1451                .map_err(|_| UlcpError::Protocol("region item encode"))?;
1452            value.extend_from_slice(&item[..len]);
1453        }
1454        let authoritative = self.set_prop(prop::MAC_REPEATER_REGIONS, &value).await?;
1455        decode_region_list(&authoritative)
1456    }
1457
1458    /// Add one region to the forwarding filter without resending the rest
1459    /// of the table. A region already present fails with `STATUS_ALREADY`.
1460    pub async fn add_repeater_region(&mut self, region: &str) -> Result<(), UlcpError> {
1461        check_region(region)?;
1462        self.insert_prop_item(prop::MAC_REPEATER_REGIONS, region.as_bytes())
1463            .await
1464            .map(|_| ())
1465    }
1466
1467    /// Remove one region from the forwarding filter. The selector is the
1468    /// string as it was written, not the code it derives to.
1469    pub async fn remove_repeater_region(&mut self, region: &str) -> Result<(), UlcpError> {
1470        check_region(region)?;
1471        self.remove_prop_item(prop::MAC_REPEATER_REGIONS, region.as_bytes())
1472            .await
1473            .map(|_| ())
1474    }
1475
1476    /// Set the region code inserted into untagged floods before
1477    /// forwarding (`PROP_MAC_REPEATER_DEFAULT_REGION`). `None` forwards
1478    /// untagged.
1479    ///
1480    /// Deliberately not cross-checked against
1481    /// [`set_repeater_regions`](Self::set_repeater_regions): the two are
1482    /// written in either order.
1483    pub async fn set_repeater_default_region(
1484        &mut self,
1485        region: Option<RegionCode>,
1486    ) -> Result<Option<RegionCode>, UlcpError> {
1487        let value = region.map(|code| code.to_bytes()).unwrap_or_default();
1488        let value: &[u8] = match region {
1489            Some(_) => &value,
1490            None => &[],
1491        };
1492        let authoritative = self
1493            .set_prop(prop::MAC_REPEATER_DEFAULT_REGION, value)
1494            .await?;
1495        decode_region_code(&authoritative)
1496    }
1497
1498    /// Set the RSSI floor for forwarding in dBm
1499    /// (`PROP_MAC_REPEATER_MIN_RSSI`). `None` accepts any.
1500    pub async fn set_repeater_min_rssi(
1501        &mut self,
1502        min_rssi: Option<i16>,
1503    ) -> Result<Option<i16>, UlcpError> {
1504        let encoded = min_rssi.map(i16::to_le_bytes).unwrap_or_default();
1505        let value: &[u8] = match min_rssi {
1506            Some(_) => &encoded,
1507            None => &[],
1508        };
1509        let authoritative = self.set_prop(prop::MAC_REPEATER_MIN_RSSI, value).await?;
1510        decode_opt_i16(&authoritative)
1511            .ok_or(UlcpError::Protocol("malformed PROP_MAC_REPEATER_MIN_RSSI"))
1512    }
1513
1514    /// Set the SNR floor for forwarding in dB
1515    /// (`PROP_MAC_REPEATER_MIN_SNR`). `None` accepts any.
1516    pub async fn set_repeater_min_snr(
1517        &mut self,
1518        min_snr: Option<i8>,
1519    ) -> Result<Option<i8>, UlcpError> {
1520        let encoded = [min_snr.unwrap_or_default() as u8];
1521        let value: &[u8] = match min_snr {
1522            Some(_) => &encoded,
1523            None => &[],
1524        };
1525        let authoritative = self.set_prop(prop::MAC_REPEATER_MIN_SNR, value).await?;
1526        decode_opt_i8(&authoritative)
1527            .ok_or(UlcpError::Protocol("malformed PROP_MAC_REPEATER_MIN_SNR"))
1528    }
1529
1530    /// Read the device's wall clock and time zone (`PROP_TIME`,
1531    /// `PROP_TZ_OFFSET`).
1532    ///
1533    /// `Ok(None)` means the device does not advertise `CAP_TIME`.
1534    /// `Ok(Some(time))` with `time.epoch == None` means it has one and
1535    /// does not know what time it is — the state in which a device with a
1536    /// screen must show no clock at all.
1537    pub async fn time(&mut self) -> Result<Option<DeviceTime>, UlcpError> {
1538        if !self.capabilities().await?.contains(&cap::TIME) {
1539            return Ok(None);
1540        }
1541        let epoch = decode_epoch(&self.get_prop(prop::TIME).await?)?;
1542        let tz_offset_min = decode_tz_offset(&self.get_prop(prop::TZ_OFFSET).await?)?;
1543        Ok(Some(DeviceTime {
1544            epoch,
1545            tz_offset_min,
1546        }))
1547    }
1548
1549    /// Set the device's wall clock (`PROP_TIME`). `None` returns it to not
1550    /// knowing what time it is.
1551    ///
1552    /// A manual set outranks every receiver-derived one, including while
1553    /// `PROP_GNSS_TIME_TRUST` is clear.
1554    pub async fn set_time(&mut self, epoch: Option<u32>) -> Result<Option<u32>, UlcpError> {
1555        let encoded = epoch.map(u32::to_le_bytes).unwrap_or_default();
1556        let value: &[u8] = match epoch {
1557            Some(_) => &encoded,
1558            None => &[],
1559        };
1560        let authoritative = self.set_prop(prop::TIME, value).await?;
1561        decode_epoch(&authoritative)
1562    }
1563
1564    /// Set the device's local time-zone offset in minutes east of UTC
1565    /// (`PROP_TZ_OFFSET`).
1566    pub async fn set_tz_offset(&mut self, minutes: i16) -> Result<i16, UlcpError> {
1567        let authoritative = self
1568            .set_prop(prop::TZ_OFFSET, &minutes.to_le_bytes())
1569            .await?;
1570        decode_tz_offset(&authoritative)
1571    }
1572
1573    /// Read everything the device reports about positioning
1574    /// (`PROP_GNSS_*`).
1575    ///
1576    /// `Ok(None)` means the device does not advertise `CAP_GNSS`. The fix
1577    /// is live telemetry, so a disabled or searching receiver reports
1578    /// [`GnssSnapshot::SEARCHING`] rather than an error.
1579    pub async fn gnss_status(&mut self) -> Result<Option<GnssStatus>, UlcpError> {
1580        if !self.capabilities().await?.contains(&cap::GNSS) {
1581            return Ok(None);
1582        }
1583        let enabled = decode_bool(
1584            &self.get_prop(prop::GNSS_ENABLED).await?,
1585            "PROP_GNSS_ENABLED",
1586        )?;
1587        let mut fix = GnssSnapshot::SEARCHING;
1588        for key in [
1589            prop::GNSS_FIX,
1590            prop::GNSS_LOCATION,
1591            prop::GNSS_ALTITUDE,
1592            prop::GNSS_PRECISION,
1593            prop::GNSS_SATELLITES,
1594        ] {
1595            let value = self.get_prop(key).await?;
1596            fix.absorb(key, &value)
1597                .map_err(|_| UlcpError::Protocol("malformed PROP_GNSS_* value"))?;
1598        }
1599        let ident_update = decode_bool(
1600            &self.get_prop(prop::GNSS_IDENT_UPDATE).await?,
1601            "PROP_GNSS_IDENT_UPDATE",
1602        )?;
1603        let ident_precision = match self.get_prop(prop::GNSS_IDENT_PRECISION).await?[..] {
1604            [precision] => precision,
1605            _ => return Err(UlcpError::Protocol("malformed PROP_GNSS_IDENT_PRECISION")),
1606        };
1607        let time_trust = decode_bool(
1608            &self.get_prop(prop::GNSS_TIME_TRUST).await?,
1609            "PROP_GNSS_TIME_TRUST",
1610        )?;
1611        Ok(Some(GnssStatus {
1612            enabled,
1613            fix,
1614            ident_update,
1615            ident_precision,
1616            time_trust,
1617        }))
1618    }
1619
1620    /// Read the device's advertisement policy, or `None` on a device
1621    /// without `CAP_ADVERT`.
1622    pub async fn advert_policy(&mut self) -> Result<Option<AdvertPolicy>, UlcpError> {
1623        if !self.capabilities().await?.contains(&cap::ADVERT) {
1624            return Ok(None);
1625        }
1626        let advert_interval_s = self.get_interval(prop::ADVERT_INTERVAL).await?;
1627        let beacon_interval_s = self.get_interval(prop::BEACON_INTERVAL).await?;
1628        let startup_beacon = decode_bool(
1629            &self.get_prop(prop::STARTUP_BEACON).await?,
1630            "PROP_STARTUP_BEACON",
1631        )?;
1632        Ok(Some(AdvertPolicy {
1633            advert_interval_s,
1634            beacon_interval_s,
1635            startup_beacon,
1636        }))
1637    }
1638
1639    async fn get_interval(&mut self, key: u32) -> Result<u32, UlcpError> {
1640        decode_interval(&self.get_prop(key).await?)
1641    }
1642
1643    /// Set the seconds between signed identity advertisements, 0 for none
1644    /// (`PROP_ADVERT_INTERVAL`).
1645    pub async fn set_advert_interval(&mut self, seconds: u32) -> Result<u32, UlcpError> {
1646        let authoritative = self
1647            .set_prop(prop::ADVERT_INTERVAL, &seconds.to_le_bytes())
1648            .await?;
1649        decode_interval(&authoritative)
1650    }
1651
1652    /// Set the seconds between empty beacons, 0 for none
1653    /// (`PROP_BEACON_INTERVAL`).
1654    pub async fn set_beacon_interval(&mut self, seconds: u32) -> Result<u32, UlcpError> {
1655        let authoritative = self
1656            .set_prop(prop::BEACON_INTERVAL, &seconds.to_le_bytes())
1657            .await?;
1658        decode_interval(&authoritative)
1659    }
1660
1661    /// Set whether one beacon goes out at bring-up (`PROP_STARTUP_BEACON`).
1662    pub async fn set_startup_beacon(&mut self, enabled: bool) -> Result<bool, UlcpError> {
1663        let authoritative = self
1664            .set_prop(prop::STARTUP_BEACON, &[enabled as u8])
1665            .await?;
1666        decode_bool(&authoritative, "PROP_STARTUP_BEACON")
1667    }
1668
1669    /// Power the GNSS receiver on or off (`PROP_GNSS_ENABLED`).
1670    pub async fn set_gnss_enabled(&mut self, enabled: bool) -> Result<bool, UlcpError> {
1671        let authoritative = self.set_prop(prop::GNSS_ENABLED, &[enabled as u8]).await?;
1672        decode_bool(&authoritative, "PROP_GNSS_ENABLED")
1673    }
1674
1675    /// Set whether fixes refresh the advertised node identity's location
1676    /// (`PROP_GNSS_IDENT_UPDATE`).
1677    pub async fn set_gnss_ident_update(&mut self, enabled: bool) -> Result<bool, UlcpError> {
1678        let authoritative = self
1679            .set_prop(prop::GNSS_IDENT_UPDATE, &[enabled as u8])
1680            .await?;
1681        decode_bool(&authoritative, "PROP_GNSS_IDENT_UPDATE")
1682    }
1683
1684    /// Set the precision the advertised location is clamped to, in
1685    /// location bytes (`PROP_GNSS_IDENT_PRECISION`).
1686    pub async fn set_gnss_ident_precision(&mut self, precision: u8) -> Result<u8, UlcpError> {
1687        let authoritative = self
1688            .set_prop(prop::GNSS_IDENT_PRECISION, &[precision])
1689            .await?;
1690        match authoritative[..] {
1691            [stored] => Ok(stored),
1692            _ => Err(UlcpError::Protocol("malformed PROP_GNSS_IDENT_PRECISION")),
1693        }
1694    }
1695
1696    /// Set whether receiver-derived time may set the wall clock
1697    /// (`PROP_GNSS_TIME_TRUST`).
1698    ///
1699    /// Clearing it leaves a manually-set clock proof against a jammed or
1700    /// spoofed sky; position reporting is unaffected.
1701    pub async fn set_gnss_time_trust(&mut self, trust: bool) -> Result<bool, UlcpError> {
1702        let authoritative = self.set_prop(prop::GNSS_TIME_TRUST, &[trust as u8]).await?;
1703        decode_bool(&authoritative, "PROP_GNSS_TIME_TRUST")
1704    }
1705
1706    async fn initialize(&mut self) -> Result<(), UlcpError> {
1707        // The reset-status property is deliberately read before CMD_RST. The
1708        // protocol requires the device to retain its hardware boot cause for this
1709        // first query; CMD_RST would replace it with RESET_SOFTWARE.
1710        let boot_status = self.get_prop(prop::LAST_STATUS).await?;
1711        self.boot_status = decode_status(&boot_status);
1712
1713        // Reset and wait for the reset notification. The TID is
1714        // ignored for CMD_RST; the notification is unsolicited.
1715        let mut buf = [0u8; 2];
1716        let len = frame::reset(&mut buf, TID_UNSOLICITED)
1717            .map_err(|_| UlcpError::Protocol("frame encode"))?;
1718        self.send(&buf[..len]).await?;
1719        let deadline = Instant::now() + self.config.response_timeout;
1720        self.wait_reset(deadline).await?;
1721
1722        // Reject devices speaking an incompatible protocol revision.
1723        let version = self.get_prop(prop::PROTOCOL_VERSION).await?;
1724        if version.first().copied() != Some(ids::PROTOCOL_MAJOR_VERSION) {
1725            return Err(UlcpError::Protocol("protocol major version mismatch"));
1726        }
1727
1728        let dev_version = self.get_prop(prop::DEV_VERSION).await?;
1729        self.dev_version = String::from_utf8_lossy(&dev_version)
1730            .trim_end_matches('\0')
1731            .to_owned();
1732        self.dev_model = self.get_prop_string_opt(prop::DEV_MODEL).await;
1733
1734        let mtu = self.get_prop(prop::PHY_MTU).await?;
1735        let [mtu_lo, mtu_hi, ..] = mtu[..] else {
1736            return Err(UlcpError::Protocol("malformed PROP_PHY_MTU"));
1737        };
1738        self.max_frame_size = usize::from(u16::from_le_bytes([mtu_lo, mtu_hi]));
1739        if self.max_frame_size == 0 {
1740            return Err(UlcpError::Protocol("device advertised zero MTU"));
1741        }
1742
1743        let config = self.config.clone();
1744        self.set_prop(prop::PHY_FREQ, &config.freq_khz.to_le_bytes())
1745            .await?;
1746        self.set_prop(prop::PHY_LORA_BW, &config.bandwidth_hz.to_le_bytes())
1747            .await?;
1748        self.set_prop(prop::PHY_LORA_SF, &[config.spreading_factor])
1749            .await?;
1750        self.set_prop(prop::PHY_LORA_CR, &[config.coding_rate_denom])
1751            .await?;
1752        self.set_prop(prop::PHY_TX_POWER, &[config.tx_power_dbm as u8])
1753            .await?;
1754        self.set_prop(prop::PHY_LORA_SW, &config.sync_word.to_le_bytes())
1755            .await?;
1756        self.set_prop(prop::PHY_ENABLED, &[1]).await?;
1757
1758        self.t_frame_ms = lora_airtime_ms(
1759            config.spreading_factor,
1760            config.bandwidth_hz,
1761            config.coding_rate_denom,
1762            self.max_frame_size,
1763        )
1764        .max(1);
1765        Ok(())
1766    }
1767
1768    /// Fetch a property's raw value via `CMD_PROP_GET`.
1769    pub async fn get_prop(&mut self, key: u32) -> Result<Vec<u8>, UlcpError> {
1770        let tid = self.alloc_tid();
1771        let mut buf = [0u8; 8];
1772        let len =
1773            frame::prop_get(&mut buf, tid, key).map_err(|_| UlcpError::Protocol("frame encode"))?;
1774        self.send(&buf[..len]).await?;
1775        self.finish_prop_transaction(tid, key, PropResponsePolicy::Value)
1776            .await
1777    }
1778
1779    /// Fetch an OPTIONAL string property, treating a refusal as absence.
1780    ///
1781    /// A device that does not implement the property answers with an
1782    /// error status rather than a value, which is a legitimate answer and
1783    /// not a transport failure — so this collapses both to `None`. Only
1784    /// for properties the spec marks OPTIONAL; a REQUIRED one that
1785    /// refuses is a real fault and should stay an `Err`.
1786    async fn get_prop_string_opt(&mut self, key: u32) -> Option<String> {
1787        let value = self.get_prop(key).await.ok()?;
1788        Some(
1789            String::from_utf8_lossy(&value)
1790                .trim_end_matches('\0')
1791                .to_owned(),
1792        )
1793    }
1794
1795    /// Set a property via `CMD_PROP_SET`, returning the authoritative
1796    /// value echoed by the device.
1797    pub async fn set_prop(&mut self, key: u32, value: &[u8]) -> Result<Vec<u8>, UlcpError> {
1798        self.require_tethered(key)?;
1799        let tid = self.alloc_tid();
1800        let mut buf = vec![0u8; value.len() + 8];
1801        let len = frame::prop_set(&mut buf, tid, key, value)
1802            .map_err(|_| UlcpError::Protocol("frame encode"))?;
1803        self.send(&buf[..len]).await?;
1804        self.finish_prop_transaction(tid, key, PropResponsePolicy::Value)
1805            .await
1806    }
1807
1808    /// Read several properties, in as few exchanges as the reply budget
1809    /// allows.
1810    ///
1811    /// One `CMD_PROP_MULTI_GET`, continued where the reply ran out of
1812    /// room. Every position comes back either as a value or as the
1813    /// status standing in for one, so an OPTIONAL property's refusal
1814    /// reads as the answer it is rather than ending the whole read.
1815    ///
1816    /// `PROP_LAST_STATUS` must not appear in `keys`: a refused position
1817    /// is reported by putting that very property into it, so its value
1818    /// and a refusal are indistinguishable. Read it on its own.
1819    pub async fn read_each(
1820        &mut self,
1821        keys: &[u32],
1822    ) -> Result<Vec<Result<Vec<u8>, Status>>, UlcpError> {
1823        debug_assert!(
1824            !keys.contains(&prop::LAST_STATUS),
1825            "PROP_LAST_STATUS cannot share a multi-property read"
1826        );
1827        let mut answers: Vec<Result<Vec<u8>, Status>> = Vec::with_capacity(keys.len());
1828        while answers.len() < keys.len() {
1829            let remaining = &keys[answers.len()..];
1830            let entries = self.get_props(remaining).await?;
1831            if entries.is_empty() {
1832                // Nothing answered and nothing refused; continuing would
1833                // ask the same question forever.
1834                return Err(UlcpError::Protocol("empty multi-property answer"));
1835            }
1836            for (offset, entry) in entries.into_iter().enumerate() {
1837                answers.push(match entry {
1838                    // Positional: the answer names its key, and it has to
1839                    // be the one asked for at that position. `get` rather
1840                    // than an index because a device answering more
1841                    // entries than it was asked for is a wire fault, not
1842                    // grounds for a panic here.
1843                    Ok((key, value)) if remaining.get(offset) == Some(&key) => Ok(value),
1844                    Ok(_) => {
1845                        return Err(UlcpError::Protocol("multi-property answer out of order"));
1846                    }
1847                    Err(status) => Err(status),
1848                });
1849            }
1850        }
1851        Ok(answers)
1852    }
1853
1854    /// Read several properties in one exchange via
1855    /// `CMD_PROP_MULTI_GET`.
1856    ///
1857    /// Reading continues past failures: a property that could not be
1858    /// fetched occupies its own position as an `Err` carrying the status
1859    /// a lone `CMD_PROP_GET` would have produced. A device that answers
1860    /// with fewer entries than were requested ran out of room in the
1861    /// response; the caller reissues the remainder.
1862    ///
1863    /// Host-domain keys are not refused here. Reading one is not writing
1864    /// it: a local device answers an administrative handle's read the
1865    /// same way it answers a tethered one, and over the mesh the
1866    /// unreachable half of the property space refuses per position, as
1867    /// any other absent property does. Only writes are the host's alone,
1868    /// and [`Self::set_prop`] still says so.
1869    pub async fn get_props(&mut self, keys: &[u32]) -> Result<Vec<MultiValue>, UlcpError> {
1870        let tid = self.alloc_tid();
1871        let mut buf = vec![0u8; keys.len() * pui::MAX_LEN + 8];
1872        let len = frame::prop_multi_get(&mut buf, tid, keys)
1873            .map_err(|_| UlcpError::Protocol("frame encode"))?;
1874        self.send(&buf[..len]).await?;
1875        self.finish_multi_transaction(tid).await
1876    }
1877
1878    /// Write several properties in order in one exchange via
1879    /// `CMD_PROP_MULTI_SET`.
1880    ///
1881    /// The device applies the writes strictly in order and stops at the
1882    /// first failure, so the returned entries cover a prefix of the
1883    /// request: each is either the authoritative value the device echoed
1884    /// or the status that ended the sequence. A short reply whose last
1885    /// entry succeeded means the device ran out of response space; the
1886    /// caller reissues the remainder.
1887    pub async fn set_props(
1888        &mut self,
1889        entries: &[(u32, Vec<u8>)],
1890    ) -> Result<Vec<MultiValue>, UlcpError> {
1891        let mut capacity = 8;
1892        for (key, value) in entries {
1893            self.require_tethered(*key)?;
1894            capacity += value.len() + pui::MAX_LEN * 2;
1895        }
1896        let borrowed: Vec<(u32, &[u8])> = entries
1897            .iter()
1898            .map(|(key, value)| (*key, value.as_slice()))
1899            .collect();
1900        let tid = self.alloc_tid();
1901        let mut buf = vec![0u8; capacity];
1902        let len = frame::prop_multi_set(&mut buf, tid, &borrowed)
1903            .map_err(|_| UlcpError::Protocol("frame encode"))?;
1904        self.send(&buf[..len]).await?;
1905        self.finish_multi_transaction(tid).await
1906    }
1907
1908    /// Await the `CMD_PROP_ARE` answering a multi-property command and
1909    /// split it into per-position outcomes.
1910    async fn finish_multi_transaction(&mut self, tid: u8) -> Result<Vec<MultiValue>, UlcpError> {
1911        let deadline = Instant::now() + self.config.response_timeout;
1912        let response = self.wait_response(tid, deadline).await?;
1913        match response.kind {
1914            ResponseKind::Are => {}
1915            // A device without `CAP_CMD_MULTI` answers the unrecognized
1916            // command with a plain status.
1917            ResponseKind::Is if response.key == prop::LAST_STATUS => {
1918                return Err(UlcpError::Status(decode_status(&response.value)));
1919            }
1920            _ => {
1921                return Err(UlcpError::Protocol(
1922                    "single-property response answering a multi-property command",
1923                ));
1924            }
1925        }
1926        let mut values = Vec::new();
1927        for entry in MultiEntries::new(&response.value) {
1928            let entry = entry.map_err(|_| UlcpError::Protocol("malformed multi-property entry"))?;
1929            values.push(if entry.key == prop::LAST_STATUS {
1930                Err(decode_status(entry.value))
1931            } else {
1932                Ok((entry.key, entry.value.to_vec()))
1933            });
1934        }
1935        Ok(values)
1936    }
1937
1938    /// Insert one item into a multi-value property via
1939    /// `CMD_PROP_INSERT`, returning the inserted item's digest form
1940    /// from the correlated `CMD_PROP_INSERTED`.
1941    ///
1942    /// `item` is in the property's item form with no length prefix.
1943    /// A duplicate fails with `STATUS_ALREADY` unless the property
1944    /// defines replacement semantics (`PROP_HOST_PEER_KEYS`).
1945    pub async fn insert_prop_item(&mut self, key: u32, item: &[u8]) -> Result<Vec<u8>, UlcpError> {
1946        self.require_tethered(key)?;
1947        let tid = self.alloc_tid();
1948        let mut buf = vec![0u8; item.len() + 8];
1949        let len = frame::prop_insert(&mut buf, tid, key, item)
1950            .map_err(|_| UlcpError::Protocol("frame encode"))?;
1951        self.send(&buf[..len]).await?;
1952        self.finish_table_transaction(tid, key, ResponseKind::Inserted)
1953            .await
1954    }
1955
1956    /// Remove one item from a multi-value property via
1957    /// `CMD_PROP_REMOVE`, returning the removed item's digest form from
1958    /// the correlated `CMD_PROP_REMOVED`.
1959    ///
1960    /// `selector` is the property's documented remove selector. A
1961    /// missing item fails with `STATUS_ITEM_NOT_FOUND`.
1962    pub async fn remove_prop_item(
1963        &mut self,
1964        key: u32,
1965        selector: &[u8],
1966    ) -> Result<Vec<u8>, UlcpError> {
1967        self.require_tethered(key)?;
1968        let tid = self.alloc_tid();
1969        let mut buf = vec![0u8; selector.len() + 8];
1970        let len = frame::prop_remove(&mut buf, tid, key, selector)
1971            .map_err(|_| UlcpError::Protocol("frame encode"))?;
1972        self.send(&buf[..len]).await?;
1973        self.finish_table_transaction(tid, key, ResponseKind::Removed)
1974            .await
1975    }
1976
1977    /// Send a payload-less command completed by a correlated
1978    /// `PROP_LAST_STATUS`.
1979    async fn status_only_command(
1980        &mut self,
1981        encode: fn(&mut [u8], u8) -> Result<usize, frame::WriteError>,
1982    ) -> Result<(), UlcpError> {
1983        let tid = self.alloc_tid();
1984        let mut buf = [0u8; 4];
1985        let len = encode(&mut buf, tid).map_err(|_| UlcpError::Protocol("frame encode"))?;
1986        self.send(&buf[..len]).await?;
1987        self.finish_prop_transaction(tid, prop::LAST_STATUS, PropResponsePolicy::StatusOnly)
1988            .await
1989            .map(|_| ())
1990    }
1991
1992    /// Drain the device's inbound queue (`CMD_QUEUE_DRAIN`).
1993    ///
1994    /// Buffered frames are delivered as ordinary `CMD_STR_RECV` and land
1995    /// in the receive queue for [`Radio::poll_receive`]; this future
1996    /// resolves on the correlated completion status.
1997    pub async fn queue_drain(&mut self) -> Result<(), UlcpError> {
1998        self.queue_drain_with(|_data, _meta| {}).await
1999    }
2000
2001    /// As [`Self::queue_drain`], invoking `on_frame` with each frame
2002    /// (data, trailing metadata bytes) delivered before completion —
2003    /// buffered and interleaved live frames alike. The callback sees
2004    /// **every** such frame: an device queue larger than this driver's
2005    /// bounded receive buffer drains losslessly through it. Frames are
2006    /// additionally queued for [`Radio::poll_receive`], where the
2007    /// bounded buffer's oldest-dropped policy still applies.
2008    pub async fn queue_drain_with(
2009        &mut self,
2010        mut on_frame: impl FnMut(&[u8], &[u8]),
2011    ) -> Result<(), UlcpError> {
2012        let tid = self.alloc_tid();
2013        let mut buf = [0u8; 4];
2014        let len =
2015            frame::queue_drain(&mut buf, tid).map_err(|_| UlcpError::Protocol("frame encode"))?;
2016        self.send(&buf[..len]).await?;
2017
2018        let deadline = Instant::now() + self.config.response_timeout;
2019        loop {
2020            while let Some(response) = self.responses.pop_front() {
2021                if response.tid != tid {
2022                    continue;
2023                }
2024                if response.kind == ResponseKind::Is && response.key == prop::LAST_STATUS {
2025                    let status = decode_status(&response.value);
2026                    return if status == Status::OK {
2027                        Ok(())
2028                    } else {
2029                        Err(UlcpError::Status(status))
2030                    };
2031                }
2032                return Err(UlcpError::Protocol("unexpected drain response"));
2033            }
2034            if let Some(status) = self.seen_reset.take() {
2035                return Err(UlcpError::UnexpectedReset(status));
2036            }
2037            // Deliver at ingest time: each read that queued a stream
2038            // frame reports it immediately, so the callback cannot
2039            // miss frames the bounded receive buffer evicts mid-drain.
2040            if self.read_more(deadline).await? {
2041                let packet = self
2042                    .rx_queue
2043                    .back()
2044                    .expect("read_more reported a queued frame");
2045                on_frame(&packet.data, &packet.raw_meta);
2046            }
2047        }
2048    }
2049
2050    /// Save the device's device and host domains to non-volatile storage
2051    /// (`CMD_SAVE`; requires `CAP_SAVE`).
2052    pub async fn save(&mut self) -> Result<(), UlcpError> {
2053        self.status_only_command(frame::save).await
2054    }
2055
2056    /// Erase the device's saved snapshot and other persisted provisioning
2057    /// (`CMD_CLEAR`; base protocol, BLE bonds and pairing PIN exempt).
2058    pub async fn clear(&mut self) -> Result<(), UlcpError> {
2059        self.status_only_command(frame::clear).await
2060    }
2061
2062    /// Reset the device (`CMD_RST`) and wait for the reset notification,
2063    /// returning the announced reset status. The device comes up as from
2064    /// a power cycle — restoring its saved snapshot when one exists,
2065    /// factory configuration otherwise. All session-scoped state and
2066    /// cached views are gone; follow with [`Self::sync`].
2067    pub async fn reset(&mut self) -> Result<Status, UlcpError> {
2068        let mut buf = [0u8; 2];
2069        let len = frame::reset(&mut buf, TID_UNSOLICITED)
2070            .map_err(|_| UlcpError::Protocol("frame encode"))?;
2071        self.send(&buf[..len]).await?;
2072        let deadline = Instant::now() + self.config.response_timeout;
2073        self.wait_reset(deadline).await
2074    }
2075
2076    /// Factory-reset the device (`CMD_FACTORY_RESET`): erase ALL mutable
2077    /// state — saved provisioning, the device identity, BLE bonds, and the
2078    /// pairing PIN — and reboot to a blank factory state. Unlike
2079    /// [`Self::reset`] this sends no expectation of a reply and does not
2080    /// wait: the device wipes storage and reboots without responding, which
2081    /// drops the transport link. Treat the ensuing disconnect as
2082    /// completion; a caller that needs the radio again must re-open the
2083    /// transport and re-pair, since the bond it used is now gone.
2084    pub async fn factory_reset(&mut self) -> Result<(), UlcpError> {
2085        let mut buf = [0u8; 2];
2086        let len = frame::factory_reset(&mut buf, TID_UNSOLICITED)
2087            .map_err(|_| UlcpError::Protocol("frame encode"))?;
2088        self.send(&buf[..len]).await?;
2089        Ok(())
2090    }
2091
2092    /// Restart the device (`CMD_REBOOT`; requires `CAP_REBOOT`): a power
2093    /// cycle that keeps every persisted journal, so the device comes back
2094    /// as itself with its saved configuration.
2095    ///
2096    /// `Ok(false)` means the device does not advertise `CAP_REBOOT` and
2097    /// nothing was sent — asked rather than sent-and-timed-out, because a
2098    /// device that *will* reboot answers nothing at all and waiting for
2099    /// silence cannot tell the two apart. When it does reboot, the link
2100    /// drops; treat the ensuing disconnect as completion.
2101    pub async fn reboot(&mut self) -> Result<bool, UlcpError> {
2102        if !self.capabilities().await?.contains(&cap::REBOOT) {
2103            return Ok(false);
2104        }
2105        let mut buf = [0u8; 2];
2106        let len = frame::reboot(&mut buf, TID_UNSOLICITED)
2107            .map_err(|_| UlcpError::Protocol("frame encode"))?;
2108        self.send(&buf[..len]).await?;
2109        Ok(true)
2110    }
2111
2112    /// Forget every Bluetooth bond, the pairing PIN, and the pairing
2113    /// lockout (`CMD_BLE_CLEAR_BONDS`; requires `CAP_BLE`), then leave
2114    /// the device in a pairing window.
2115    ///
2116    /// `Ok(false)` means the device has no Bluetooth transport at all and
2117    /// nothing was sent. One that has a transport but does not manage its
2118    /// own bonds answers `STATUS_UNIMPLEMENTED`, which surfaces as an
2119    /// error — the caps list stops at "has Bluetooth", so the refusal is
2120    /// where the rest of the answer lives.
2121    ///
2122    /// Over Bluetooth this severs the caller's own link — the bond that
2123    /// carried it is one of the bonds deleted — but the status arrives
2124    /// first; over a cable and over the mesh nothing is disturbed.
2125    pub async fn ble_clear_bonds(&mut self) -> Result<bool, UlcpError> {
2126        if !self.capabilities().await?.contains(&cap::BLE) {
2127            return Ok(false);
2128        }
2129        self.status_only_command(frame::ble_clear_bonds).await?;
2130        Ok(true)
2131    }
2132
2133    /// Open or close the pairing window (`PROP_BLE_PAIRING`; requires
2134    /// `CAP_BLE`). Open, an unbonded host can pair without a gesture at
2135    /// the device; the window also closes by itself, on a new bond or a
2136    /// timeout, which is why it is a property a caller can read back
2137    /// rather than a command.
2138    ///
2139    /// `Ok(None)` means the device has no Bluetooth transport at all;
2140    /// `Ok(Some(state))` quotes the state the device settled on. A
2141    /// device that does not manage its own bonds answers
2142    /// `STATUS_PROP_NOT_FOUND`, and one that could not open a window
2143    /// right now — locked out, or with Bluetooth disabled — answers
2144    /// `STATUS_INVALID_STATE`; both surface as errors rather than a
2145    /// quiet success.
2146    pub async fn set_ble_pairing(&mut self, open: bool) -> Result<Option<bool>, UlcpError> {
2147        if !self.capabilities().await?.contains(&cap::BLE) {
2148            return Ok(None);
2149        }
2150        let value = self.set_prop(prop::BLE_PAIRING, &[open as u8]).await?;
2151        Ok(Some(value.first().copied() == Some(1)))
2152    }
2153
2154    /// Revert the device to its saved snapshot (`CMD_RESTORE`; requires
2155    /// `CAP_SAVE`), accepting both spec-permitted completion forms.
2156    pub async fn restore(&mut self) -> Result<RestoreCompletion, UlcpError> {
2157        let tid = self.alloc_tid();
2158        let mut buf = [0u8; 4];
2159        let len = frame::restore(&mut buf, tid).map_err(|_| UlcpError::Protocol("frame encode"))?;
2160        self.send(&buf[..len]).await?;
2161
2162        let deadline = Instant::now() + self.config.response_timeout;
2163        loop {
2164            while let Some(response) = self.responses.pop_front() {
2165                if response.tid != tid {
2166                    continue;
2167                }
2168                if response.kind == ResponseKind::Is && response.key == prop::LAST_STATUS {
2169                    let status = decode_status(&response.value);
2170                    return if status == Status::OK {
2171                        Ok(RestoreCompletion::Updated)
2172                    } else {
2173                        Err(UlcpError::Status(status))
2174                    };
2175                }
2176                return Err(UlcpError::Protocol("unexpected restore response"));
2177            }
2178            match self.seen_reset.take() {
2179                Some(status) if status == Status::RESET_RESTORED => {
2180                    return Ok(RestoreCompletion::Reset);
2181                }
2182                Some(status) => return Err(UlcpError::UnexpectedReset(status)),
2183                None => {}
2184            }
2185            self.read_more(deadline).await?;
2186        }
2187    }
2188
2189    /// Set or clear the device's persisted, write-only BLE pairing PIN.
2190    ///
2191    /// This property is the protocol's sole status-only property write: the
2192    /// value is never echoed. `None` clears the configured passkey.
2193    pub async fn set_ble_pairing_pin(&mut self, pin: Option<u32>) -> Result<(), UlcpError> {
2194        if pin.is_some_and(|pin| pin > 999_999) {
2195            return Err(UlcpError::Protocol("BLE pairing PIN out of range"));
2196        }
2197        let tid = self.alloc_tid();
2198        let value = pin.map(u32::to_le_bytes);
2199        let mut buf = [0u8; 12];
2200        let len = frame::prop_set(
2201            &mut buf,
2202            tid,
2203            prop::BLE_PAIRING_PIN,
2204            value.as_ref().map_or(&[], |value| &value[..]),
2205        )
2206        .map_err(|_| UlcpError::Protocol("frame encode"))?;
2207        self.send(&buf[..len]).await?;
2208        self.finish_prop_transaction(tid, prop::BLE_PAIRING_PIN, PropResponsePolicy::StatusOnly)
2209            .await
2210            .map(|_| ())
2211    }
2212
2213    /// Fetch and decode `PROP_CAPS`.
2214    pub async fn capabilities(&mut self) -> Result<Vec<u32>, UlcpError> {
2215        decode_capabilities(&self.get_prop(prop::CAPS).await?)
2216    }
2217
2218    /// Run the spec's post-attach synchronization procedure: fetch the
2219    /// retained `PROP_LAST_STATUS` (detecting a reset since the last
2220    /// contact), the capability list, the configured host identity —
2221    /// yielding an ownership verdict against `expected_host_key` — and
2222    /// the state each advertised capability grants, all in digest form.
2223    ///
2224    /// The host must decide ownership before treating queued data as
2225    /// its own: [`HostOwnership::OtherHost`] means the queue and
2226    /// provisioning belong to another identity.
2227    pub async fn sync(
2228        &mut self,
2229        expected_host_key: Option<&[u8; 32]>,
2230    ) -> Result<DeviceSync, UlcpError> {
2231        // Step 1: the retained status, before any other command can
2232        // overwrite a reset code.
2233        let last_status = decode_status(&self.get_prop(prop::LAST_STATUS).await?);
2234        let capabilities = self.capabilities().await?;
2235        let has = |capability: u32| capabilities.contains(&capability);
2236        // A capability says the device implements a property;
2237        // reachability says this handle may ask for it. Over the mesh the
2238        // host domain fails both tests, and the second one costs no
2239        // airtime to check.
2240
2241        // Step 2: ownership.
2242        let (host_key, ownership) = if !self.prop_reachable(prop::HOST_KEY) {
2243            (None, HostOwnership::Unreachable)
2244        } else if has(cap::HOST_FILTER) {
2245            let value = self.get_prop(prop::HOST_KEY).await?;
2246            match <[u8; 32]>::try_from(value.as_slice()) {
2247                Ok(key) => {
2248                    let ownership = match expected_host_key {
2249                        Some(expected) if *expected == key => HostOwnership::Ours,
2250                        _ => HostOwnership::OtherHost(key),
2251                    };
2252                    (Some(key), ownership)
2253                }
2254                Err(_) if value.is_empty() => (None, HostOwnership::Unclaimed),
2255                Err(_) => return Err(UlcpError::Protocol("malformed PROP_HOST_KEY")),
2256            }
2257        } else {
2258            (None, HostOwnership::Unsupported)
2259        };
2260
2261        // Step 3: the device-domain and host-domain state we depend
2262        // on, gated by the advertised capabilities.
2263        let phy_enabled = self.get_prop(prop::PHY_ENABLED).await? == [1];
2264        let freq = self.get_prop(prop::PHY_FREQ).await?;
2265        let freq_khz = u32::from_le_bytes(
2266            freq.as_slice()
2267                .try_into()
2268                .map_err(|_| UlcpError::Protocol("malformed PROP_PHY_FREQ"))?,
2269        );
2270        let device_name = self.device_name().await?;
2271        let saved = match has(cap::SAVE) {
2272            true => Some(SavedSnapshot::from_octet(
2273                &self.get_prop(prop::SAVED).await?,
2274            )?),
2275            false => None,
2276        };
2277        let (queue_count, queue_dropped) =
2278            if has(cap::HOST_RX_QUEUE) && self.prop_reachable(prop::HOST_RX_QUEUE_COUNT) {
2279                let count = self.get_prop(prop::HOST_RX_QUEUE_COUNT).await?;
2280                let dropped = self.get_prop(prop::HOST_RX_QUEUE_DROPPED).await?;
2281                (
2282                    Some(u16::from_le_bytes(count.as_slice().try_into().map_err(
2283                        |_| UlcpError::Protocol("malformed PROP_HOST_RX_QUEUE_COUNT"),
2284                    )?)),
2285                    Some(u32::from_le_bytes(dropped.as_slice().try_into().map_err(
2286                        |_| UlcpError::Protocol("malformed PROP_HOST_RX_QUEUE_DROPPED"),
2287                    )?)),
2288                )
2289            } else {
2290                (None, None)
2291            };
2292        let filters = match has(cap::HOST_FILTER) && self.prop_reachable(prop::HOST_RX_FILTERS) {
2293            true => Some(decode_filter_table(
2294                &self.get_prop(prop::HOST_RX_FILTERS).await?,
2295            )?),
2296            false => None,
2297        };
2298        let (host_channel_ids, host_peer_keys) =
2299            if has(cap::HOST_KEYS) && self.prop_reachable(prop::HOST_CHANNEL_KEYS) {
2300                (
2301                    Some(decode_fixed_list::<{ items::CHANNEL_ID_LEN }>(
2302                        &self.get_prop(prop::HOST_CHANNEL_KEYS).await?,
2303                        "malformed PROP_HOST_CHANNEL_KEYS digest",
2304                    )?),
2305                    Some(decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>(
2306                        &self.get_prop(prop::HOST_PEER_KEYS).await?,
2307                        "malformed PROP_HOST_PEER_KEYS digest",
2308                    )?),
2309                )
2310            } else {
2311                (None, None)
2312            };
2313        let auto_ack = match has(cap::HOST_AUTO_ACK) && self.prop_reachable(prop::HOST_AUTO_ACK) {
2314            true => Some(self.get_prop(prop::HOST_AUTO_ACK).await? == [1]),
2315            false => None,
2316        };
2317        let dev_key = if has(cap::DEV_IDENTITY) {
2318            let value = self.get_prop(prop::DEV_KEY).await?;
2319            match <[u8; 32]>::try_from(value.as_slice()) {
2320                Ok(key) => Some(key),
2321                Err(_) if value.is_empty() => None,
2322                Err(_) => return Err(UlcpError::Protocol("malformed PROP_DEV_KEY")),
2323            }
2324        } else {
2325            None
2326        };
2327
2328        Ok(DeviceSync {
2329            reset_since_last_contact: last_status.is_reset(),
2330            last_status,
2331            capabilities,
2332            ownership,
2333            host_key,
2334            phy_enabled,
2335            freq_khz,
2336            device_name,
2337            saved,
2338            queue_count,
2339            queue_dropped,
2340            filters,
2341            host_channel_ids,
2342            host_peer_keys,
2343            auto_ack,
2344            dev_key,
2345        })
2346    }
2347
2348    /// Establish `desired` as the device's complete host domain,
2349    /// writing every part of it unconditionally.
2350    ///
2351    /// **This does not compare and patch, and that is deliberate.** Key
2352    /// tables read back in lossy form only: the device reports channel
2353    /// identifiers and peer public keys, never key material. An
2354    /// administrator can replace a peer's `K_enc`/`K_mic` without
2355    /// changing anything observable, so no comparison over the readable
2356    /// surface can detect it — and a digest over the secret state would
2357    /// mean deriving a readable value from key material, which is worse
2358    /// than the problem. The host asserts what it wants; it does not
2359    /// reason about what the device already holds.
2360    ///
2361    /// Removals still come from comparison, and that is not a
2362    /// contradiction: *membership* is readable even though key material
2363    /// is not. So this reads the digest lists, removes what `desired`
2364    /// omits, and writes everything `desired` contains regardless of
2365    /// what came back.
2366    ///
2367    /// The host domain is volatile across power cycles, so the usual
2368    /// case is a device that has just rebooted and holds nothing. When
2369    /// it has *not* rebooted the rewrite is redundant — that is the
2370    /// point. Correctness must not depend on detecting which case this
2371    /// is, because reboot detection would also have to cover partial
2372    /// provisioning, another administrator having intervened, and future
2373    /// device behavior changes.
2374    ///
2375    /// Each individual write is transactional on the device (spec
2376    /// §Mutation Atomicity); the *sequence* is not (see the ULCP
2377    /// transition plan, decision 7). An interrupted call leaves a
2378    /// mixture, which the next call repairs by rewriting everything.
2379    ///
2380    /// Provisioning is per item rather than per table wherever the table
2381    /// can grow: a whole peer table stops fitting in a frame at the
2382    /// fifth entry.
2383    pub async fn provision(
2384        &mut self,
2385        desired: &HostProvisioning,
2386    ) -> Result<ProvisionReport, UlcpError> {
2387        self.require_tethered(prop::HOST_KEY)?;
2388        let mut report = ProvisionReport::default();
2389        let current_key = self.get_prop(prop::HOST_KEY).await?;
2390        // A host-key write to a different value resets the whole host
2391        // domain on the device, so everything below lands on an empty
2392        // one; writing the same key is idempotent and has no effect.
2393        if current_key.as_slice() != desired.host_key.as_slice() {
2394            report.host_replaced = true;
2395        }
2396        self.set_prop(prop::HOST_KEY, &desired.host_key).await?;
2397
2398        // Filters: item and digest forms are identical and the whole
2399        // table is small, so one atomic write says everything.
2400        let mut table = Vec::new();
2401        for filter in &desired.filters {
2402            let mut item = [0u8; items::Filter::MAX_WIRE_LEN];
2403            let item_len = filter
2404                .encode(&mut item)
2405                .map_err(|_| UlcpError::Protocol("filter encode"))?;
2406            let mut prefixed = [0u8; items::Filter::MAX_WIRE_LEN + 2];
2407            let prefixed_len = items::encode_prefixed_item(&item[..item_len], &mut prefixed)
2408                .map_err(|_| UlcpError::Protocol("filter encode"))?;
2409            table.extend_from_slice(&prefixed[..prefixed_len]);
2410        }
2411        self.set_prop(prop::HOST_RX_FILTERS, &table).await?;
2412        report.filters_replaced = true;
2413
2414        // Channel keys: the remove selector is the key itself, which we
2415        // hold for everything we want and not for anything we do not, so
2416        // shedding an unknown channel needs the whole-table form. That
2417        // table is bounded and small enough to send.
2418        let engine = CryptoEngine::new(SoftwareAes, SoftwareSha256);
2419        let desired_ids: Vec<[u8; items::CHANNEL_ID_LEN]> = desired
2420            .channel_keys
2421            .iter()
2422            .map(|key| engine.derive_channel_id(&ChannelKey(*key)).0)
2423            .collect();
2424        let current_ids = if report.host_replaced {
2425            Vec::new()
2426        } else {
2427            decode_fixed_list::<{ items::CHANNEL_ID_LEN }>(
2428                &self.get_prop(prop::HOST_CHANNEL_KEYS).await?,
2429                "malformed PROP_HOST_CHANNEL_KEYS digest",
2430            )?
2431        };
2432        if current_ids.iter().any(|id| !desired_ids.contains(id)) {
2433            let table: Vec<u8> = desired.channel_keys.concat();
2434            self.set_prop(prop::HOST_CHANNEL_KEYS, &table).await?;
2435            report.channels_replaced = true;
2436        } else {
2437            // Insert every desired key, including ones already reported.
2438            // A channel key *is* its own item, so a duplicate insert
2439            // asserts a state that already holds: `STATUS_ALREADY` says
2440            // "the entry is present", which is what was asked for, and
2441            // is treated as success. (Peers differ — a matching public
2442            // key replaces the entry's key material — so their inserts
2443            // never report it.)
2444            for key in &desired.channel_keys {
2445                match self.insert_prop_item(prop::HOST_CHANNEL_KEYS, key).await {
2446                    Ok(_) => report.channels_inserted += 1,
2447                    Err(UlcpError::Status(Status::ALREADY)) => {}
2448                    Err(error) => return Err(error),
2449                }
2450            }
2451        }
2452
2453        // Peers: remove by comparison over the readable public keys,
2454        // then insert every desired entry unconditionally. The device
2455        // reconciles rather than rebuilding, so re-inserting a peer it
2456        // already holds preserves that peer's replay baseline.
2457        let current_peers = if report.host_replaced {
2458            Vec::new()
2459        } else {
2460            decode_fixed_list::<{ items::PUBLIC_KEY_LEN }>(
2461                &self.get_prop(prop::HOST_PEER_KEYS).await?,
2462                "malformed PROP_HOST_PEER_KEYS digest",
2463            )?
2464        };
2465        for existing in &current_peers {
2466            if !desired
2467                .peer_keys
2468                .iter()
2469                .any(|entry| entry.public_key == *existing)
2470            {
2471                self.remove_prop_item(prop::HOST_PEER_KEYS, existing)
2472                    .await?;
2473                report.peers_removed += 1;
2474            }
2475        }
2476        for entry in &desired.peer_keys {
2477            let mut item = [0u8; items::PeerKeyEntry::WIRE_LEN];
2478            entry
2479                .encode(&mut item)
2480                .map_err(|_| UlcpError::Protocol("peer entry encode"))?;
2481            self.insert_prop_item(prop::HOST_PEER_KEYS, &item).await?;
2482            report.peers_inserted += 1;
2483        }
2484
2485        // Delegation policy last, once the keys it depends on exist.
2486        self.set_prop(prop::HOST_AUTO_ACK, &[desired.auto_ack as u8])
2487            .await?;
2488        report.auto_ack_changed = true;
2489        Ok(report)
2490    }
2491
2492    /// The device's device identity public key, generating one on-device
2493    /// if none is configured (`CAP_DEV_IDENTITY`; generation requires
2494    /// the transport's provisioning-security binding).
2495    ///
2496    /// On-device generation is the spec-recommended form: the private
2497    /// key never exists anywhere but the radio, and only the resulting
2498    /// public key crosses the link.
2499    pub async fn ensure_device_identity(&mut self) -> Result<[u8; 32], UlcpError> {
2500        let current = self.get_prop(prop::DEV_KEY).await?;
2501        if let Ok(key) = <[u8; 32]>::try_from(current.as_slice()) {
2502            return Ok(key);
2503        }
2504        if !current.is_empty() {
2505            return Err(UlcpError::Protocol("malformed PROP_DEV_KEY"));
2506        }
2507        // An empty PROP_DEV_PRIVATE_KEY write commands generation;
2508        // success is announced as PROP_IS for PROP_DEV_KEY carrying
2509        // the new public key.
2510        let tid = self.alloc_tid();
2511        let mut buf = [0u8; 8];
2512        let len = frame::prop_set(&mut buf, tid, prop::DEV_PRIVATE_KEY, &[])
2513            .map_err(|_| UlcpError::Protocol("frame encode"))?;
2514        self.send(&buf[..len]).await?;
2515        let value = self
2516            .finish_prop_transaction(tid, prop::DEV_KEY, PropResponsePolicy::Value)
2517            .await?;
2518        <[u8; 32]>::try_from(value.as_slice())
2519            .map_err(|_| UlcpError::Protocol("malformed PROP_DEV_KEY"))
2520    }
2521
2522    async fn finish_prop_transaction(
2523        &mut self,
2524        tid: u8,
2525        key: u32,
2526        policy: PropResponsePolicy,
2527    ) -> Result<Vec<u8>, UlcpError> {
2528        let deadline = Instant::now() + self.config.response_timeout;
2529        let response = self.wait_response(tid, deadline).await?;
2530        if response.kind != ResponseKind::Is {
2531            return Err(UlcpError::Protocol(
2532                "table notification answering a property command",
2533            ));
2534        }
2535        match (policy, response.key) {
2536            (PropResponsePolicy::Value, response_key) if response_key == key => Ok(response.value),
2537            (PropResponsePolicy::StatusOnly, prop::LAST_STATUS) => {
2538                let status = decode_status(&response.value);
2539                if status == Status::OK {
2540                    Ok(Vec::new())
2541                } else {
2542                    Err(UlcpError::Status(status))
2543                }
2544            }
2545            (PropResponsePolicy::Value, prop::LAST_STATUS) => {
2546                let status = decode_status(&response.value);
2547                if status == Status::OK {
2548                    Err(UlcpError::Protocol(
2549                        "unexpected status-only property response",
2550                    ))
2551                } else {
2552                    Err(UlcpError::Status(status))
2553                }
2554            }
2555            _ => Err(UlcpError::Protocol("response for unexpected property")),
2556        }
2557    }
2558
2559    /// Complete a `CMD_PROP_INSERT`/`CMD_PROP_REMOVE` transaction:
2560    /// success is the matching item notification carrying the digest,
2561    /// failure a correlated `PROP_LAST_STATUS`.
2562    async fn finish_table_transaction(
2563        &mut self,
2564        tid: u8,
2565        key: u32,
2566        expected: ResponseKind,
2567    ) -> Result<Vec<u8>, UlcpError> {
2568        let deadline = Instant::now() + self.config.response_timeout;
2569        let response = self.wait_response(tid, deadline).await?;
2570        match (response.kind, response.key) {
2571            (kind, response_key) if kind == expected && response_key == key => Ok(response.value),
2572            (ResponseKind::Is, prop::LAST_STATUS) => {
2573                let status = decode_status(&response.value);
2574                if status == Status::OK {
2575                    Err(UlcpError::Protocol(
2576                        "status-only success for a table mutation",
2577                    ))
2578                } else {
2579                    Err(UlcpError::Status(status))
2580                }
2581            }
2582            _ => Err(UlcpError::Protocol("response for unexpected property")),
2583        }
2584    }
2585
2586    fn alloc_tid(&mut self) -> u8 {
2587        self.tids.allocate()
2588    }
2589
2590    /// Sort a complete ULCP frame into the receive queue, response queue,
2591    /// or the reset flag; returns whether a stream frame was queued
2592    /// (the back of `rx_queue` is then the new packet). Malformed
2593    /// frames are dropped.
2594    fn ingest_frame(&mut self, frame_bytes: &[u8]) -> bool {
2595        if let Some(trace) = &mut self.trace {
2596            trace(TraceDirection::DeviceToHost, &describe_frame(frame_bytes));
2597        }
2598        let Ok(frame) = Frame::parse(frame_bytes) else {
2599            return false;
2600        };
2601        match frame.command() {
2602            Some(Cmd::StrRecv) => {
2603                let Ok(payload) = StreamPayload::parse(frame.payload) else {
2604                    return false;
2605                };
2606                if payload.stream != stream::PHY_RAW {
2607                    return false;
2608                }
2609                let meta = RxMeta::decode(payload.metadata).unwrap_or_default();
2610                if self.rx_queue.len() >= RX_QUEUE_DEPTH {
2611                    self.rx_queue.pop_front();
2612                }
2613                self.rx_queue.push_back(RxPacket {
2614                    data: payload.data.to_vec(),
2615                    meta,
2616                    raw_meta: payload.metadata.to_vec(),
2617                });
2618                return true;
2619            }
2620            Some(Cmd::PropIs) => self.ingest_prop_notification(ResponseKind::Is, &frame),
2621            Some(Cmd::PropInserted) => {
2622                self.ingest_prop_notification(ResponseKind::Inserted, &frame)
2623            }
2624            Some(Cmd::PropRemoved) => self.ingest_prop_notification(ResponseKind::Removed, &frame),
2625            // `CMD_PROP_ARE` carries entries rather than one key and
2626            // value, so it is queued whole and decoded by whichever
2627            // multi-property transaction is waiting on the TID. It is
2628            // never unsolicited, so a TID-0 one is dropped.
2629            Some(Cmd::PropAre) => {
2630                let tid = frame.header.tid();
2631                if tid != TID_UNSOLICITED {
2632                    if self.responses.len() >= RESPONSE_QUEUE_DEPTH {
2633                        self.responses.pop_front();
2634                    }
2635                    self.responses.push_back(Response {
2636                        tid,
2637                        kind: ResponseKind::Are,
2638                        key: prop::LAST_STATUS,
2639                        value: frame.payload.to_vec(),
2640                    });
2641                }
2642            }
2643            _ => {}
2644        }
2645        false
2646    }
2647
2648    fn ingest_prop_notification(&mut self, kind: ResponseKind, frame: &Frame<'_>) {
2649        let Ok(notification) = PropertyNotification::from_frame(frame) else {
2650            return;
2651        };
2652        // The caller dispatches from the parsed command; keep that assertion
2653        // explicit so future command additions cannot be misclassified.
2654        if notification.kind != kind {
2655            return;
2656        }
2657        let tid = notification.tid;
2658        if tid != TID_UNSOLICITED {
2659            if self.responses.len() >= RESPONSE_QUEUE_DEPTH {
2660                self.responses.pop_front();
2661            }
2662            self.responses.push_back(Response {
2663                tid,
2664                kind,
2665                key: notification.key,
2666                value: notification.value.to_vec(),
2667            });
2668            return;
2669        }
2670        // Unsolicited `PROP_LAST_STATUS` is a reset notice or an
2671        // operation status, not a property update to retain.
2672        if kind == ResponseKind::Is && notification.key == prop::LAST_STATUS {
2673            let status = decode_status(notification.value);
2674            if status.is_reset() {
2675                self.seen_reset = Some(status);
2676            }
2677            return;
2678        }
2679        let event = match kind {
2680            ResponseKind::Is => PropEvent::Is {
2681                key: notification.key,
2682                value: notification.value.to_vec(),
2683            },
2684            ResponseKind::Inserted => PropEvent::Inserted {
2685                key: notification.key,
2686                digest: notification.value.to_vec(),
2687            },
2688            ResponseKind::Removed => PropEvent::Removed {
2689                key: notification.key,
2690                digest: notification.value.to_vec(),
2691            },
2692            // Unreachable: `PropertyNotification` refuses the
2693            // multi-property form, and this path only sees notifications
2694            // it parsed.
2695            ResponseKind::Are => return,
2696        };
2697        if self.prop_events.len() >= PROP_EVENT_DEPTH {
2698            self.prop_events.pop_front();
2699        }
2700        self.prop_events.push_back(event);
2701    }
2702
2703    /// Take the oldest retained unsolicited property notification.
2704    ///
2705    /// Events accumulate while other calls read from the link (bounded
2706    /// at [`PROP_EVENT_DEPTH`], oldest dropped first).
2707    pub fn pop_prop_event(&mut self) -> Option<PropEvent> {
2708        self.prop_events.pop_front()
2709    }
2710
2711    /// Read from the stream until the response for `tid` arrives.
2712    ///
2713    /// Frames received meanwhile are queued for [`Radio::poll_receive`].
2714    async fn wait_response(&mut self, tid: u8, deadline: Instant) -> Result<Response, UlcpError> {
2715        loop {
2716            // Drain responses before honoring a reset notice: if both
2717            // arrived in one read, the response was sent first and the
2718            // command did complete. The reset stays latched for the
2719            // next receive poll.
2720            while let Some(response) = self.responses.pop_front() {
2721                if response.tid == tid {
2722                    return Ok(response);
2723                }
2724                // A stale response from an earlier timed-out
2725                // transaction; drop it.
2726            }
2727            if let Some(status) = self.seen_reset.take() {
2728                return Err(UlcpError::UnexpectedReset(status));
2729            }
2730            self.read_more(deadline).await?;
2731        }
2732    }
2733
2734    /// Read until the device announces a reset via `PROP_LAST_STATUS`.
2735    async fn wait_reset(&mut self, deadline: Instant) -> Result<Status, UlcpError> {
2736        loop {
2737            if let Some(status) = self.seen_reset.take() {
2738                return Ok(status);
2739            }
2740            // Accept a reset notice even if the device attached a TID.
2741            while let Some(response) = self.responses.pop_front() {
2742                if response.kind == ResponseKind::Is && response.key == prop::LAST_STATUS {
2743                    let status = decode_status(&response.value);
2744                    if status.is_reset() {
2745                        return Ok(status);
2746                    }
2747                }
2748            }
2749            self.read_more(deadline).await?;
2750        }
2751    }
2752
2753    /// Read and ingest one frame before `deadline`; returns whether it
2754    /// was a stream frame that is now the back of `rx_queue`.
2755    async fn read_more(&mut self, deadline: Instant) -> Result<bool, UlcpError> {
2756        let now = Instant::now();
2757        if now >= deadline {
2758            return Err(UlcpError::Timeout);
2759        }
2760        let frame = match tokio::time::timeout(deadline - now, self.link.recv_frame()).await {
2761            Err(_elapsed) => return Err(UlcpError::Timeout),
2762            Ok(Err(error)) => return Err(error),
2763            Ok(Ok(frame)) => frame,
2764        };
2765        Ok(self.ingest_frame(&frame))
2766    }
2767
2768    fn pop_rx(&mut self, buf: &mut [u8]) -> Option<RxInfo> {
2769        let packet = self.rx_queue.pop_front()?;
2770        let len = packet.data.len().min(buf.len());
2771        buf[..len].copy_from_slice(&packet.data[..len]);
2772        // `RX_FLAG_SELF_TX` says the *device* transmitted the frame. To
2773        // the host that is not a self-transmission at all — the device is
2774        // a separate node, and its frames are as real as any peer's. What
2775        // carries across is that nothing measured the frame on its way
2776        // here, which is exactly what `Backhaul` means on this side of
2777        // the link. Calling it `LocalTx` would tell the host's own MAC
2778        // that its antenna had already sent the frame, and it would
2779        // refuse to forward it.
2780        let self_tx = BufferedRxMeta::decode(&packet.raw_meta)
2781            .is_ok_and(|meta| meta.flags & RX_FLAG_SELF_TX != 0);
2782        Some(RxInfo {
2783            len,
2784            rssi: packet.meta.rssi_dbm.unwrap_or(0),
2785            snr: Snr::from_centibels(packet.meta.snr_cb.unwrap_or(0)),
2786            lqi: packet.meta.lqi,
2787            origin: if self_tx {
2788                RxOrigin::Backhaul
2789            } else {
2790                RxOrigin::Air
2791            },
2792        })
2793    }
2794
2795    /// Issue one `CMD_STR_SEND` with an already-encoded metadata block
2796    /// and await its confirmation, retrying while CCA reports the
2797    /// channel busy and `cca_deadline` has not passed.
2798    async fn send_confirmed(
2799        &mut self,
2800        data: &[u8],
2801        metadata: &[u8],
2802        cca_deadline: Option<Instant>,
2803    ) -> Result<(), TxError<UlcpError>> {
2804        loop {
2805            let tid = self.alloc_tid();
2806            let mut frame_buf = vec![0u8; data.len() + metadata.len() + 16];
2807            let frame_len = frame::str_send(&mut frame_buf, tid, stream::PHY_RAW, data, metadata)
2808                .map_err(|_| TxError::Io(UlcpError::Protocol("frame encode")))?;
2809            self.send(&frame_buf[..frame_len])
2810                .await
2811                .map_err(TxError::Io)?;
2812
2813            // The confirmation arrives only after the frame is on the
2814            // air (or definitively failed), so allow for airtime.
2815            let deadline = Instant::now()
2816                + self.config.response_timeout
2817                + Duration::from_millis(u64::from(self.t_frame_ms) * 2);
2818            let response = self
2819                .wait_response(tid, deadline)
2820                .await
2821                .map_err(TxError::Io)?;
2822            if response.kind != ResponseKind::Is || response.key != prop::LAST_STATUS {
2823                return Err(TxError::Io(UlcpError::Protocol(
2824                    "unexpected transmit response",
2825                )));
2826            }
2827            match decode_status(&response.value) {
2828                Status::OK => return Ok(()),
2829                Status::CCA_FAILURE => match cca_deadline {
2830                    Some(deadline) if Instant::now() < deadline => {
2831                        tokio::time::sleep(CCA_RETRY_DELAY).await;
2832                    }
2833                    _ => return Err(TxError::CadTimeout),
2834                },
2835                status => return Err(TxError::Io(UlcpError::Status(status))),
2836            }
2837        }
2838    }
2839
2840    /// Transmit a frame with a caller-supplied `STR_PHY_RAW` metadata
2841    /// block, byte for byte.
2842    ///
2843    /// [`Radio::transmit`] composes the metadata from [`TxOptions`],
2844    /// which is what a MAC wants. A bridge does not: it relays frames
2845    /// whose transmit parameters were decided elsewhere, and must be
2846    /// able to put exactly those bytes on the wire — including fields
2847    /// [`TxOptions`] has no vocabulary for, such as a power override.
2848    ///
2849    /// The channel-access retry budget comes from the metadata itself:
2850    /// with `TX_FLAG_NOCCA` clear the device performs CCA and a busy
2851    /// channel fails immediately with [`TxError::CadTimeout`], leaving
2852    /// the retry policy to the caller.
2853    pub async fn transmit_raw_with_meta(
2854        &mut self,
2855        data: &[u8],
2856        metadata: &[u8],
2857    ) -> Result<(), TxError<UlcpError>> {
2858        if data.len() > self.max_frame_size {
2859            return Err(TxError::Io(UlcpError::FrameTooLarge(data.len())));
2860        }
2861        let skips_cca = metadata
2862            .get(1)
2863            .is_some_and(|flags| flags & TX_FLAG_NOCCA != 0);
2864        let cca_deadline = (!skips_cca).then(Instant::now);
2865        self.send_confirmed(data, metadata, cca_deadline).await
2866    }
2867
2868    /// Poll for one inbound frame, preserving its metadata bytes.
2869    ///
2870    /// [`Radio::poll_receive`] decodes the metadata into [`RxInfo`],
2871    /// which cannot represent it faithfully: the "unsupported" sentinels
2872    /// collapse to zero and the buffered-frame extension is discarded
2873    /// entirely. A bridge relays the metadata rather than interpreting
2874    /// it, so it needs the bytes.
2875    pub fn poll_receive_raw(
2876        &mut self,
2877        cx: &mut core::task::Context<'_>,
2878    ) -> core::task::Poll<Result<RawRxFrame, UlcpError>> {
2879        loop {
2880            if let Some(status) = self.seen_reset.take() {
2881                return core::task::Poll::Ready(Err(UlcpError::UnexpectedReset(status)));
2882            }
2883            if let Some(packet) = self.rx_queue.pop_front() {
2884                return core::task::Poll::Ready(Ok(RawRxFrame {
2885                    data: packet.data,
2886                    metadata: packet.raw_meta,
2887                }));
2888            }
2889
2890            match self.link.poll_recv_frame(cx) {
2891                core::task::Poll::Ready(Ok(frame)) => {
2892                    self.ingest_frame(&frame);
2893                }
2894                core::task::Poll::Ready(Err(error)) => return core::task::Poll::Ready(Err(error)),
2895                core::task::Poll::Pending => return core::task::Poll::Pending,
2896            }
2897        }
2898    }
2899
2900    /// Await one inbound frame with its metadata bytes intact.
2901    ///
2902    /// Cancel-safe: the frame is only removed from the inbound queue
2903    /// once this future is ready to return it.
2904    pub async fn receive_raw(&mut self) -> Result<RawRxFrame, UlcpError> {
2905        core::future::poll_fn(|cx| self.poll_receive_raw(cx)).await
2906    }
2907}
2908
2909/// One inbound `STR_PHY_RAW` frame with its trailing metadata exactly as
2910/// the device sent it.
2911#[derive(Clone, Debug)]
2912pub struct RawRxFrame {
2913    pub data: Vec<u8>,
2914    pub metadata: Vec<u8>,
2915}
2916
2917#[cfg(feature = "serial-radio")]
2918impl UlcpDevice<SerialFrameLink<tokio_serial::SerialStream>> {
2919    /// Attach to a device on a serial port.
2920    pub async fn open_serial(
2921        path: impl AsRef<str>,
2922        baud_rate: u32,
2923        config: UlcpDeviceConfig,
2924    ) -> Result<Self, UlcpError> {
2925        use tokio_serial::SerialPortBuilderExt;
2926
2927        let stream = tokio_serial::new(path.as_ref(), baud_rate)
2928            .open_native_async()
2929            .map_err(|error| UlcpError::Io(error.into()))?;
2930        Self::new(SerialFrameLink::new(stream), config).await
2931    }
2932}
2933
2934#[cfg(feature = "ble-radio")]
2935impl UlcpDevice<BleFrameLink> {
2936    /// Discover, connect, attach, and initialize a BLE companion radio.
2937    pub async fn open_ble(
2938        selector: Option<&str>,
2939        config: UlcpDeviceConfig,
2940    ) -> Result<Self, UlcpError> {
2941        Self::open_ble_with_link_config(selector, config, BleFrameLinkConfig::default()).await
2942    }
2943
2944    /// As [`Self::open_ble`], with an explicit GATT link configuration.
2945    pub async fn open_ble_with_link_config(
2946        selector: Option<&str>,
2947        config: UlcpDeviceConfig,
2948        link_config: BleFrameLinkConfig,
2949    ) -> Result<Self, UlcpError> {
2950        let link = BleFrameLink::connect(selector, link_config).await?;
2951        Self::new(link, config).await
2952    }
2953}
2954
2955impl<L> Radio for UlcpDevice<L>
2956where
2957    L: FrameLink,
2958{
2959    type Error = UlcpError;
2960
2961    /// Transmit one frame and await the device's confirmation.
2962    ///
2963    /// A confirmed transmit blocks the caller for up to
2964    /// `response_timeout + 2 × t_frame_ms` while the frame goes out on air. This
2965    /// is inherent to the half-duplex [`Radio::transmit`] contract and a real
2966    /// radio behaves the same way. Frames the device receives during this window
2967    /// are not lost — they are queued (see [`wait_response`](Self::wait_response)
2968    /// → [`ingest`](Self::ingest)) and surface on the next
2969    /// [`poll_receive`](Radio::poll_receive). MAC-layer timers (ACK timeouts,
2970    /// retransmit deadlines) cannot advance while this future is pending, but
2971    /// they are only *delayed*, not missed: the coordinator re-evaluates every
2972    /// deadline against the current clock as soon as `transmit` returns, so a
2973    /// deadline that came due mid-transmit fires immediately afterward.
2974    async fn transmit(
2975        &mut self,
2976        data: &[u8],
2977        options: TxOptions,
2978    ) -> Result<(), TxError<Self::Error>> {
2979        if data.len() > self.max_frame_size {
2980            return Err(TxError::Io(UlcpError::FrameTooLarge(data.len())));
2981        }
2982
2983        // The device performs CCA itself; the CAD policy becomes a host-side
2984        // retry budget around `STATUS_CCA_FAILURE`.
2985        let mut meta = TxMeta::default();
2986        let cca_deadline = match options.cad {
2987            CadPolicy::Skip => {
2988                meta.flags |= TX_FLAG_NOCCA;
2989                None
2990            }
2991            // Gate is a single attempt: a zero-length budget, so a busy channel
2992            // fails immediately with CadTimeout.
2993            CadPolicy::Gate => Some(Instant::now()),
2994            CadPolicy::RetryFor { timeout_ms } => {
2995                Some(Instant::now() + Duration::from_millis(timeout_ms.into()))
2996            }
2997        };
2998        let mut meta_buf = [0u8; TxMeta::WIRE_LEN];
2999        let meta_len = meta
3000            .encode(&mut meta_buf)
3001            .expect("buffer sized with WIRE_LEN");
3002
3003        self.send_confirmed(data, &meta_buf[..meta_len], cca_deadline)
3004            .await
3005    }
3006
3007    fn poll_receive(
3008        &mut self,
3009        cx: &mut core::task::Context<'_>,
3010        buf: &mut [u8],
3011    ) -> core::task::Poll<Result<RxInfo, Self::Error>> {
3012        loop {
3013            if let Some(status) = self.seen_reset.take() {
3014                return core::task::Poll::Ready(Err(UlcpError::UnexpectedReset(status)));
3015            }
3016            if let Some(info) = self.pop_rx(buf) {
3017                return core::task::Poll::Ready(Ok(info));
3018            }
3019
3020            match self.link.poll_recv_frame(cx) {
3021                core::task::Poll::Ready(Ok(frame)) => {
3022                    self.ingest_frame(&frame);
3023                }
3024                core::task::Poll::Ready(Err(error)) => return core::task::Poll::Ready(Err(error)),
3025                core::task::Poll::Pending => return core::task::Poll::Pending,
3026            }
3027        }
3028    }
3029
3030    fn max_frame_size(&self) -> usize {
3031        self.max_frame_size
3032    }
3033
3034    fn t_frame_ms(&self) -> u32 {
3035        self.t_frame_ms
3036    }
3037}
3038
3039// ─── Value decoders ──────────────────────────────────────────────────
3040//
3041// Public because fetching and decoding are separable and a batched read
3042// separates them. A caller that asks for a dozen properties in one
3043// `CMD_PROP_MULTI_GET` holds a bag of octets afterwards, and needs the
3044// same readings the per-property methods here apply — otherwise the only
3045// way to understand a value is to spend a round trip fetching it alone,
3046// which is the cost batching exists to avoid.
3047
3048/// Decode a `PROP_LAST_STATUS` value. Anything malformed reads as
3049/// `STATUS_FAILURE`, which is what a device unable to say why would mean.
3050pub fn decode_status(value: &[u8]) -> Status {
3051    match pui::decode(value) {
3052        Ok((code, _)) => Status(code),
3053        Err(_) => Status::FAILURE,
3054    }
3055}
3056
3057/// Decode a `PROP_CAPS` value: capability codes as consecutive PUIs.
3058pub fn decode_capabilities(value: &[u8]) -> Result<Vec<u32>, UlcpError> {
3059    let mut caps = Vec::new();
3060    let mut offset = 0;
3061    while offset < value.len() {
3062        let (code, used) = pui::decode(&value[offset..])
3063            .map_err(|_| UlcpError::Protocol("malformed PROP_CAPS"))?;
3064        caps.push(code);
3065        offset += used;
3066    }
3067    Ok(caps)
3068}
3069
3070/// Decode a `PROP_HOST_RX_FILTERS` digest table (PUI-length-prefixed
3071/// filter items).
3072pub fn decode_filter_table(value: &[u8]) -> Result<Vec<items::Filter>, UlcpError> {
3073    let mut filters = Vec::new();
3074    for item in items::prefixed_items(value) {
3075        let item = item.map_err(|_| UlcpError::Protocol("malformed PROP_HOST_RX_FILTERS"))?;
3076        filters.push(
3077            items::Filter::decode(item)
3078                .map_err(|_| UlcpError::Protocol("malformed PROP_HOST_RX_FILTERS"))?,
3079        );
3080    }
3081    Ok(filters)
3082}
3083
3084/// Decode `PROP_MAC_REPEATER_REGIONS`: length-prefixed UTF-8 region
3085/// strings, in the order they were written.
3086pub fn decode_region_list(value: &[u8]) -> Result<Vec<String>, UlcpError> {
3087    let mut regions = Vec::new();
3088    for item in items::prefixed_items(value) {
3089        let item = item.map_err(|_| UlcpError::Protocol("malformed PROP_MAC_REPEATER_REGIONS"))?;
3090        let text = core::str::from_utf8(item)
3091            .map_err(|_| UlcpError::Protocol("malformed PROP_MAC_REPEATER_REGIONS"))?;
3092        regions.push(text.to_owned());
3093    }
3094    Ok(regions)
3095}
3096
3097/// Reject a region string the device is bound to refuse, so a malformed
3098/// write fails here rather than costing a round trip
3099/// (ulcp-device.md § `PROP_MAC_REPEATER_REGIONS`).
3100fn check_region(region: &str) -> Result<(), UlcpError> {
3101    match (1..=umsh_core::REGION_NAME_MAX_LEN).contains(&region.len()) {
3102        true => Ok(()),
3103        false => Err(UlcpError::Protocol(
3104            "a region string is 1 to 24 octets of UTF-8",
3105        )),
3106    }
3107}
3108
3109/// Decode a single optional region code. Empty means unset.
3110pub fn decode_region_code(value: &[u8]) -> Result<Option<RegionCode>, UlcpError> {
3111    match value {
3112        [] => Ok(None),
3113        [high, low] => Ok(Some(RegionCode::from_bytes([*high, *low]))),
3114        _ => Err(UlcpError::Protocol(
3115            "malformed PROP_MAC_REPEATER_DEFAULT_REGION",
3116        )),
3117    }
3118}
3119
3120/// Decode an optional INT16 gate. Empty means unset; `None` is malformed.
3121pub fn decode_opt_i16(value: &[u8]) -> Option<Option<i16>> {
3122    match value {
3123        [] => Some(None),
3124        [low, high] => Some(Some(i16::from_le_bytes([*low, *high]))),
3125        _ => None,
3126    }
3127}
3128
3129/// Decode an optional INT8 gate. Empty means unset; `None` is malformed.
3130pub fn decode_opt_i8(value: &[u8]) -> Option<Option<i8>> {
3131    match value {
3132        [] => Some(None),
3133        [byte] => Some(Some(*byte as i8)),
3134        _ => None,
3135    }
3136}
3137
3138/// Decode a `PROP_ALERT` value: exactly one PUI naming a known state.
3139pub fn decode_alert(value: &[u8]) -> Result<AlertState, UlcpError> {
3140    const MALFORMED: &str = "malformed PROP_ALERT";
3141    let (code, consumed) = pui::decode(value).map_err(|_| UlcpError::Protocol(MALFORMED))?;
3142    if consumed != value.len() {
3143        return Err(UlcpError::Protocol(MALFORMED));
3144    }
3145    AlertState::from_code(code).ok_or(UlcpError::Protocol(MALFORMED))
3146}
3147
3148/// Decode a `PROP_TIME` value. Empty means the device does not know what
3149/// time it is, which is an answer rather than a malformed one.
3150fn decode_epoch(value: &[u8]) -> Result<Option<u32>, UlcpError> {
3151    match value {
3152        [] => Ok(None),
3153        [a, b, c, d] => Ok(Some(u32::from_le_bytes([*a, *b, *c, *d]))),
3154        _ => Err(UlcpError::Protocol("malformed PROP_TIME")),
3155    }
3156}
3157
3158/// Decode a `PROP_TZ_OFFSET` value: minutes east of UTC, always present.
3159fn decode_tz_offset(value: &[u8]) -> Result<i16, UlcpError> {
3160    match value {
3161        [low, high] => Ok(i16::from_le_bytes([*low, *high])),
3162        _ => Err(UlcpError::Protocol("malformed PROP_TZ_OFFSET")),
3163    }
3164}
3165
3166/// Decode a single-octet boolean property, naming the property in the
3167/// error so a malformed one is attributable.
3168fn decode_bool(value: &[u8], what: &'static str) -> Result<bool, UlcpError> {
3169    match value {
3170        [0] => Ok(false),
3171        [1] => Ok(true),
3172        _ => Err(UlcpError::Protocol(what)),
3173    }
3174}
3175
3176/// Decode a `UINT32_LE` announcement interval in seconds.
3177fn decode_interval(value: &[u8]) -> Result<u32, UlcpError> {
3178    match value {
3179        [a, b, c, d] => Ok(u32::from_le_bytes([*a, *b, *c, *d])),
3180        _ => Err(UlcpError::Protocol("malformed announcement interval")),
3181    }
3182}
3183
3184/// Decode a digest table of fixed-size items.
3185fn decode_fixed_list<const N: usize>(
3186    value: &[u8],
3187    what: &'static str,
3188) -> Result<Vec<[u8; N]>, UlcpError> {
3189    items::fixed_items::<N>(value)
3190        .map(|iterator| iterator.copied().collect())
3191        .map_err(|_| UlcpError::Protocol(what))
3192}
3193
3194/// Render one ULCP frame as a one-line human-readable summary:
3195/// command, TID, property mnemonic, and the decoded status where the
3196/// payload is a `PROP_LAST_STATUS` value. Values are summarized by
3197/// length — never dumped — so traces cannot leak key material.
3198pub fn describe_frame(bytes: &[u8]) -> String {
3199    umsh_ulcp::FrameDescription(bytes).to_string()
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204    use super::*;
3205    use std::collections::HashMap;
3206    use tokio::io::{AsyncReadExt, DuplexStream};
3207    use umsh_ulcp::PropPayload;
3208    use umsh_ulcp::meta::RX_FLAG_BUFFERED;
3209
3210    /// Payload that makes the fake device report a CCA failure.
3211    const CCA_FAIL: &[u8] = b"cca-fail";
3212    /// Payload that makes the fake device report success and then
3213    /// announce a spurious watchdog reset.
3214    const RESET_AFTER: &[u8] = b"reset-after";
3215    /// Property that switches the fake device's `CMD_RESTORE` completion
3216    /// to the reset form.
3217    const RESTORE_RESET_FORM_KEY: u32 = 59_999;
3218
3219    /// Minimal in-process device: answers the initialization handshake,
3220    /// stores property sets and multi-value tables, and echoes
3221    /// transmitted frames back as received frames.
3222    ///
3223    /// Generic over the stream so the same device can be reached down a
3224    /// pipe or across a socket — which is the whole claim TCP support
3225    /// rests on.
3226    async fn fake_device<IO: AsyncRead + AsyncWrite + Unpin>(mut io: IO) {
3227        let mut decoder = hdlc::Decoder::<WIRE_BUF>::new();
3228        let mut props: HashMap<u32, Vec<u8>> = HashMap::new();
3229        let mut tables: HashMap<u32, Vec<Vec<u8>>> = HashMap::new();
3230        let mut chunk = [0u8; READ_CHUNK];
3231        loop {
3232            let read = match io.read(&mut chunk).await {
3233                Ok(0) | Err(_) => return,
3234                Ok(read) => read,
3235            };
3236            let mut replies: Vec<Vec<u8>> = Vec::new();
3237            for &byte in &chunk[..read] {
3238                let Some(Ok(frame_bytes)) = decoder.push(byte) else {
3239                    continue;
3240                };
3241                let frame = Frame::parse(frame_bytes).expect("host sent malformed frame");
3242                let tid = frame.header.tid();
3243                let mut buf = vec![0u8; 512];
3244                match frame.command().expect("host sent unknown command") {
3245                    Cmd::Reset => {
3246                        let len =
3247                            frame::last_status(&mut buf, TID_UNSOLICITED, Status::RESET_SOFTWARE)
3248                                .unwrap();
3249                        replies.push(buf[..len].to_vec());
3250                    }
3251                    Cmd::PropGet => {
3252                        let key = PropPayload::parse(frame.payload).unwrap().key;
3253                        let value: Vec<u8> = match key {
3254                            prop::LAST_STATUS => vec![Status::RESET_POWER_ON.0 as u8],
3255                            prop::PROTOCOL_VERSION => {
3256                                vec![ids::PROTOCOL_MAJOR_VERSION, ids::PROTOCOL_MINOR_VERSION]
3257                            }
3258                            prop::DEV_VERSION => b"fake-dev/0.1\0".to_vec(),
3259                            prop::DEV_MODEL => b"Fake Board\0".to_vec(),
3260                            prop::PHY_MTU => 255u16.to_le_bytes().to_vec(),
3261                            _ => props.get(&key).cloned().unwrap_or_default(),
3262                        };
3263                        let len = frame::prop_is(&mut buf, tid, key, &value).unwrap();
3264                        replies.push(buf[..len].to_vec());
3265                    }
3266                    Cmd::PropSet => {
3267                        let payload = PropPayload::parse(frame.payload).unwrap();
3268                        props.insert(payload.key, payload.value.to_vec());
3269                        let len = if payload.key == prop::BLE_PAIRING_PIN {
3270                            frame::last_status(&mut buf, tid, Status::OK).unwrap()
3271                        } else {
3272                            frame::prop_is(&mut buf, tid, payload.key, payload.value).unwrap()
3273                        };
3274                        replies.push(buf[..len].to_vec());
3275                    }
3276                    Cmd::StrSend => {
3277                        let payload = StreamPayload::parse(frame.payload).unwrap();
3278                        assert_eq!(payload.stream, stream::PHY_RAW);
3279                        if payload.data == CCA_FAIL {
3280                            let len =
3281                                frame::last_status(&mut buf, tid, Status::CCA_FAILURE).unwrap();
3282                            replies.push(buf[..len].to_vec());
3283                            continue;
3284                        }
3285                        let len = frame::last_status(&mut buf, tid, Status::OK).unwrap();
3286                        replies.push(buf[..len].to_vec());
3287                        if payload.data == RESET_AFTER {
3288                            let len = frame::last_status(
3289                                &mut buf,
3290                                TID_UNSOLICITED,
3291                                Status::RESET_WATCHDOG,
3292                            )
3293                            .unwrap();
3294                            replies.push(buf[..len].to_vec());
3295                            continue;
3296                        }
3297                        // Echo the packet back as a reception.
3298                        let mut meta = [0u8; RxMeta::WIRE_LEN];
3299                        RxMeta {
3300                            rssi_dbm: Some(-91),
3301                            lqi: None,
3302                            snr_cb: Some(55),
3303                        }
3304                        .encode(&mut meta)
3305                        .unwrap();
3306                        let len = frame::str_recv(&mut buf, stream::PHY_RAW, payload.data, &meta)
3307                            .unwrap();
3308                        replies.push(buf[..len].to_vec());
3309                    }
3310                    Cmd::Nop => {
3311                        let len = frame::last_status(&mut buf, tid, Status::OK).unwrap();
3312                        replies.push(buf[..len].to_vec());
3313                    }
3314                    Cmd::PropInsert => {
3315                        let payload = PropPayload::parse(frame.payload).unwrap();
3316                        // PROP_HOST_PEER_KEYS: secret-bearing 64-byte item,
3317                        // 32-byte public-key digest, insert-replaces on a
3318                        // matching public key. Other tables: item == digest,
3319                        // duplicates fail with STATUS_ALREADY.
3320                        let replaces = payload.key == prop::HOST_PEER_KEYS;
3321                        let stored = payload.value.to_vec();
3322                        let digest_len = if replaces {
3323                            assert_eq!(stored.len(), 64);
3324                            32
3325                        } else {
3326                            stored.len()
3327                        };
3328                        let table = tables.entry(payload.key).or_default();
3329                        let existing = table.iter_mut().find(|item| {
3330                            item[..digest_len.min(item.len())] == stored[..digest_len]
3331                        });
3332                        let len = match existing {
3333                            Some(_) if !replaces => {
3334                                frame::last_status(&mut buf, tid, Status::ALREADY).unwrap()
3335                            }
3336                            Some(existing) => {
3337                                *existing = stored.clone();
3338                                frame::prop_inserted(
3339                                    &mut buf,
3340                                    tid,
3341                                    payload.key,
3342                                    &stored[..digest_len],
3343                                )
3344                                .unwrap()
3345                            }
3346                            None => {
3347                                table.push(stored.clone());
3348                                frame::prop_inserted(
3349                                    &mut buf,
3350                                    tid,
3351                                    payload.key,
3352                                    &stored[..digest_len],
3353                                )
3354                                .unwrap()
3355                            }
3356                        };
3357                        replies.push(buf[..len].to_vec());
3358                    }
3359                    Cmd::PropRemove => {
3360                        let payload = PropPayload::parse(frame.payload).unwrap();
3361                        let table = tables.entry(payload.key).or_default();
3362                        let position = table.iter().position(|item| {
3363                            item[..payload.value.len().min(item.len())] == *payload.value
3364                        });
3365                        let len = match position {
3366                            Some(index) => {
3367                                let removed = table.remove(index);
3368                                let digest = &removed[..payload.value.len().min(removed.len())];
3369                                frame::prop_removed(&mut buf, tid, payload.key, digest).unwrap()
3370                            }
3371                            None => {
3372                                frame::last_status(&mut buf, tid, Status::ITEM_NOT_FOUND).unwrap()
3373                            }
3374                        };
3375                        replies.push(buf[..len].to_vec());
3376                    }
3377                    Cmd::QueueDrain => {
3378                        // Two buffered frames, oldest first, then completion.
3379                        for (index, age_s) in [5u32, 3].into_iter().enumerate() {
3380                            let mut meta = [0u8; BufferedRxMeta::WIRE_LEN];
3381                            BufferedRxMeta {
3382                                rx: RxMeta {
3383                                    rssi_dbm: Some(-80),
3384                                    lqi: None,
3385                                    snr_cb: Some(10),
3386                                },
3387                                flags: RX_FLAG_BUFFERED,
3388                                age_s,
3389                            }
3390                            .encode(&mut meta)
3391                            .unwrap();
3392                            let data = [0xB0u8 + index as u8];
3393                            let len =
3394                                frame::str_recv(&mut buf, stream::PHY_RAW, &data, &meta).unwrap();
3395                            replies.push(buf[..len].to_vec());
3396                        }
3397                        let len = frame::last_status(&mut buf, tid, Status::OK).unwrap();
3398                        replies.push(buf[..len].to_vec());
3399                    }
3400                    // The fake device has no durable state to erase, so a
3401                    // factory reset is acknowledged like the rest; the
3402                    // reboot it implies is out of this harness's scope.
3403                    Cmd::Save | Cmd::Clear | Cmd::FactoryReset => {
3404                        let len = frame::last_status(&mut buf, tid, Status::OK).unwrap();
3405                        replies.push(buf[..len].to_vec());
3406                    }
3407                    Cmd::Restore => {
3408                        if props
3409                            .get(&RESTORE_RESET_FORM_KEY)
3410                            .is_some_and(|value| value == &[1])
3411                        {
3412                            let len = frame::last_status(
3413                                &mut buf,
3414                                TID_UNSOLICITED,
3415                                Status::RESET_RESTORED,
3416                            )
3417                            .unwrap();
3418                            replies.push(buf[..len].to_vec());
3419                        } else {
3420                            // Update form: publish the reverted value, then
3421                            // the correlated completion.
3422                            let len = frame::prop_is(
3423                                &mut buf,
3424                                TID_UNSOLICITED,
3425                                prop::PHY_FREQ,
3426                                &905_000u32.to_le_bytes(),
3427                            )
3428                            .unwrap();
3429                            replies.push(buf[..len].to_vec());
3430                            let len = frame::last_status(&mut buf, tid, Status::OK).unwrap();
3431                            replies.push(buf[..len].to_vec());
3432                        }
3433                    }
3434                    // This device predates CAP_CMD_MULTI and CAP_REBOOT,
3435                    // and manages no bonds of its own: it answers commands
3436                    // it does not implement the way any such device
3437                    // answers them.
3438                    Cmd::PropMultiGet | Cmd::PropMultiSet | Cmd::Reboot | Cmd::BleClearBonds => {
3439                        let len = frame::last_status(&mut buf, tid, Status::UNIMPLEMENTED).unwrap();
3440                        replies.push(buf[..len].to_vec());
3441                    }
3442                    Cmd::PropIs
3443                    | Cmd::StrRecv
3444                    | Cmd::PropInserted
3445                    | Cmd::PropRemoved
3446                    | Cmd::PropAre => {
3447                        panic!("host sent a device-only command")
3448                    }
3449                }
3450            }
3451            for reply in replies {
3452                let mut wire = vec![0u8; hdlc::max_encoded_len(reply.len())];
3453                let len = hdlc::encode_frame(&reply, &mut wire).unwrap();
3454                if io.write_all(&wire[..len]).await.is_err() {
3455                    return;
3456                }
3457            }
3458        }
3459    }
3460
3461    fn test_config() -> UlcpDeviceConfig {
3462        let mut config = UlcpDeviceConfig::new(906_875, 250_000, 11, 5);
3463        config.tx_power_dbm = 10;
3464        config.response_timeout = Duration::from_millis(500);
3465        config
3466    }
3467
3468    async fn attached_radio() -> UlcpDevice<SerialFrameLink<DuplexStream>> {
3469        let (client, server) = tokio::io::duplex(4096);
3470        tokio::spawn(fake_device(server));
3471        UlcpDevice::new(SerialFrameLink::new(client), test_config())
3472            .await
3473            .unwrap()
3474    }
3475
3476    fn wire(frame: &[u8]) -> Vec<u8> {
3477        let mut encoded = vec![0; hdlc::max_encoded_len(frame.len())];
3478        let len = hdlc::encode_frame(frame, &mut encoded).unwrap();
3479        encoded.truncate(len);
3480        encoded
3481    }
3482
3483    #[tokio::test]
3484    async fn serial_link_preserves_two_frames_from_one_read() {
3485        let (client, mut server) = tokio::io::duplex(1024);
3486        let mut bytes = wire(b"first");
3487        bytes.extend_from_slice(&wire(b"second"));
3488        server.write_all(&bytes).await.unwrap();
3489
3490        let mut link = SerialFrameLink::new(client);
3491        assert_eq!(link.recv_frame().await.unwrap(), b"first");
3492        assert_eq!(link.recv_frame().await.unwrap(), b"second");
3493    }
3494
3495    #[tokio::test]
3496    async fn serial_link_cancellation_keeps_partial_and_buffered_tail() {
3497        let (client, mut server) = tokio::io::duplex(1024);
3498        let first = wire(b"first");
3499        let second = wire(b"second");
3500        let split = second.len() / 2;
3501        let mut initial = first;
3502        initial.extend_from_slice(&second[..split]);
3503        server.write_all(&initial).await.unwrap();
3504
3505        let mut link = SerialFrameLink::new(client);
3506        assert_eq!(link.recv_frame().await.unwrap(), b"first");
3507        assert!(
3508            tokio::time::timeout(Duration::from_millis(1), link.recv_frame())
3509                .await
3510                .is_err()
3511        );
3512        server.write_all(&second[split..]).await.unwrap();
3513        assert_eq!(link.recv_frame().await.unwrap(), b"second");
3514    }
3515
3516    /// A socket is a serial link with a different name on it, which is
3517    /// what lets a bridged port serve a radio over TCP. The payload
3518    /// carries every byte the framing gives meaning to — flag, escape,
3519    /// and both flow-control bytes — so any encoder that treated the
3520    /// socket as transparent would fail here.
3521    #[tokio::test]
3522    async fn serial_link_frames_the_same_bytes_over_a_socket() {
3523        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3524        let endpoint = listener.local_addr().unwrap();
3525        let accepted = tokio::spawn(async move { listener.accept().await.unwrap().0 });
3526        let client = tokio::net::TcpStream::connect(endpoint).await.unwrap();
3527
3528        let mut device = SerialFrameLink::new(accepted.await.unwrap());
3529        let mut host = SerialFrameLink::new(client);
3530
3531        let hostile = [0x7E, 0x7D, 0x11, 0x13, 0x00, 0xFF];
3532        host.send_frame(&hostile).await.unwrap();
3533        assert_eq!(device.recv_frame().await.unwrap(), hostile);
3534
3535        device.send_frame(b"pong").await.unwrap();
3536        assert_eq!(host.recv_frame().await.unwrap(), b"pong");
3537    }
3538
3539    /// The handshake itself over a socket, not just the framing under
3540    /// it: a bridged radio has to attach exactly as a wired one does.
3541    #[tokio::test]
3542    async fn a_radio_attaches_over_a_socket() {
3543        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3544        let endpoint = listener.local_addr().unwrap();
3545        tokio::spawn(async move {
3546            let (stream, _) = listener.accept().await.unwrap();
3547            fake_device(stream).await;
3548        });
3549
3550        let stream = tokio::net::TcpStream::connect(endpoint).await.unwrap();
3551        stream.set_nodelay(true).unwrap();
3552        let mut device = UlcpDevice::new(SerialFrameLink::new(stream), test_config())
3553            .await
3554            .unwrap();
3555
3556        // Attach is only half of it; a property exchange proves the
3557        // session is live in both directions.
3558        device.set_prop(prop::PHY_TX_POWER, &[14]).await.unwrap();
3559        assert_eq!(device.get_prop(prop::PHY_TX_POWER).await.unwrap(), [14]);
3560    }
3561
3562    /// The far end going away has to surface as a dropped link rather
3563    /// than an endless wait, or a session whose bridge was killed would
3564    /// hang instead of reporting a detach.
3565    #[tokio::test]
3566    async fn serial_link_reports_a_closed_socket_as_a_lost_link() {
3567        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3568        let endpoint = listener.local_addr().unwrap();
3569        let accepted = tokio::spawn(async move { listener.accept().await.unwrap().0 });
3570        let client = tokio::net::TcpStream::connect(endpoint).await.unwrap();
3571
3572        drop(accepted.await.unwrap());
3573        let mut host = SerialFrameLink::new(client);
3574        assert!(matches!(
3575            host.recv_frame().await,
3576            Err(UlcpError::Disconnected)
3577        ));
3578    }
3579
3580    #[cfg(feature = "ble-radio")]
3581    #[test]
3582    fn ble_link_config_rejects_invalid_values_without_opening_an_adapter() {
3583        let mut config = BleFrameLinkConfig::default();
3584        assert!(config.validate().is_ok());
3585        config.segment_payload = 0;
3586        assert!(matches!(config.validate(), Err(UlcpError::Protocol(_))));
3587        config.segment_payload = 512;
3588        assert!(matches!(config.validate(), Err(UlcpError::Protocol(_))));
3589        config.segment_payload = 19;
3590        config.operation_timeout = Duration::ZERO;
3591        assert!(matches!(config.validate(), Err(UlcpError::Protocol(_))));
3592        config.operation_timeout = Duration::from_secs(1);
3593        config.pairing_timeout = Duration::ZERO;
3594        assert!(matches!(config.validate(), Err(UlcpError::Protocol(_))));
3595    }
3596
3597    #[cfg(feature = "ble-radio")]
3598    #[tokio::test]
3599    async fn ble_notification_receiver_reassembles_and_recovers_from_malformed_segment() {
3600        let (tx, rx) = tokio::sync::mpsc::channel(8);
3601        let mut receiver = BleNotificationReceiver::new(rx);
3602
3603        // Reserved header bits are malformed and must be dropped without
3604        // poisoning the next valid frame.
3605        tx.send(vec![0x01, 0xff]).await.unwrap();
3606        let frame = b"a frame larger than one tiny GATT segment";
3607        for segment in umsh_ulcp::gatt::segments(frame, 7) {
3608            let mut value = vec![0; segment.payload().len() + 1];
3609            segment.write_to(&mut value).unwrap();
3610            tx.send(value).await.unwrap();
3611        }
3612
3613        let received = core::future::poll_fn(|cx| receiver.poll_recv_frame(cx))
3614            .await
3615            .unwrap();
3616        assert_eq!(received, frame);
3617    }
3618
3619    #[cfg(feature = "ble-radio")]
3620    #[tokio::test]
3621    async fn ble_notification_channel_close_surfaces_disconnect() {
3622        let (tx, rx) = tokio::sync::mpsc::channel(1);
3623        let mut receiver = BleNotificationReceiver::new(rx);
3624        drop(tx);
3625        let result = core::future::poll_fn(|cx| receiver.poll_recv_frame(cx)).await;
3626        assert!(matches!(result, Err(UlcpError::Disconnected)));
3627    }
3628
3629    #[tokio::test]
3630    async fn initialization_handshake() {
3631        let radio = attached_radio().await;
3632        assert_eq!(radio.max_frame_size(), 255);
3633        assert_eq!(radio.dev_version(), "fake-dev/0.1");
3634        assert_eq!(radio.dev_model(), Some("Fake Board"));
3635        assert_eq!(radio.boot_status(), Status::RESET_POWER_ON);
3636        assert!(radio.t_frame_ms() > 0);
3637    }
3638
3639    #[tokio::test]
3640    async fn explicit_reset_returns_the_announced_status() {
3641        let mut radio = attached_radio().await;
3642        let status = radio.reset().await.unwrap();
3643        assert_eq!(status, Status::RESET_SOFTWARE);
3644        // The link and session must remain usable after the reset.
3645        radio.get_prop(prop::LAST_STATUS).await.unwrap();
3646    }
3647
3648    #[tokio::test]
3649    async fn write_only_pairing_pin_accepts_status_completion() {
3650        let mut radio = attached_radio().await;
3651        radio.set_ble_pairing_pin(Some(123_456)).await.unwrap();
3652        radio.set_ble_pairing_pin(None).await.unwrap();
3653        assert!(radio.set_ble_pairing_pin(Some(1_000_000)).await.is_err());
3654
3655        let error = radio
3656            .set_prop(prop::BLE_PAIRING_PIN, &123_456u32.to_le_bytes())
3657            .await
3658            .unwrap_err();
3659        assert!(matches!(error, UlcpError::Protocol(_)));
3660    }
3661
3662    #[tokio::test]
3663    async fn device_name_typed_accessors_round_trip_and_validate() {
3664        let mut radio = attached_radio().await;
3665        radio.set_device_name("Field Radio 📻").await.unwrap();
3666        assert_eq!(radio.device_name().await.unwrap(), "Field Radio 📻");
3667        assert!(radio.set_device_name("").await.is_err());
3668        assert!(radio.set_device_name(&"x".repeat(65)).await.is_err());
3669        assert!(radio.set_device_name("bad\0name").await.is_err());
3670    }
3671
3672    #[tokio::test]
3673    async fn transmit_and_receive_round_trip() {
3674        let mut radio = attached_radio().await;
3675        let packet = [0x10u8, 0x20, 0x30, 0x40];
3676        radio.transmit(&packet, TxOptions::default()).await.unwrap();
3677
3678        let mut buf = [0u8; 256];
3679        let info = core::future::poll_fn(|cx| radio.poll_receive(cx, &mut buf))
3680            .await
3681            .unwrap();
3682        assert_eq!(&buf[..info.len], &packet);
3683        assert_eq!(info.rssi, -91);
3684        assert_eq!(info.snr.as_centibels(), 55);
3685    }
3686
3687    #[tokio::test]
3688    async fn cca_failure_maps_to_cad_timeout() {
3689        let mut radio = attached_radio().await;
3690        let result = radio
3691            .transmit(
3692                CCA_FAIL,
3693                TxOptions {
3694                    cad: CadPolicy::Gate,
3695                },
3696            )
3697            .await;
3698        assert!(matches!(result, Err(TxError::CadTimeout)));
3699    }
3700
3701    #[tokio::test]
3702    async fn oversized_frame_rejected() {
3703        let mut radio = attached_radio().await;
3704        let oversized = vec![0u8; radio.max_frame_size() + 1];
3705        let result = radio.transmit(&oversized, TxOptions::default()).await;
3706        assert!(matches!(
3707            result,
3708            Err(TxError::Io(UlcpError::FrameTooLarge(_)))
3709        ));
3710    }
3711
3712    #[tokio::test]
3713    async fn unexpected_reset_surfaces_on_receive() {
3714        let mut radio = attached_radio().await;
3715        radio
3716            .transmit(RESET_AFTER, TxOptions::default())
3717            .await
3718            .unwrap();
3719
3720        let mut buf = [0u8; 256];
3721        let result = core::future::poll_fn(|cx| radio.poll_receive(cx, &mut buf)).await;
3722        assert!(matches!(
3723            result,
3724            Err(UlcpError::UnexpectedReset(status))
3725                if status == Status::RESET_WATCHDOG
3726        ));
3727    }
3728
3729    #[tokio::test]
3730    async fn table_insert_replace_remove_with_secret_free_digests() {
3731        let mut radio = attached_radio().await;
3732        let mut item = vec![0x11u8; 64];
3733        item[32..].fill(0x22);
3734        let digest = radio
3735            .insert_prop_item(prop::HOST_PEER_KEYS, &item)
3736            .await
3737            .unwrap();
3738        // The digest form is the public key alone — no key material.
3739        assert_eq!(digest, vec![0x11; 32]);
3740
3741        // Same public key, new pairwise keys: replacement, not ALREADY.
3742        let mut replacement = item.clone();
3743        replacement[32..].fill(0x33);
3744        let digest = radio
3745            .insert_prop_item(prop::HOST_PEER_KEYS, &replacement)
3746            .await
3747            .unwrap();
3748        assert_eq!(digest, vec![0x11; 32]);
3749
3750        let removed = radio
3751            .remove_prop_item(prop::HOST_PEER_KEYS, &[0x11; 32])
3752            .await
3753            .unwrap();
3754        assert_eq!(removed, vec![0x11; 32]);
3755        let error = radio
3756            .remove_prop_item(prop::HOST_PEER_KEYS, &[0x11; 32])
3757            .await
3758            .unwrap_err();
3759        assert!(matches!(error, UlcpError::Status(status) if status == Status::ITEM_NOT_FOUND));
3760    }
3761
3762    #[tokio::test]
3763    async fn duplicate_insert_reports_already() {
3764        let mut radio = attached_radio().await;
3765        let filter = [2u8, 0]; // FILTER_PKT_TYPE broadcast
3766        radio
3767            .insert_prop_item(prop::HOST_RX_FILTERS, &filter)
3768            .await
3769            .unwrap();
3770        let error = radio
3771            .insert_prop_item(prop::HOST_RX_FILTERS, &filter)
3772            .await
3773            .unwrap_err();
3774        assert!(matches!(error, UlcpError::Status(status) if status == Status::ALREADY));
3775    }
3776
3777    #[tokio::test]
3778    async fn queue_drain_delivers_buffered_frames_then_completes() {
3779        let mut radio = attached_radio().await;
3780        let mut drained = Vec::new();
3781        radio
3782            .queue_drain_with(|data, meta| {
3783                drained.push((data.to_vec(), BufferedRxMeta::decode(meta).unwrap()));
3784            })
3785            .await
3786            .unwrap();
3787        assert_eq!(drained.len(), 2);
3788        assert!(
3789            drained
3790                .iter()
3791                .all(|(_, meta)| meta.flags & RX_FLAG_BUFFERED != 0)
3792        );
3793        assert_eq!((drained[0].1.age_s, drained[1].1.age_s), (5, 3));
3794
3795        // The frames also surface through the ordinary receive path,
3796        // oldest first.
3797        let mut buf = [0u8; 16];
3798        for expected in [0xB0u8, 0xB1] {
3799            let info = core::future::poll_fn(|cx| radio.poll_receive(cx, &mut buf))
3800                .await
3801                .unwrap();
3802            assert_eq!(&buf[..info.len], &[expected]);
3803        }
3804    }
3805
3806    /// A device that never learned the multi-property commands answers
3807    /// with a plain status rather than a `CMD_PROP_ARE`. That is the
3808    /// exchange failing, not a malformed reply, and the client must say
3809    /// which status ended it.
3810    #[tokio::test]
3811    async fn a_device_without_multi_property_support_reports_unimplemented() {
3812        let mut radio = attached_radio().await;
3813        let error = radio
3814            .get_props(&[prop::PHY_FREQ, prop::PHY_TX_POWER])
3815            .await
3816            .expect_err("the fake device does not implement CMD_PROP_MULTI_GET");
3817        assert!(matches!(error, UlcpError::Status(Status::UNIMPLEMENTED)));
3818    }
3819
3820    /// An administrative handle attaches to a device it does not own, so
3821    /// it refuses to *write* the host domain. Reading it is another
3822    /// matter: a local device answers an administrative read exactly as
3823    /// it answers a tethered one, and refusing here would only mean the
3824    /// question was never asked.
3825    ///
3826    /// The fake device implements no multi-property command, so a read
3827    /// that reaches the wire comes back UNIMPLEMENTED. That is the whole
3828    /// assertion — the refusal is the device's to make, not the handle's.
3829    #[tokio::test]
3830    async fn an_administrative_handle_reads_the_host_domain_but_will_not_write_it() {
3831        let (client, server) = tokio::io::duplex(4096);
3832        tokio::spawn(fake_device(server));
3833        let mut radio = UlcpDevice::bare(SerialFrameLink::new(client), test_config());
3834        radio.mode = AttachMode::Administrative;
3835
3836        let error = radio
3837            .get_props(&[prop::HOST_KEY, prop::HOST_AUTO_ACK])
3838            .await
3839            .expect_err("the fake device does not implement CMD_PROP_MULTI_GET");
3840        assert!(
3841            matches!(error, UlcpError::Status(Status::UNIMPLEMENTED)),
3842            "a host-domain read must reach the device, got {error:?}"
3843        );
3844
3845        let error = radio
3846            .set_prop(prop::HOST_AUTO_ACK, &[1])
3847            .await
3848            .expect_err("a host-domain write needs a tethered attach");
3849        assert!(matches!(error, UlcpError::AdministrativeAttach));
3850    }
3851
3852    #[tokio::test]
3853    async fn save_and_clear_complete_on_status() {
3854        let mut radio = attached_radio().await;
3855        radio.save().await.unwrap();
3856        radio.clear().await.unwrap();
3857    }
3858
3859    #[tokio::test]
3860    async fn restore_update_form_reports_updated_and_retains_events() {
3861        let mut radio = attached_radio().await;
3862        assert_eq!(radio.restore().await.unwrap(), RestoreCompletion::Updated);
3863        assert_eq!(
3864            radio.pop_prop_event(),
3865            Some(PropEvent::Is {
3866                key: prop::PHY_FREQ,
3867                value: 905_000u32.to_le_bytes().to_vec(),
3868            })
3869        );
3870        assert_eq!(radio.pop_prop_event(), None);
3871    }
3872
3873    #[tokio::test]
3874    async fn restore_reset_form_is_success_not_unexpected_reset() {
3875        let mut radio = attached_radio().await;
3876        radio.set_prop(RESTORE_RESET_FORM_KEY, &[1]).await.unwrap();
3877        assert_eq!(radio.restore().await.unwrap(), RestoreCompletion::Reset);
3878
3879        // The consumed RESET_RESTORED must not resurface as an
3880        // unexpected reset on the next operation.
3881        radio.transmit(&[0x55], TxOptions::default()).await.unwrap();
3882        let mut buf = [0u8; 16];
3883        let info = core::future::poll_fn(|cx| radio.poll_receive(cx, &mut buf))
3884            .await
3885            .unwrap();
3886        assert_eq!(&buf[..info.len], &[0x55]);
3887    }
3888
3889    #[tokio::test]
3890    async fn unsolicited_table_notifications_are_retained_events() {
3891        let mut radio = attached_radio().await;
3892        let mut buf = [0u8; 48];
3893        let len = frame::prop_inserted(&mut buf, TID_UNSOLICITED, prop::HOST_RX_FILTERS, &[2, 0])
3894            .unwrap();
3895        radio.ingest_frame(&buf[..len]);
3896        let len = frame::prop_removed(
3897            &mut buf,
3898            TID_UNSOLICITED,
3899            prop::HOST_CHANNEL_KEYS,
3900            &[0x12, 0x34],
3901        )
3902        .unwrap();
3903        radio.ingest_frame(&buf[..len]);
3904
3905        assert_eq!(
3906            radio.pop_prop_event(),
3907            Some(PropEvent::Inserted {
3908                key: prop::HOST_RX_FILTERS,
3909                digest: vec![2, 0],
3910            })
3911        );
3912        assert_eq!(
3913            radio.pop_prop_event(),
3914            Some(PropEvent::Removed {
3915                key: prop::HOST_CHANNEL_KEYS,
3916                digest: vec![0x12, 0x34],
3917            })
3918        );
3919        assert_eq!(radio.pop_prop_event(), None);
3920    }
3921
3922    #[test]
3923    fn airtime_is_plausible() {
3924        // ~255-byte frame at SF11/BW250 is on the order of seconds.
3925        let airtime = lora_airtime_ms(11, 250_000, 5, 255);
3926        assert!((500..5_000).contains(&airtime), "airtime {airtime}");
3927        // Faster settings give shorter airtime.
3928        assert!(lora_airtime_ms(7, 250_000, 5, 255) < airtime);
3929    }
3930}