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