Skip to main content

hap_ble/
accessory.rs

1//! The public per-accessory handle: typed find/read/subscribe/events over an
2//! established session.
3
4use crate::broadcast_state::BleBroadcastState;
5use crate::db;
6use crate::error::{BleError, Result};
7use crate::gatt::{GattConnection, GattService};
8use crate::pairing;
9use crate::pdu::{self, OpCode};
10use crate::session::BleSession;
11use hap_crypto::{AccessoryPairing, ControllerKeypair};
12use hap_model::format::{CharFormat, CharValue};
13use hap_model::tree::Accessory;
14use hap_model::{CharacteristicType, ServiceType};
15use std::collections::HashMap;
16use std::sync::Arc;
17use tokio::sync::Mutex;
18use tokio_stream::StreamExt as _;
19
20/// Whether `new` is a newer GSN than `last` under HAP's u16 wraparound
21/// (RFC 1982 serial-number arithmetic): newer iff the forward distance is
22/// non-zero and within the first half of the range.
23fn gsn_is_newer(new: u16, last: u16) -> bool {
24    let diff = new.wrapping_sub(last);
25    diff != 0 && diff < 0x8000
26}
27
28/// Parse a colon-separated hex device id (`"59:fa:bc:61:09:d2"`), matching
29/// the HAP pairing-id format. `hap-ble` has no dependency on `hap-pairing`
30/// (dependencies flow strictly downward and no new ones are added here), so
31/// this mirrors `hap_pairing::parse_device_id`'s logic locally rather than
32/// pulling in the crate.
33fn parse_device_id(s: &str) -> Option<[u8; 6]> {
34    let mut out = [0u8; 6];
35    let mut parts = s.split(':');
36    for slot in &mut out {
37        let p = parts.next()?;
38        if p.len() != 2 {
39            return None;
40        }
41        *slot = u8::from_str_radix(p, 16).ok()?;
42    }
43    parts.next().is_none().then_some(out)
44}
45
46/// The maximum number of mid-operation re-verify retries before giving up — a
47/// backstop against a link that reconnects on every attempt.
48const MAX_REVIVE_RETRIES: u32 = 3;
49
50/// HAP-BLE Characteristic-Configuration body enabling encrypted broadcasts:
51/// Properties (TLV 0x01, u16 LE = 1) + Broadcast-Interval (TLV 0x02 = 1).
52const ENABLE_BROADCAST_BODY: [u8; 7] = [0x01, 0x02, 0x01, 0x00, 0x02, 0x01, 0x01];
53
54/// A characteristic value-change event.
55#[derive(Debug, Clone, PartialEq)]
56pub struct CharacteristicEvent {
57    /// Accessory instance id.
58    pub aid: u64,
59    /// Characteristic instance id.
60    pub iid: u64,
61    /// The decoded new value.
62    pub value: CharValue,
63}
64
65/// The encrypted-session state shared between foreground reads and the
66/// background event tasks (each event-triggered read also advances the session).
67struct Secure {
68    session: BleSession,
69    tid: u8,
70    /// The link generation at which `session` was established. When the
71    /// connection's generation advances past this (a reconnect), the accessory
72    /// has dropped the session and it must be re-minted via Pair Verify.
73    generation: u64,
74}
75
76/// Everything needed to re-establish a secure session (re-run Pair Verify) after
77/// a reconnect invalidates the accessory's session. Shared with event tasks.
78struct Reviver {
79    keypair: ControllerKeypair,
80    pairing: AccessoryPairing,
81    verify_char: String,
82    verify_iid: u16,
83    frag_size: usize,
84}
85
86/// The post-Pair-Verify material a [`BleAccessory`] needs: the live secure
87/// session and the addresses/keys to re-mint it (Pair Verify) or manage pairings
88/// (the Pairing-Pairings characteristic). Bundled so [`BleAccessory::new`] takes
89/// one descriptive value rather than a long positional argument list.
90pub(crate) struct SecureContext {
91    /// The session established by Pair Verify.
92    pub session: BleSession,
93    /// The link generation `session` was minted at (see [`Secure::generation`]).
94    pub session_generation: u64,
95    /// This controller's long-term identity (to re-run Pair Verify).
96    pub keypair: ControllerKeypair,
97    /// The accessory's pairing (to re-run Pair Verify).
98    pub pairing: AccessoryPairing,
99    /// The Pair-Verify characteristic UUID and instance id.
100    pub verify_char: String,
101    pub verify_iid: u16,
102    /// The Pairing-Pairings characteristic UUID and instance id (RemovePairing).
103    pub pairings_char: String,
104    pub pairings_iid: u16,
105    /// The broadcast decryption key derived during Pair Verify.
106    pub broadcast_key: hap_crypto::BroadcastKey,
107    /// Initial GSN to seed `last_gsn` from a previously-persisted state.
108    pub initial_gsn: u16,
109}
110
111/// If the link has reconnected since the secure session was minted, the
112/// accessory dropped that session — re-run Pair Verify and adopt the fresh keys
113/// (resetting the transaction counter). A no-op when the session is still live.
114async fn revive_if_stale(
115    gatt: &dyn GattConnection,
116    s: &mut Secure,
117    reviver: &Reviver,
118) -> Result<()> {
119    if gatt.generation().await <= s.generation {
120        return Ok(());
121    }
122    let (session, _bkey) = pairing::pair_verify(
123        gatt,
124        &reviver.verify_char,
125        reviver.verify_iid,
126        &reviver.keypair,
127        &reviver.pairing,
128        reviver.frag_size,
129    )
130    .await?;
131    s.session = session;
132    s.tid = 0;
133    // Capture the generation *after* the handshake: Pair Verify itself fails if
134    // the link drops mid-handshake, so reaching here means this is current.
135    s.generation = gatt.generation().await;
136    Ok(())
137}
138
139// kTLVType values for the Pairing-Pairings (Add/Remove/List) exchange.
140mod pairings_tlv {
141    pub(super) const STATE: u8 = 0x06;
142    pub(super) const METHOD: u8 = 0x00;
143    pub(super) const IDENTIFIER: u8 = 0x01;
144    pub(super) const ERROR: u8 = 0x07;
145    pub(super) const STATE_M1: u8 = 0x01;
146    pub(super) const STATE_M2: u8 = 0x02;
147    pub(super) const METHOD_REMOVE: u8 = 0x04;
148}
149
150/// Encode a RemovePairing request (State M1, Method 4, Identifier) as the TLV8
151/// carried in the Pairing-Pairings characteristic's Value param.
152fn encode_remove_pairing(controller_id: &str) -> Vec<u8> {
153    let mut out = Vec::new();
154    let mut w = hap_tlv8::Tlv8Writer::new(&mut out);
155    w.push_u8(pairings_tlv::STATE, pairings_tlv::STATE_M1);
156    w.push_u8(pairings_tlv::METHOD, pairings_tlv::METHOD_REMOVE);
157    w.push(pairings_tlv::IDENTIFIER, controller_id.as_bytes());
158    out
159}
160
161/// Validate a RemovePairing reply: reject a `kTLVType_Error`, then require the
162/// reply state to be M2.
163fn expect_remove_m2(tlv: &[u8]) -> Result<()> {
164    let map = hap_tlv8::Tlv8Map::parse(tlv)?;
165    if let Some(err) = map.get(pairings_tlv::ERROR) {
166        return Err(BleError::PairingRejected(err.first().copied().unwrap_or(1)));
167    }
168    match map
169        .get(pairings_tlv::STATE)
170        .and_then(|s| s.first().copied())
171    {
172        Some(pairings_tlv::STATE_M2) => Ok(()),
173        _ => Err(BleError::MalformedPdu("remove-pairing reply not state M2")),
174    }
175}
176
177/// Decide whether an event for `(iid, gsn)` should be emitted and record it in
178/// the dedup map. Exactly one emission per `(iid, gsn)`; the stored GSN is
179/// never downgraded — an out-of-order poll completion racing a newer broadcast
180/// must not clobber the broadcast's record (RFC 1982 serial order, matching
181/// [`gsn_is_newer`]).
182/// The exactly-once guarantee for a GSN older than the stored record relies on
183/// the callers' monotonic gating (`last_gsn`); the map alone does not suppress
184/// repeats of an older GSN.
185async fn dedup_should_emit(emitted: &Mutex<HashMap<u64, u16>>, iid: u64, gsn: u16) -> bool {
186    let mut e = emitted.lock().await;
187    let prev = e.get(&iid).copied();
188    if prev == Some(gsn) {
189        return false;
190    }
191    if prev.is_none_or(|p| gsn_is_newer(gsn, p)) {
192        e.insert(iid, gsn);
193    }
194    true
195}
196
197/// Issue one encrypted Characteristic-Read and return the raw value bytes,
198/// re-establishing the secure session if a reconnect invalidated it (before the
199/// read, and again if the link drops mid-read — retried a bounded number of
200/// times).
201async fn read_char_raw(
202    gatt: &dyn GattConnection,
203    secure: &Mutex<Secure>,
204    reviver: &Reviver,
205    uuid: &str,
206    iid: u64,
207    frag_size: usize,
208) -> Result<Vec<u8>> {
209    let iid16 = u16::try_from(iid).map_err(|_| BleError::CharacteristicNotFound { aid: 0, iid })?;
210    let mut s = secure.lock().await;
211    let mut attempts = 0;
212    loop {
213        revive_if_stale(gatt, &mut s, reviver).await?;
214        s.tid = s.tid.wrapping_add(1);
215        let tid = s.tid;
216        match pdu::request_secure(
217            gatt,
218            &mut s.session,
219            uuid,
220            OpCode::CharacteristicRead,
221            tid,
222            iid16,
223            &[],
224            frag_size,
225        )
226        .await
227        {
228            Ok(resp) => return pdu::value_param(&resp.body),
229            // A reconnect during the read kills the session mid-stream; if the
230            // generation advanced, re-verify and retry rather than surfacing the
231            // transient failure.
232            Err(e) => {
233                attempts += 1;
234                if attempts < MAX_REVIVE_RETRIES && gatt.generation().await > s.generation {
235                    continue;
236                }
237                return Err(e);
238            }
239        }
240    }
241}
242
243/// Issue one encrypted Characteristic-Write carrying `value_bytes`,
244/// re-establishing the secure session exactly as [`read_char_raw`] does.
245///
246/// # Errors
247/// [`BleError::CharacteristicNotFound`] if iid overflows u16; otherwise from
248/// [`pdu::request_secure`], crypto, or reconnection failures.
249async fn write_char_raw(
250    gatt: &dyn GattConnection,
251    secure: &Mutex<Secure>,
252    reviver: &Reviver,
253    uuid: &str,
254    iid: u64,
255    value_bytes: &[u8],
256    frag_size: usize,
257) -> Result<()> {
258    let iid16 = u16::try_from(iid).map_err(|_| BleError::CharacteristicNotFound { aid: 0, iid })?;
259    let body = pdu::encode_write_body(value_bytes);
260    let mut s = secure.lock().await;
261    let mut attempts = 0;
262    loop {
263        revive_if_stale(gatt, &mut s, reviver).await?;
264        s.tid = s.tid.wrapping_add(1);
265        let tid = s.tid;
266        match pdu::request_secure(
267            gatt,
268            &mut s.session,
269            uuid,
270            OpCode::CharacteristicWrite,
271            tid,
272            iid16,
273            &body,
274            frag_size,
275        )
276        .await
277        {
278            Ok(resp) if resp.status != 0 => return Err(BleError::RequestRejected(resp.status)),
279            Ok(_) => return Ok(()),
280            Err(e) => {
281                attempts += 1;
282                if attempts < MAX_REVIVE_RETRIES && gatt.generation().await > s.generation {
283                    continue;
284                }
285                return Err(e);
286            }
287        }
288    }
289}
290
291/// A connected BLE accessory: holds the GATT link, the secure session, the
292/// cached attribute database, and a map from (aid, iid) to GATT characteristic
293/// UUID for issuing PDUs.
294pub struct BleAccessory {
295    gatt: Arc<dyn GattConnection>,
296    secure: Arc<Mutex<Secure>>,
297    reviver: Arc<Reviver>,
298    /// The Pairing-Pairings characteristic (UUID, instance id) for RemovePairing.
299    pairings: (String, u16),
300    frag_size: usize,
301    accessories: Vec<Accessory>,
302    /// (aid, iid) -> characteristic UUID, format.
303    chars: HashMap<(u64, u64), (String, CharFormat)>,
304    events_tx: tokio::sync::broadcast::Sender<CharacteristicEvent>,
305    /// Background event-forwarding tasks, aborted when the handle is dropped.
306    tasks: Vec<tokio::task::JoinHandle<()>>,
307    /// The last GSN seen in a regular advertisement; shared with catch-up poll tasks.
308    last_gsn: Arc<Mutex<u16>>,
309    /// Dedup map of `iid → last-emitted GSN`; shared with catch-up poll tasks.
310    /// Bounded to one entry per characteristic (unlike a `HashSet` that grows forever).
311    emitted: Arc<Mutex<HashMap<u64, u16>>>,
312    /// The broadcast decryption key derived during the most recent Pair Verify.
313    broadcast_key: hap_crypto::BroadcastKey,
314    /// The advert source (the connection itself), set by the controller after
315    /// connect so `watch_sleepy_events` can self-source it. `None` on handles
316    /// built without one.
317    advert_source: Option<Arc<dyn crate::gatt::AdvertSource>>,
318}
319
320impl Drop for BleAccessory {
321    fn drop(&mut self) {
322        for task in &self.tasks {
323            task.abort();
324        }
325    }
326}
327
328impl BleAccessory {
329    /// Wrap an established GATT link + session with a pre-built attribute
330    /// database (fetched unencrypted before Pair Verify). Builds the
331    /// `(aid, iid) -> (uuid, format)` map used to address characteristics.
332    ///
333    /// `ctx` carries the established secure session plus the material to re-mint
334    /// it (Pair Verify) after a reconnect and to manage pairings.
335    pub(crate) fn new(
336        gatt: Arc<dyn GattConnection>,
337        ctx: SecureContext,
338        frag_size: usize,
339        gatt_services: &[GattService],
340        accessories: Vec<Accessory>,
341    ) -> Self {
342        let (events_tx, _) = tokio::sync::broadcast::channel(64);
343        // `accessories` models a single accessory (aid 1 — BLE accessories are
344        // not bridges in this milestone), so characteristic iids are unique and
345        // a plain iid->uuid map is sufficient.
346        let mut uuid_by_iid: HashMap<u64, String> = HashMap::new();
347        for gs in gatt_services {
348            for gc in &gs.characteristics {
349                uuid_by_iid.insert(u64::from(gc.iid), gc.uuid.clone());
350            }
351        }
352        let mut chars = HashMap::new();
353        for acc in &accessories {
354            for svc in &acc.services {
355                for ch in &svc.characteristics {
356                    if let Some(uuid) = uuid_by_iid.get(&ch.iid) {
357                        chars.insert((acc.aid, ch.iid), (uuid.clone(), ch.format));
358                    }
359                }
360            }
361        }
362        Self {
363            gatt,
364            secure: Arc::new(Mutex::new(Secure {
365                session: ctx.session,
366                tid: 0,
367                generation: ctx.session_generation,
368            })),
369            reviver: Arc::new(Reviver {
370                keypair: ctx.keypair,
371                pairing: ctx.pairing,
372                verify_char: ctx.verify_char,
373                verify_iid: ctx.verify_iid,
374                frag_size,
375            }),
376            pairings: (ctx.pairings_char, ctx.pairings_iid),
377            frag_size,
378            accessories,
379            chars,
380            events_tx,
381            tasks: Vec::new(),
382            last_gsn: Arc::new(Mutex::new(ctx.initial_gsn)),
383            emitted: Arc::new(Mutex::new(HashMap::new())),
384            broadcast_key: ctx.broadcast_key,
385            advert_source: None,
386        }
387    }
388
389    /// The cached attribute database.
390    pub fn accessories(&self) -> &[Accessory] {
391        &self.accessories
392    }
393
394    /// The current persistable broadcast material (key + latest GSN). Persist
395    /// this so a later `connect` can resume broadcast decryption.
396    pub async fn broadcast_state(&self) -> BleBroadcastState {
397        BleBroadcastState {
398            key: self.broadcast_key.clone(),
399            gsn: *self.last_gsn.lock().await,
400        }
401    }
402
403    /// Find the `(aid, iid)` of a characteristic by service + characteristic
404    /// type.
405    ///
406    /// # Errors
407    /// [`BleError::CharacteristicNotFound`] if no match exists.
408    // Take the type enums by value for caller ergonomics and to match the IP
409    // `hap-controller::find` signature (the two unify in Milestone B).
410    #[allow(clippy::needless_pass_by_value)]
411    pub fn find(&self, svc: ServiceType, chr: CharacteristicType) -> Result<(u64, u64)> {
412        for acc in &self.accessories {
413            for service in &acc.services {
414                if service.service_type == svc {
415                    for ch in &service.characteristics {
416                        if ch.char_type == chr {
417                            return Ok((acc.aid, ch.iid));
418                        }
419                    }
420                }
421            }
422        }
423        Err(BleError::CharacteristicNotFound { aid: 0, iid: 0 })
424    }
425
426    /// Read a characteristic value, decoded to its declared format.
427    ///
428    /// # Errors
429    /// [`BleError::CharacteristicNotFound`] if unknown; otherwise GATT/PDU/crypto.
430    pub async fn read(&mut self, aid: u64, iid: u64) -> Result<CharValue> {
431        let (uuid, format) = self
432            .chars
433            .get(&(aid, iid))
434            .cloned()
435            .ok_or(BleError::CharacteristicNotFound { aid, iid })?;
436        let raw = read_char_raw(
437            self.gatt.as_ref(),
438            &self.secure,
439            &self.reviver,
440            &uuid,
441            iid,
442            self.frag_size,
443        )
444        .await?;
445        db::decode_value(format, &raw)
446    }
447
448    /// Remove a pairing by controller pairing id. Pass this controller's own id
449    /// to un-pair this controller; pass another controller's id (this session
450    /// must hold admin permission) to remove that one.
451    ///
452    /// Runs as an encrypted RemovePairing (State M1, Method 4) write to the
453    /// accessory's Pairing-Pairings characteristic; a reconnect-invalidated
454    /// session is re-verified first.
455    ///
456    /// Removing this controller's **own** pairing is a special case: the
457    /// accessory removes the pairing and tears down the secure session as part
458    /// of the same operation, so the encrypted M2 response is frequently lost or
459    /// undecryptable (the link drops, or the reply is no longer sealed under the
460    /// now-defunct session). Per the HAP self-removal semantics, once the request
461    /// has been written the removal has taken effect, so a transport/crypto
462    /// failure *reading the response* on self-removal is reported as success.
463    ///
464    /// # Errors
465    /// [`BleError::PairingRejected`] if the accessory rejects the request (PDU
466    /// status or a `kTLVType_Error` in the M2 reply); otherwise GATT/PDU/crypto
467    /// errors (except the tolerated self-removal teardown described above).
468    pub async fn remove_pairing(&mut self, controller_id: &str) -> Result<()> {
469        let (uuid, iid) = self.pairings.clone();
470        let removing_self = controller_id == self.reviver.keypair.id;
471        let tlv = encode_remove_pairing(controller_id);
472        let body = pdu::encode_write_body(&tlv);
473        let mut s = self.secure.lock().await;
474        revive_if_stale(self.gatt.as_ref(), &mut s, &self.reviver).await?;
475        s.tid = s.tid.wrapping_add(1);
476        let tid = s.tid;
477        let result = pdu::request_secure(
478            self.gatt.as_ref(),
479            &mut s.session,
480            &uuid,
481            OpCode::CharacteristicWrite,
482            tid,
483            iid,
484            &body,
485            self.frag_size,
486        )
487        .await;
488        match result {
489            Ok(resp) if resp.status != 0 => Err(BleError::PairingRejected(resp.status)),
490            Ok(resp) => expect_remove_m2(&pdu::value_param(&resp.body)?),
491            // The request was written, but reading the sealed M2 back failed.
492            // On self-removal that is the expected session teardown — the
493            // pairing is gone — so swallow the teardown-shaped error.
494            Err(BleError::Disconnected | BleError::Crypto(_)) if removing_self => Ok(()),
495            Err(e) => Err(e),
496        }
497    }
498
499    /// Write a characteristic value, encoded per its declared format, over the
500    /// encrypted session (re-verified after a reconnect, like [`Self::read`]).
501    ///
502    /// # Errors
503    /// [`BleError::CharacteristicNotFound`] if unknown;
504    /// [`BleError::RequestRejected`] if the accessory returns a non-zero PDU
505    /// status; [`BleError::MalformedPdu`] if `value` does not match the
506    /// characteristic's format; otherwise GATT/crypto errors.
507    pub async fn write(&mut self, aid: u64, iid: u64, value: CharValue) -> Result<()> {
508        let (uuid, format) = self
509            .chars
510            .get(&(aid, iid))
511            .cloned()
512            .ok_or(BleError::CharacteristicNotFound { aid, iid })?;
513        let bytes = db::encode_value(format, &value)?;
514        write_char_raw(
515            self.gatt.as_ref(),
516            &self.secure,
517            &self.reviver,
518            &uuid,
519            iid,
520            &bytes,
521            self.frag_size,
522        )
523        .await
524    }
525
526    /// The accessory's HAP pairing id (as stored in the pairing record).
527    #[must_use]
528    pub fn pairing_id(&self) -> &str {
529        &self.reviver.pairing.pairing_id
530    }
531
532    /// Enable encrypted broadcast notifications for the given characteristic
533    /// instance ids (the HAP BLE accessory id is always 1). Each is an encrypted
534    /// Characteristic-Configuration write (Properties + Broadcast-Interval). Call
535    /// this **while connected**, before disconnecting to receive sleepy events —
536    /// without it the accessory will not emit `0x11` encrypted broadcasts. A
537    /// characteristic that does not support broadcasts is skipped; per-write
538    /// failures are tolerated (best-effort).
539    ///
540    /// # Errors
541    /// Propagates a session re-verify failure.
542    pub async fn enable_broadcasts(&mut self, iids: &[u64]) -> Result<()> {
543        let mut s = self.secure.lock().await;
544        for &iid in iids {
545            let Some((uuid, _)) = self.chars.get(&(1, iid)).cloned() else {
546                continue;
547            };
548            let Ok(iid16) = u16::try_from(iid) else {
549                continue;
550            };
551            revive_if_stale(self.gatt.as_ref(), &mut s, &self.reviver).await?;
552            s.tid = s.tid.wrapping_add(1);
553            let tid = s.tid;
554            let _ = pdu::request_secure(
555                self.gatt.as_ref(),
556                &mut s.session,
557                &uuid,
558                OpCode::CharacteristicConfig,
559                tid,
560                iid16,
561                &ENABLE_BROADCAST_BODY,
562                self.frag_size,
563            )
564            .await;
565        }
566        Ok(())
567    }
568
569    /// Release the underlying link (so the sleepy accessory advertises again).
570    /// A no-op on backends without a live link.
571    pub async fn disconnect(&self) {
572        self.gatt.disconnect().await;
573    }
574
575    /// Subscribe to value-change events for a characteristic. HAP-BLE connected
576    /// events use the GATT notification only as a **trigger**: when it fires we
577    /// issue an encrypted Characteristic-Read for the new value and publish it
578    /// on [`BleAccessory::events`].
579    ///
580    /// # Errors
581    /// [`BleError::CharacteristicNotFound`] if unknown; otherwise GATT errors.
582    ///
583    /// Connected events are best-effort: if the link drops, this GATT
584    /// subscription ends and is not re-armed (re-arming a sleepy device storms).
585    /// Durable updates arrive via [`BleAccessory::events`] from the broadcast and
586    /// disconnected-event channels.
587    pub async fn subscribe(&mut self, aid: u64, iid: u64) -> Result<()> {
588        let (uuid, format) = self
589            .chars
590            .get(&(aid, iid))
591            .cloned()
592            .ok_or(BleError::CharacteristicNotFound { aid, iid })?;
593        let mut rx = self.gatt.subscribe(&uuid).await?;
594        let tx = self.events_tx.clone();
595        let gatt = self.gatt.clone();
596        let secure = self.secure.clone();
597        let reviver = self.reviver.clone();
598        let frag_size = self.frag_size;
599        let task = tokio::spawn(async move {
600            // The notification carries no value; it signals "read me".
601            while rx.recv().await.is_some() {
602                if let Ok(raw) =
603                    read_char_raw(gatt.as_ref(), &secure, &reviver, &uuid, iid, frag_size).await
604                {
605                    if let Ok(value) = db::decode_value(format, &raw) {
606                        let _ = tx.send(CharacteristicEvent { aid, iid, value });
607                    }
608                }
609            }
610        });
611        self.tasks.push(task);
612        Ok(())
613    }
614
615    /// Watch advertisements and deliver disconnected-event updates. Two paths:
616    ///
617    /// - **Regular (0x06) advertisements:** when the accessory's GSN bumps, read
618    ///   each polled characteristic and publish its value on
619    ///   [`BleAccessory::events`]. Events are deduplicated by `(iid, gsn)`.
620    ///   The poll runs on its own task fed by a watch channel, so bumps coalesce.
621    /// - **Encrypted broadcast (0x11) advertisements:** decrypt the value directly
622    ///   from the advertisement using the stored [`hap_crypto::BroadcastKey`] and
623    ///   publish it — no GATT connection needed.
624    ///
625    /// The advert source is supplied by the caller (the same backend object that
626    /// provides the GATT connection).
627    ///
628    /// # Errors
629    /// [`BleError`] if the advert source cannot start.
630    #[allow(clippy::too_many_lines)]
631    pub async fn watch_sleepy_events_with_source(
632        &mut self,
633        advert_source: Arc<dyn crate::gatt::AdvertSource>,
634        device_id: [u8; 6],
635        poll_iids: Vec<(u64, u64)>,
636    ) -> Result<()> {
637        // Pre-resolve poll targets to (aid, iid, uuid, format) so the task needs no
638        // access to self.chars.
639        let mut targets = Vec::new();
640        for (aid, iid) in poll_iids {
641            if let Some((uuid, format)) = self.chars.get(&(aid, iid)).cloned() {
642                targets.push((aid, iid, uuid, format));
643            }
644        }
645        // Build an iid→format map for the 0x11 broadcast-decrypt path.
646        let formats: std::collections::HashMap<u64, CharFormat> = self
647            .chars
648            .iter()
649            .map(|((_, iid), (_, f))| (*iid, *f))
650            .collect();
651        let broadcast_key = self.broadcast_key.clone();
652
653        let mut adverts = advert_source.watch_adverts().await?;
654
655        // The catch-up poll runs on its own task, fed the latest bumped GSN
656        // through a watch channel: the advert loop must never block on GATT
657        // I/O (a reconnect-read pauses the advert scan and can take seconds),
658        // and a burst of bumps coalesces into one poll of the latest GSN.
659        let (poll_tx, mut poll_rx) = tokio::sync::watch::channel(0u16);
660        if !targets.is_empty() {
661            let gatt = self.gatt.clone();
662            let secure = self.secure.clone();
663            let reviver = self.reviver.clone();
664            let frag = self.frag_size;
665            let poll_events = self.events_tx.clone();
666            let poll_emitted = self.emitted.clone();
667            let poll_task = tokio::spawn(async move {
668                while poll_rx.changed().await.is_ok() {
669                    let gsn = *poll_rx.borrow_and_update();
670                    for (aid, iid, uuid, format) in &targets {
671                        if let Ok(raw_val) =
672                            read_char_raw(gatt.as_ref(), &secure, &reviver, uuid, *iid, frag).await
673                        {
674                            if let Ok(value) = db::decode_value(*format, &raw_val) {
675                                if dedup_should_emit(&poll_emitted, *iid, gsn).await {
676                                    let _ = poll_events.send(CharacteristicEvent {
677                                        aid: *aid,
678                                        iid: *iid,
679                                        value,
680                                    });
681                                }
682                            }
683                        }
684                    }
685                }
686            });
687            self.tasks.push(poll_task);
688        }
689
690        let tx = self.events_tx.clone();
691        let last_gsn = self.last_gsn.clone();
692        let emitted = self.emitted.clone();
693        let advert_task = tokio::spawn(async move {
694            while let Some(raw) = adverts.recv().await {
695                match crate::advert::HapAdvert::parse(&raw.manufacturer_data) {
696                    Some(crate::advert::HapAdvert::Regular {
697                        device_id: d, gsn, ..
698                    }) => {
699                        if d != device_id {
700                            continue;
701                        }
702                        {
703                            let mut lg = last_gsn.lock().await;
704                            if !gsn_is_newer(gsn, *lg) {
705                                continue;
706                            }
707                            *lg = gsn;
708                        }
709                        // Hand the bump to the poll task. A dropped receiver
710                        // (no poll targets) is fine.
711                        let _ = poll_tx.send(gsn);
712                    }
713                    Some(crate::advert::HapAdvert::EncryptedNotification {
714                        advertising_id,
715                        payload,
716                    }) => {
717                        if advertising_id != device_id {
718                            continue;
719                        }
720                        let start = *last_gsn.lock().await;
721                        // GSN candidates per aiohomekit: next, current, then a
722                        // forward window up to +100.
723                        let candidates = std::iter::once(start.wrapping_add(1))
724                            .chain(std::iter::once(start))
725                            .chain((2..=100u16).map(|d| start.wrapping_add(d)));
726                        for gsn in candidates {
727                            let Ok(pt) = broadcast_key.open(gsn, &payload, &advertising_id) else {
728                                continue;
729                            };
730                            if pt.len() < 12 {
731                                continue;
732                            }
733                            if u16::from_le_bytes([pt[0], pt[1]]) != gsn {
734                                continue;
735                            }
736                            // stale duplicate: not newer than start — ignore.
737                            if !gsn_is_newer(gsn, start) {
738                                break;
739                            }
740                            let iid = u64::from(u16::from_le_bytes([pt[2], pt[3]]));
741                            // Advance last_gsn now — the device's state
742                            // genuinely moved, even if the iid is unknown or
743                            // the value fails to decode.
744                            {
745                                let mut lg = last_gsn.lock().await;
746                                *lg = gsn;
747                            }
748                            let Some(format) = formats.get(&iid).copied() else {
749                                break;
750                            };
751                            if let Ok(value) = db::decode_value(format, &pt[4..12]) {
752                                if dedup_should_emit(&emitted, iid, gsn).await {
753                                    let _ = tx.send(CharacteristicEvent { aid: 1, iid, value });
754                                }
755                            }
756                            break;
757                        }
758                    }
759                    _ => {}
760                }
761            }
762        });
763        self.tasks.push(advert_task);
764        Ok(())
765    }
766
767    /// Provide the advert source (the connection) so the self-sourcing
768    /// [`watch_sleepy_events`](Self::watch_sleepy_events) can use it.
769    pub fn set_advert_source(&mut self, src: Arc<dyn crate::gatt::AdvertSource>) {
770        self.advert_source = Some(src);
771    }
772
773    /// Watch for sleepy-device events, self-sourcing the advert source (set via
774    /// [`set_advert_source`](Self::set_advert_source)) and the device id (from
775    /// the pairing id). Arms the same machinery as
776    /// [`watch_sleepy_events_with_source`](Self::watch_sleepy_events_with_source).
777    ///
778    /// # Errors
779    /// [`BleError::NoAdvertSource`] if no source was set; [`BleError::Backend`]
780    /// if the stored pairing id cannot be parsed as a device id; otherwise
781    /// advert/GATT errors.
782    pub async fn watch_sleepy_events(&mut self, poll_iids: Vec<(u64, u64)>) -> Result<()> {
783        let src = self.advert_source.clone().ok_or(BleError::NoAdvertSource)?;
784        let device_id =
785            parse_device_id(self.reviver.pairing.pairing_id.as_str()).ok_or_else(|| {
786                BleError::Backend("malformed pairing id; cannot derive device id".into())
787            })?;
788        self.watch_sleepy_events_with_source(src, device_id, poll_iids)
789            .await
790    }
791
792    /// An async stream of characteristic events. Each call returns a fresh
793    /// subscriber to the shared event channel.
794    pub fn events(&self) -> impl tokio_stream::Stream<Item = CharacteristicEvent> {
795        tokio_stream::wrappers::BroadcastStream::new(self.events_tx.subscribe())
796            .filter_map(std::result::Result::ok)
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::test_support::ble_accessory_with_db;
804
805    #[tokio::test]
806    #[allow(clippy::unwrap_used)]
807    async fn find_locates_characteristic() {
808        let (h, _g) = ble_accessory_with_db().await;
809        let (aid, iid) = h
810            .find(ServiceType::LightBulb, CharacteristicType::On)
811            .unwrap();
812        assert_eq!((aid, iid), (1, 11));
813    }
814
815    #[tokio::test]
816    #[allow(clippy::unwrap_used)]
817    async fn find_missing_errors() {
818        let (h, _g) = ble_accessory_with_db().await;
819        let err = h
820            .find(ServiceType::LightBulb, CharacteristicType::Brightness)
821            .unwrap_err();
822        assert!(matches!(err, BleError::CharacteristicNotFound { .. }));
823    }
824
825    #[test]
826    fn encode_remove_pairing_matches_hap_layout() {
827        // State M1, Method RemovePairing(4), Identifier "c2".
828        let tlv = encode_remove_pairing("c2");
829        assert_eq!(
830            tlv,
831            vec![0x06, 0x01, 0x01, 0x00, 0x01, 0x04, 0x01, 0x02, b'c', b'2']
832        );
833    }
834
835    #[test]
836    fn expect_remove_m2_accepts_m2_and_rejects_error() {
837        assert!(expect_remove_m2(&[0x06, 0x01, 0x02]).is_ok());
838        // A kTLVType_Error (0x07) is surfaced as a rejection with its code.
839        assert!(matches!(
840            expect_remove_m2(&[0x07, 0x01, 0x02]),
841            Err(BleError::PairingRejected(2))
842        ));
843        // Anything that is not state M2 is malformed.
844        assert!(matches!(
845            expect_remove_m2(&[0x06, 0x01, 0x01]),
846            Err(BleError::MalformedPdu(_))
847        ));
848    }
849
850    #[tokio::test]
851    #[allow(clippy::unwrap_used)]
852    async fn remove_pairing_writes_request_and_accepts_m2() {
853        let (mut h, gatt) = ble_accessory_with_db().await;
854
855        // The accessory replies to the encrypted RemovePairing write with a
856        // sealed success PDU whose value param is a State-M2 TLV8.
857        let m2 = vec![0x06, 0x01, 0x02];
858        let vbody = crate::pdu::encode_value_param(&m2);
859        let mut plain = vec![0x02, 0x01, 0x00];
860        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
861        plain.extend_from_slice(&vbody);
862        let sealed =
863            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
864        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", sealed);
865
866        h.remove_pairing("AE:EC:86:C0:BF:D7").await.unwrap();
867    }
868
869    #[tokio::test]
870    #[allow(clippy::unwrap_used)]
871    async fn remove_own_pairing_tolerates_session_teardown() {
872        // ble_accessory_with_db pairs as controller id "test-controller".
873        let (mut h, gatt) = ble_accessory_with_db().await;
874        // The accessory tears down the session as it removes us, so the reply is
875        // not validly sealed — open() fails with a crypto error. Removing our OWN
876        // id must still succeed (the removal took effect on write).
877        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", vec![0u8; 24]);
878        h.remove_pairing("test-controller").await.unwrap();
879    }
880
881    #[tokio::test]
882    #[allow(clippy::unwrap_used)]
883    async fn remove_other_pairing_propagates_teardown_error() {
884        // The same undecryptable reply when removing a DIFFERENT controller must
885        // NOT be swallowed — only self-removal tolerates a teardown.
886        let (mut h, gatt) = ble_accessory_with_db().await;
887        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", vec![0u8; 24]);
888        let err = h.remove_pairing("some-other-controller").await.unwrap_err();
889        assert!(matches!(err, BleError::Crypto(_)));
890    }
891
892    #[tokio::test]
893    #[allow(clippy::unwrap_used)]
894    async fn subscribe_then_event_decodes_value() {
895        use tokio_stream::StreamExt as _;
896        let (mut h, gatt) = ble_accessory_with_db().await;
897
898        // A HAP-BLE connected event is a bare notification (trigger) followed by
899        // an encrypted Characteristic-Read. Queue the sealed read response the
900        // accessory would return (zero session keys, recv counter 0).
901        let mut plain = vec![0x02, 0x01, 0x00];
902        let vbody = crate::pdu::encode_value_param(&[0x01]); // Bool true
903        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
904        plain.extend_from_slice(&vbody);
905        let sealed =
906            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
907        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
908
909        h.subscribe(1, 11).await.unwrap();
910        let mut events = h.events();
911
912        // Push the (empty) notification trigger.
913        gatt.notifier("00000025-0000-1000-8000-0026bb765291")
914            .unwrap()
915            .send(Vec::new())
916            .await
917            .unwrap();
918
919        let ev = events.next().await.unwrap();
920        assert_eq!(ev.iid, 11);
921        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
922    }
923
924    #[tokio::test]
925    #[allow(clippy::unwrap_used)]
926    async fn gsn_bump_triggers_disconnected_event_read() {
927        use tokio_stream::StreamExt as _;
928        let (mut h, gatt) = ble_accessory_with_db().await;
929
930        // The catch-up poll will issue an encrypted read for iid 11; queue the sealed
931        // response (zero session keys, recv counter 0) decoding to Bool(true).
932        let mut plain = vec![0x02, 0x01, 0x00];
933        let vbody = crate::pdu::encode_value_param(&[0x01]);
934        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
935        plain.extend_from_slice(&vbody);
936        let sealed =
937            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
938        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
939
940        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
941        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
942            .await
943            .unwrap();
944        let mut events = h.events();
945
946        // Push a 0x06 advert for device [1..6] with GSN 9 (a bump from 0).
947        gatt.advert_sender()
948            .send(crate::gatt::RawAdvert {
949                manufacturer_data: vec![
950                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
951                ],
952            })
953            .await
954            .unwrap();
955
956        let ev = events.next().await.unwrap();
957        assert_eq!(ev.iid, 11);
958        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
959    }
960
961    #[tokio::test]
962    #[allow(clippy::unwrap_used)]
963    async fn encrypted_broadcast_0x11_decrypts_and_emits_event() {
964        use tokio_stream::StreamExt as _;
965        // ble_accessory_with_db sets broadcast_key = BroadcastKey::from_bytes([0u8; 32]).
966        let (mut h, gatt) = ble_accessory_with_db().await;
967
968        // Seal a 12-byte broadcast plaintext: gsn=1, iid=11 (LightBulb On, Bool),
969        // value bytes = [0x01, 0, 0, 0, 0, 0, 0, 0] (Bool true).
970        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
971        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
972        let mut pt = Vec::new();
973        pt.extend_from_slice(&1u16.to_le_bytes()); // gsn = 1
974        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
975        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // value: Bool true
976        let sealed = key.seal(1, &pt, &aid_bytes);
977
978        // Build a 0x11 manufacturer-data frame: [0x11, 0x00, aid[0..6], sealed...]
979        let mut mfg = vec![0x11u8, 0x00];
980        mfg.extend_from_slice(&aid_bytes);
981        mfg.extend_from_slice(&sealed);
982
983        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
984        // poll_iids is empty — broadcast path needs no poll targets.
985        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
986            .await
987            .unwrap();
988        let mut events = h.events();
989
990        gatt.advert_sender()
991            .send(crate::gatt::RawAdvert {
992                manufacturer_data: mfg,
993            })
994            .await
995            .unwrap();
996
997        let ev = events.next().await.unwrap();
998        assert_eq!(ev.aid, 1);
999        assert_eq!(ev.iid, 11);
1000        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1001    }
1002
1003    #[test]
1004    fn gsn_is_newer_handles_wraparound() {
1005        assert!(gsn_is_newer(6, 5));
1006        assert!(!gsn_is_newer(5, 5));
1007        assert!(!gsn_is_newer(4, 5));
1008        assert!(gsn_is_newer(1, 65535)); // wrap 65535 -> 1
1009        assert!(!gsn_is_newer(65535, 1)); // not newer across the wrap
1010    }
1011
1012    #[tokio::test]
1013    #[allow(clippy::unwrap_used)]
1014    async fn same_change_via_poll_and_broadcast_emits_once() {
1015        use tokio_stream::StreamExt as _;
1016        let (mut h, gatt) = ble_accessory_with_db().await;
1017
1018        // Queue the sealed Characteristic-Read response the poll will issue for
1019        // iid 11 (same setup as gsn_bump_triggers_disconnected_event_read).
1020        let mut plain = vec![0x02, 0x01, 0x00];
1021        let vbody = crate::pdu::encode_value_param(&[0x01]);
1022        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1023        plain.extend_from_slice(&vbody);
1024        let sealed =
1025            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1026        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1027
1028        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1029        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1030            .await
1031            .unwrap();
1032        let mut events = h.events();
1033
1034        // Send 0x06 advert first (GSN 9) — triggers the poll → reads iid 11 → emits event.
1035        gatt.advert_sender()
1036            .send(crate::gatt::RawAdvert {
1037                manufacturer_data: vec![
1038                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1039                ],
1040            })
1041            .await
1042            .unwrap();
1043
1044        // Wait for the poll-triggered event.
1045        let ev = events.next().await.unwrap();
1046        assert_eq!(ev.iid, 11);
1047        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1048
1049        // Now send a 0x11 broadcast for the same GSN 9 / iid 11 — must be deduped.
1050        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1051        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1052        let mut pt = Vec::new();
1053        pt.extend_from_slice(&9u16.to_le_bytes()); // gsn = 9
1054        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1055        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // value: Bool true
1056        let sealed_bc = key.seal(9, &pt, &aid_bytes);
1057
1058        let mut mfg = vec![0x11u8, 0x00];
1059        mfg.extend_from_slice(&aid_bytes);
1060        mfg.extend_from_slice(&sealed_bc);
1061
1062        gatt.advert_sender()
1063            .send(crate::gatt::RawAdvert {
1064                manufacturer_data: mfg,
1065            })
1066            .await
1067            .unwrap();
1068
1069        // The second event (same iid=11, gsn=9) must be deduped — no second emit.
1070        let timeout_result =
1071            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1072        assert!(
1073            timeout_result.is_err(),
1074            "expected dedup to suppress the duplicate 0x11 broadcast event, but got one"
1075        );
1076    }
1077
1078    /// The advert loop must not block on the catch-up poll's GATT read: while
1079    /// a poll read is stalled (e.g. a scan-pausing reconnect), a 0x11
1080    /// broadcast must still decrypt and emit. Regression test for running poll
1081    /// reads off the advert task.
1082    #[tokio::test]
1083    #[allow(clippy::unwrap_used)]
1084    async fn broadcast_delivered_while_poll_read_blocked() {
1085        use tokio_stream::StreamExt as _;
1086        let (mut h, gatt) = ble_accessory_with_db().await;
1087
1088        // Stall the poll's encrypted read of iid 11 until released; queue the
1089        // sealed Bool(true) response it eventually returns.
1090        let release = gatt.block_next_read("00000025-0000-1000-8000-0026bb765291");
1091        let mut plain = vec![0x02, 0x01, 0x00];
1092        let vbody = crate::pdu::encode_value_param(&[0x01]);
1093        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1094        plain.extend_from_slice(&vbody);
1095        let sealed =
1096            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1097        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1098
1099        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1100        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1101            .await
1102            .unwrap();
1103        let mut events = h.events();
1104
1105        // 0x06 bump to GSN 9 — the poll starts its read and stalls on the gate.
1106        gatt.advert_sender()
1107            .send(crate::gatt::RawAdvert {
1108                manufacturer_data: vec![
1109                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1110                ],
1111            })
1112            .await
1113            .unwrap();
1114
1115        // A 0x11 broadcast for GSN 10 carrying Bool(false) — the advert loop
1116        // must process it while the poll read is still stalled.
1117        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1118        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1119        let mut pt = Vec::new();
1120        pt.extend_from_slice(&10u16.to_le_bytes()); // gsn = 10
1121        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1122        pt.extend_from_slice(&[0x00, 0, 0, 0, 0, 0, 0, 0]); // Bool false
1123        let sealed_bc = key.seal(10, &pt, &aid_bytes);
1124        let mut mfg = vec![0x11u8, 0x00];
1125        mfg.extend_from_slice(&aid_bytes);
1126        mfg.extend_from_slice(&sealed_bc);
1127        gatt.advert_sender()
1128            .send(crate::gatt::RawAdvert {
1129                manufacturer_data: mfg,
1130            })
1131            .await
1132            .unwrap();
1133
1134        // The broadcast event (Bool(false)) must arrive FIRST: the poll read is
1135        // still blocked. With the old inline poll this times out because the
1136        // advert loop is stuck inside the read.
1137        let ev = tokio::time::timeout(std::time::Duration::from_secs(2), events.next())
1138            .await
1139            .unwrap()
1140            .unwrap();
1141        assert_eq!(ev.iid, 11);
1142        assert_eq!(ev.value, hap_model::format::CharValue::Bool(false));
1143
1144        // Release the stalled read; the poll's event (GSN 9, Bool(true)) follows.
1145        release.notify_one();
1146        let ev2 = tokio::time::timeout(std::time::Duration::from_secs(2), events.next())
1147            .await
1148            .unwrap()
1149            .unwrap();
1150        assert_eq!(ev2.iid, 11);
1151        assert_eq!(ev2.value, hap_model::format::CharValue::Bool(true));
1152    }
1153
1154    // ── negative-path tests for sleepy-device event handling ─────────────────
1155
1156    /// A 0x06 advert from a foreign device id must be silently dropped — no
1157    /// event emitted, no panic.
1158    #[tokio::test]
1159    #[allow(clippy::unwrap_used)]
1160    async fn foreign_device_advert_ignored() {
1161        use tokio_stream::StreamExt as _;
1162        let (mut h, gatt) = ble_accessory_with_db().await;
1163
1164        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1165        // watch_sleepy_events expects device_id [1,2,3,4,5,6]
1166        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1167            .await
1168            .unwrap();
1169        let mut events = h.events();
1170
1171        // Send a 0x06 advert whose device_id is [9,9,9,9,9,9] — a foreign device.
1172        gatt.advert_sender()
1173            .send(crate::gatt::RawAdvert {
1174                manufacturer_data: vec![
1175                    0x06, 0x21, 0x01, 9, 9, 9, 9, 9, 9, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1176                ],
1177            })
1178            .await
1179            .unwrap();
1180
1181        let timeout_result =
1182            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1183        assert!(
1184            timeout_result.is_err(),
1185            "foreign device advert must not emit an event, but one was received"
1186        );
1187    }
1188
1189    /// A 0x11 broadcast replayed at the same GSN that was already processed must
1190    /// be silently dropped — stale-GSN dedup.
1191    #[tokio::test]
1192    #[allow(clippy::unwrap_used)]
1193    async fn stale_gsn_broadcast_ignored() {
1194        use tokio_stream::StreamExt as _;
1195        let (mut h, gatt) = ble_accessory_with_db().await;
1196
1197        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1198        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1199
1200        // Plaintext: gsn=5, iid=11, value=Bool(true)
1201        // Layout: [gsn_le: 2B][iid_le: 2B][value: 8B]
1202        let mut pt = Vec::new();
1203        pt.extend_from_slice(&5u16.to_le_bytes()); // gsn = 5
1204        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1205        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // Bool true
1206        let sealed = key.seal(5, &pt, &aid_bytes);
1207
1208        let mut mfg = vec![0x11u8, 0x00];
1209        mfg.extend_from_slice(&aid_bytes);
1210        mfg.extend_from_slice(&sealed);
1211
1212        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1213        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1214            .await
1215            .unwrap();
1216        let mut events = h.events();
1217
1218        // First delivery — GSN 5 is fresh (last_gsn starts at 0).
1219        gatt.advert_sender()
1220            .send(crate::gatt::RawAdvert {
1221                manufacturer_data: mfg.clone(),
1222            })
1223            .await
1224            .unwrap();
1225
1226        let ev = events.next().await.unwrap();
1227        assert_eq!(ev.iid, 11);
1228        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1229
1230        // Second delivery — identical GSN 5 is now stale.
1231        gatt.advert_sender()
1232            .send(crate::gatt::RawAdvert {
1233                manufacturer_data: mfg,
1234            })
1235            .await
1236            .unwrap();
1237
1238        let timeout_result =
1239            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1240        assert!(
1241            timeout_result.is_err(),
1242            "duplicate GSN 5 broadcast must not emit a second event"
1243        );
1244    }
1245
1246    /// A 0x11 broadcast sealed with the wrong key must be silently dropped — all
1247    /// GSN candidate decrypts fail the 4-byte tag check, so no event, no panic.
1248    #[tokio::test]
1249    #[allow(clippy::unwrap_used)]
1250    async fn wrong_broadcast_key_ignored() {
1251        use tokio_stream::StreamExt as _;
1252        // ble_accessory_with_db installs broadcast_key = BroadcastKey::from_bytes([0u8;32])
1253        let (mut h, gatt) = ble_accessory_with_db().await;
1254
1255        // Seal with the WRONG key ([0xFF;32]).
1256        let wrong_key = hap_crypto::BroadcastKey::from_bytes([0xFF; 32]);
1257        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1258
1259        let mut pt = Vec::new();
1260        pt.extend_from_slice(&1u16.to_le_bytes());
1261        pt.extend_from_slice(&11u16.to_le_bytes());
1262        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]);
1263        let sealed = wrong_key.seal(1, &pt, &aid_bytes);
1264
1265        let mut mfg = vec![0x11u8, 0x00];
1266        mfg.extend_from_slice(&aid_bytes);
1267        mfg.extend_from_slice(&sealed);
1268
1269        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1270        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1271            .await
1272            .unwrap();
1273        let mut events = h.events();
1274
1275        gatt.advert_sender()
1276            .send(crate::gatt::RawAdvert {
1277                manufacturer_data: mfg,
1278            })
1279            .await
1280            .unwrap();
1281
1282        let timeout_result =
1283            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1284        assert!(
1285            timeout_result.is_err(),
1286            "wrong-key broadcast must not emit any event (all candidate opens fail)"
1287        );
1288    }
1289
1290    /// A 0x11 advert whose payload is too short (< 4 bytes after the advertising
1291    /// id) must be silently dropped — `BroadcastKey::open` returns `Err` on
1292    /// `< 4` bytes, so no event, no panic.
1293    #[tokio::test]
1294    #[allow(clippy::unwrap_used)]
1295    async fn malformed_0x11_advert_ignored() {
1296        use tokio_stream::StreamExt as _;
1297        let (mut h, gatt) = ble_accessory_with_db().await;
1298
1299        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1300        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![])
1301            .await
1302            .unwrap();
1303        let mut events = h.events();
1304
1305        // advertising_id present, only 2 payload bytes — too short for open().
1306        let manufacturer_data = vec![0x11, 0x00, 1, 2, 3, 4, 5, 6, 0xAA, 0xBB];
1307        gatt.advert_sender()
1308            .send(crate::gatt::RawAdvert { manufacturer_data })
1309            .await
1310            .unwrap();
1311
1312        let timeout_result =
1313            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1314        assert!(
1315            timeout_result.is_err(),
1316            "malformed (too-short payload) 0x11 advert must not emit any event"
1317        );
1318    }
1319
1320    /// A 0x11 broadcast where the embedded GSN in the plaintext does NOT match
1321    /// the nonce GSN must be silently dropped — the self-consistency check
1322    /// (`u16::from_le_bytes(pt[0..2]) == gsn`) fails, so no emit.
1323    #[tokio::test]
1324    #[allow(clippy::unwrap_used)]
1325    async fn broadcast_value_self_inconsistent_gsn_ignored() {
1326        use tokio_stream::StreamExt as _;
1327        let (mut h, gatt) = ble_accessory_with_db().await;
1328
1329        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1330        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1331
1332        // Plaintext embeds gsn=3 but is sealed at nonce gsn=7.
1333        // After decryption succeeds at candidate gsn=7, the guard
1334        // `u16::from_le_bytes([pt[0], pt[1]]) != gsn` fires (3 != 7) → no emit.
1335        let mut pt = Vec::new();
1336        pt.extend_from_slice(&3u16.to_le_bytes()); // embedded gsn = 3 (mismatches nonce)
1337        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1338        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // Bool true
1339        let sealed = key.seal(7, &pt, &aid_bytes); // sealed at nonce gsn=7
1340
1341        let mut mfg = vec![0x11u8, 0x00];
1342        mfg.extend_from_slice(&aid_bytes);
1343        mfg.extend_from_slice(&sealed);
1344
1345        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1346        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1347            .await
1348            .unwrap();
1349        let mut events = h.events();
1350
1351        gatt.advert_sender()
1352            .send(crate::gatt::RawAdvert {
1353                manufacturer_data: mfg,
1354            })
1355            .await
1356            .unwrap();
1357
1358        let timeout_result =
1359            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1360        assert!(
1361            timeout_result.is_err(),
1362            "self-inconsistent GSN (embedded 3 != nonce 7) must not emit any event"
1363        );
1364    }
1365
1366    #[tokio::test]
1367    #[allow(clippy::unwrap_used)]
1368    async fn read_after_reconnect_re_verifies_before_using_session() {
1369        let (mut h, gatt) = ble_accessory_with_db().await;
1370
1371        // Queue a perfectly valid sealed read response (recv counter 0) — it
1372        // would decode cleanly if the session were used directly.
1373        let mut plain = vec![0x02, 0x01, 0x00];
1374        let vbody = crate::pdu::encode_value_param(&[0x01]);
1375        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1376        plain.extend_from_slice(&vbody);
1377        let sealed =
1378            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1379        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1380
1381        // Simulate a reconnect: the accessory dropped the session. The read must
1382        // now re-run Pair Verify *before* touching the session. The mock can't
1383        // complete that handshake, so the read surfaces an error rather than
1384        // silently decoding with the dead session.
1385        gatt.bump_generation();
1386        let err = h.read(1, 11).await.unwrap_err();
1387        assert!(
1388            !matches!(err, BleError::CharacteristicNotFound { .. }),
1389            "expected a verify/transport error from the re-verify attempt, got {err:?}"
1390        );
1391    }
1392
1393    #[tokio::test]
1394    async fn dedup_emits_once_per_gsn_and_never_downgrades() {
1395        let emitted = Mutex::new(HashMap::new());
1396        // Fresh (iid, gsn): emit and record.
1397        assert!(dedup_should_emit(&emitted, 11, 9).await);
1398        // Same gsn again: suppressed.
1399        assert!(!dedup_should_emit(&emitted, 11, 9).await);
1400        // Newer gsn: emit, record moves forward.
1401        assert!(dedup_should_emit(&emitted, 11, 10).await);
1402        // Out-of-order older gsn (stalled poll racing a broadcast): still
1403        // emits (distinct gsn) but must NOT downgrade the stored record …
1404        assert!(dedup_should_emit(&emitted, 11, 9).await);
1405        // … so a repeat of the newest gsn stays suppressed.
1406        assert!(!dedup_should_emit(&emitted, 11, 10).await);
1407        // Wraparound: 1 is newer than 65535 in RFC 1982 order.
1408        assert!(dedup_should_emit(&emitted, 12, 65535).await);
1409        assert!(dedup_should_emit(&emitted, 12, 1).await);
1410        assert!(!dedup_should_emit(&emitted, 12, 1).await);
1411    }
1412
1413    #[tokio::test]
1414    #[allow(clippy::unwrap_used)]
1415    async fn write_sends_secure_pdu_and_accepts_success() {
1416        let (mut h, gatt) = ble_accessory_with_db().await;
1417        // Sealed empty success response (control, tid, status=0), zero keys.
1418        let plain = vec![0x02, 0x01, 0x00];
1419        let sealed =
1420            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1421        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1422        h.write(1, 11, hap_model::format::CharValue::Bool(true))
1423            .await
1424            .unwrap();
1425    }
1426
1427    #[tokio::test]
1428    #[allow(clippy::unwrap_used)]
1429    async fn write_surfaces_nonzero_pdu_status() {
1430        let (mut h, gatt) = ble_accessory_with_db().await;
1431        let plain = vec![0x02, 0x01, 0x06]; // status 6 = invalid request
1432        let sealed =
1433            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1434        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1435        let err = h
1436            .write(1, 11, hap_model::format::CharValue::Bool(true))
1437            .await
1438            .unwrap_err();
1439        assert!(matches!(err, BleError::RequestRejected(6)));
1440    }
1441
1442    #[tokio::test]
1443    #[allow(clippy::unwrap_used)]
1444    async fn pairing_id_exposes_the_stored_pairing() {
1445        let (h, _g) = ble_accessory_with_db().await;
1446        assert_eq!(h.pairing_id(), "AE:EC:86:C0:BF:D7");
1447    }
1448
1449    #[tokio::test]
1450    #[allow(clippy::unwrap_used)]
1451    async fn disconnect_is_callable_on_the_accessory() {
1452        let (h, _g) = ble_accessory_with_db().await;
1453        h.disconnect().await; // MockGatt uses the default no-op; must compile + run
1454    }
1455
1456    #[tokio::test]
1457    #[allow(clippy::unwrap_used)]
1458    async fn self_sourcing_watch_errors_without_source() {
1459        let (mut h, _g) = ble_accessory_with_db().await;
1460        // No advert source set → must error, never silently no-op.
1461        let err = h.watch_sleepy_events(vec![(1, 11)]).await.unwrap_err();
1462        assert!(matches!(err, BleError::NoAdvertSource));
1463    }
1464
1465    #[tokio::test]
1466    #[allow(clippy::unwrap_used)]
1467    async fn self_sourcing_watch_emits_via_set_source() {
1468        use tokio_stream::StreamExt as _;
1469        let (mut h, gatt) = ble_accessory_with_db().await;
1470        // fixture pairing id is "AE:EC:86:C0:BF:D7" → device_id AE:EC:86:C0:BF:D7
1471        h.set_advert_source(gatt.clone() as std::sync::Arc<dyn crate::gatt::AdvertSource>);
1472        // queue the sealed read the poll will issue for iid 11 (Bool true)
1473        let mut plain = vec![0x02, 0x01, 0x00];
1474        let vbody = crate::pdu::encode_value_param(&[0x01]);
1475        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1476        plain.extend_from_slice(&vbody);
1477        let sealed =
1478            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1479        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1480        h.watch_sleepy_events(vec![(1, 11)]).await.unwrap();
1481        let mut events = h.events();
1482        // 0x06 advert for device AE:EC:86:C0:BF:D7 (0xAE,0xEC,0x86,0xC0,0xBF,0xD7), GSN 9
1483        gatt.advert_sender()
1484            .send(crate::gatt::RawAdvert {
1485                manufacturer_data: vec![
1486                    0x06, 0x21, 0x01, 0xAE, 0xEC, 0x86, 0xC0, 0xBF, 0xD7, 0x01, 0x00, 0x09, 0x00,
1487                    0x01, 0x00,
1488                ],
1489            })
1490            .await
1491            .unwrap();
1492        let ev = events.next().await.unwrap();
1493        assert_eq!((ev.aid, ev.iid), (1, 11));
1494    }
1495}