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/// The maximum number of mid-operation re-verify retries before giving up — a
29/// backstop against a link that reconnects on every attempt.
30const MAX_REVIVE_RETRIES: u32 = 3;
31
32/// HAP-BLE Characteristic-Configuration body enabling encrypted broadcasts:
33/// Properties (TLV 0x01, u16 LE = 1) + Broadcast-Interval (TLV 0x02 = 1).
34const ENABLE_BROADCAST_BODY: [u8; 7] = [0x01, 0x02, 0x01, 0x00, 0x02, 0x01, 0x01];
35
36/// A characteristic value-change event.
37#[derive(Debug, Clone, PartialEq)]
38pub struct CharacteristicEvent {
39    /// Accessory instance id.
40    pub aid: u64,
41    /// Characteristic instance id.
42    pub iid: u64,
43    /// The decoded new value.
44    pub value: CharValue,
45}
46
47/// The encrypted-session state shared between foreground reads and the
48/// background event tasks (each event-triggered read also advances the session).
49struct Secure {
50    session: BleSession,
51    tid: u8,
52    /// The link generation at which `session` was established. When the
53    /// connection's generation advances past this (a reconnect), the accessory
54    /// has dropped the session and it must be re-minted via Pair Verify.
55    generation: u64,
56}
57
58/// Everything needed to re-establish a secure session (re-run Pair Verify) after
59/// a reconnect invalidates the accessory's session. Shared with event tasks.
60struct Reviver {
61    keypair: ControllerKeypair,
62    pairing: AccessoryPairing,
63    verify_char: String,
64    verify_iid: u16,
65    frag_size: usize,
66}
67
68/// The post-Pair-Verify material a [`BleAccessory`] needs: the live secure
69/// session and the addresses/keys to re-mint it (Pair Verify) or manage pairings
70/// (the Pairing-Pairings characteristic). Bundled so [`BleAccessory::new`] takes
71/// one descriptive value rather than a long positional argument list.
72pub(crate) struct SecureContext {
73    /// The session established by Pair Verify.
74    pub session: BleSession,
75    /// The link generation `session` was minted at (see [`Secure::generation`]).
76    pub session_generation: u64,
77    /// This controller's long-term identity (to re-run Pair Verify).
78    pub keypair: ControllerKeypair,
79    /// The accessory's pairing (to re-run Pair Verify).
80    pub pairing: AccessoryPairing,
81    /// The Pair-Verify characteristic UUID and instance id.
82    pub verify_char: String,
83    pub verify_iid: u16,
84    /// The Pairing-Pairings characteristic UUID and instance id (RemovePairing).
85    pub pairings_char: String,
86    pub pairings_iid: u16,
87    /// The broadcast decryption key derived during Pair Verify.
88    pub broadcast_key: hap_crypto::BroadcastKey,
89    /// Initial GSN to seed `last_gsn` from a previously-persisted state.
90    pub initial_gsn: u16,
91}
92
93/// If the link has reconnected since the secure session was minted, the
94/// accessory dropped that session — re-run Pair Verify and adopt the fresh keys
95/// (resetting the transaction counter). A no-op when the session is still live.
96async fn revive_if_stale(
97    gatt: &dyn GattConnection,
98    s: &mut Secure,
99    reviver: &Reviver,
100) -> Result<()> {
101    if gatt.generation().await <= s.generation {
102        return Ok(());
103    }
104    let (session, _bkey) = pairing::pair_verify(
105        gatt,
106        &reviver.verify_char,
107        reviver.verify_iid,
108        &reviver.keypair,
109        &reviver.pairing,
110        reviver.frag_size,
111    )
112    .await?;
113    s.session = session;
114    s.tid = 0;
115    // Capture the generation *after* the handshake: Pair Verify itself fails if
116    // the link drops mid-handshake, so reaching here means this is current.
117    s.generation = gatt.generation().await;
118    Ok(())
119}
120
121// kTLVType values for the Pairing-Pairings (Add/Remove/List) exchange.
122mod pairings_tlv {
123    pub(super) const STATE: u8 = 0x06;
124    pub(super) const METHOD: u8 = 0x00;
125    pub(super) const IDENTIFIER: u8 = 0x01;
126    pub(super) const ERROR: u8 = 0x07;
127    pub(super) const STATE_M1: u8 = 0x01;
128    pub(super) const STATE_M2: u8 = 0x02;
129    pub(super) const METHOD_REMOVE: u8 = 0x04;
130}
131
132/// Encode a RemovePairing request (State M1, Method 4, Identifier) as the TLV8
133/// carried in the Pairing-Pairings characteristic's Value param.
134fn encode_remove_pairing(controller_id: &str) -> Vec<u8> {
135    let mut out = Vec::new();
136    let mut w = hap_tlv8::Tlv8Writer::new(&mut out);
137    w.push_u8(pairings_tlv::STATE, pairings_tlv::STATE_M1);
138    w.push_u8(pairings_tlv::METHOD, pairings_tlv::METHOD_REMOVE);
139    w.push(pairings_tlv::IDENTIFIER, controller_id.as_bytes());
140    out
141}
142
143/// Validate a RemovePairing reply: reject a `kTLVType_Error`, then require the
144/// reply state to be M2.
145fn expect_remove_m2(tlv: &[u8]) -> Result<()> {
146    let map = hap_tlv8::Tlv8Map::parse(tlv)?;
147    if let Some(err) = map.get(pairings_tlv::ERROR) {
148        return Err(BleError::PairingRejected(err.first().copied().unwrap_or(1)));
149    }
150    match map
151        .get(pairings_tlv::STATE)
152        .and_then(|s| s.first().copied())
153    {
154        Some(pairings_tlv::STATE_M2) => Ok(()),
155        _ => Err(BleError::MalformedPdu("remove-pairing reply not state M2")),
156    }
157}
158
159/// Issue one encrypted Characteristic-Read and return the raw value bytes,
160/// re-establishing the secure session if a reconnect invalidated it (before the
161/// read, and again if the link drops mid-read — retried a bounded number of
162/// times).
163async fn read_char_raw(
164    gatt: &dyn GattConnection,
165    secure: &Mutex<Secure>,
166    reviver: &Reviver,
167    uuid: &str,
168    iid: u64,
169    frag_size: usize,
170) -> Result<Vec<u8>> {
171    let iid16 = u16::try_from(iid).map_err(|_| BleError::CharacteristicNotFound { aid: 0, iid })?;
172    let mut s = secure.lock().await;
173    let mut attempts = 0;
174    loop {
175        revive_if_stale(gatt, &mut s, reviver).await?;
176        s.tid = s.tid.wrapping_add(1);
177        let tid = s.tid;
178        match pdu::request_secure(
179            gatt,
180            &mut s.session,
181            uuid,
182            OpCode::CharacteristicRead,
183            tid,
184            iid16,
185            &[],
186            frag_size,
187        )
188        .await
189        {
190            Ok(resp) => return pdu::value_param(&resp.body),
191            // A reconnect during the read kills the session mid-stream; if the
192            // generation advanced, re-verify and retry rather than surfacing the
193            // transient failure.
194            Err(e) => {
195                attempts += 1;
196                if attempts < MAX_REVIVE_RETRIES && gatt.generation().await > s.generation {
197                    continue;
198                }
199                return Err(e);
200            }
201        }
202    }
203}
204
205/// A connected BLE accessory: holds the GATT link, the secure session, the
206/// cached attribute database, and a map from (aid, iid) to GATT characteristic
207/// UUID for issuing PDUs.
208pub struct BleAccessory {
209    gatt: Arc<dyn GattConnection>,
210    secure: Arc<Mutex<Secure>>,
211    reviver: Arc<Reviver>,
212    /// The Pairing-Pairings characteristic (UUID, instance id) for RemovePairing.
213    pairings: (String, u16),
214    frag_size: usize,
215    accessories: Vec<Accessory>,
216    /// (aid, iid) -> characteristic UUID, format.
217    chars: HashMap<(u64, u64), (String, CharFormat)>,
218    events_tx: tokio::sync::broadcast::Sender<CharacteristicEvent>,
219    /// Background event-forwarding tasks, aborted when the handle is dropped.
220    tasks: Vec<tokio::task::JoinHandle<()>>,
221    /// The last GSN seen in a regular advertisement; shared with catch-up poll tasks.
222    last_gsn: Arc<Mutex<u16>>,
223    /// Dedup map of `iid → last-emitted GSN`; shared with catch-up poll tasks.
224    /// Bounded to one entry per characteristic (unlike a `HashSet` that grows forever).
225    emitted: Arc<Mutex<HashMap<u64, u16>>>,
226    /// The broadcast decryption key derived during the most recent Pair Verify.
227    broadcast_key: hap_crypto::BroadcastKey,
228}
229
230impl Drop for BleAccessory {
231    fn drop(&mut self) {
232        for task in &self.tasks {
233            task.abort();
234        }
235    }
236}
237
238impl BleAccessory {
239    /// Wrap an established GATT link + session with a pre-built attribute
240    /// database (fetched unencrypted before Pair Verify). Builds the
241    /// `(aid, iid) -> (uuid, format)` map used to address characteristics.
242    ///
243    /// `ctx` carries the established secure session plus the material to re-mint
244    /// it (Pair Verify) after a reconnect and to manage pairings.
245    pub(crate) fn new(
246        gatt: Arc<dyn GattConnection>,
247        ctx: SecureContext,
248        frag_size: usize,
249        gatt_services: &[GattService],
250        accessories: Vec<Accessory>,
251    ) -> Self {
252        let (events_tx, _) = tokio::sync::broadcast::channel(64);
253        // `accessories` models a single accessory (aid 1 — BLE accessories are
254        // not bridges in this milestone), so characteristic iids are unique and
255        // a plain iid->uuid map is sufficient.
256        let mut uuid_by_iid: HashMap<u64, String> = HashMap::new();
257        for gs in gatt_services {
258            for gc in &gs.characteristics {
259                uuid_by_iid.insert(u64::from(gc.iid), gc.uuid.clone());
260            }
261        }
262        let mut chars = HashMap::new();
263        for acc in &accessories {
264            for svc in &acc.services {
265                for ch in &svc.characteristics {
266                    if let Some(uuid) = uuid_by_iid.get(&ch.iid) {
267                        chars.insert((acc.aid, ch.iid), (uuid.clone(), ch.format));
268                    }
269                }
270            }
271        }
272        Self {
273            gatt,
274            secure: Arc::new(Mutex::new(Secure {
275                session: ctx.session,
276                tid: 0,
277                generation: ctx.session_generation,
278            })),
279            reviver: Arc::new(Reviver {
280                keypair: ctx.keypair,
281                pairing: ctx.pairing,
282                verify_char: ctx.verify_char,
283                verify_iid: ctx.verify_iid,
284                frag_size,
285            }),
286            pairings: (ctx.pairings_char, ctx.pairings_iid),
287            frag_size,
288            accessories,
289            chars,
290            events_tx,
291            tasks: Vec::new(),
292            last_gsn: Arc::new(Mutex::new(ctx.initial_gsn)),
293            emitted: Arc::new(Mutex::new(HashMap::new())),
294            broadcast_key: ctx.broadcast_key,
295        }
296    }
297
298    /// The cached attribute database.
299    pub fn accessories(&self) -> &[Accessory] {
300        &self.accessories
301    }
302
303    /// The current persistable broadcast material (key + latest GSN). Persist
304    /// this so a later `connect` can resume broadcast decryption.
305    pub async fn broadcast_state(&self) -> BleBroadcastState {
306        BleBroadcastState {
307            key: self.broadcast_key.clone(),
308            gsn: *self.last_gsn.lock().await,
309        }
310    }
311
312    /// Find the `(aid, iid)` of a characteristic by service + characteristic
313    /// type.
314    ///
315    /// # Errors
316    /// [`BleError::CharacteristicNotFound`] if no match exists.
317    // Take the type enums by value for caller ergonomics and to match the IP
318    // `hap-controller::find` signature (the two unify in Milestone B).
319    #[allow(clippy::needless_pass_by_value)]
320    pub fn find(&self, svc: ServiceType, chr: CharacteristicType) -> Result<(u64, u64)> {
321        for acc in &self.accessories {
322            for service in &acc.services {
323                if service.service_type == svc {
324                    for ch in &service.characteristics {
325                        if ch.char_type == chr {
326                            return Ok((acc.aid, ch.iid));
327                        }
328                    }
329                }
330            }
331        }
332        Err(BleError::CharacteristicNotFound { aid: 0, iid: 0 })
333    }
334
335    /// Read a characteristic value, decoded to its declared format.
336    ///
337    /// # Errors
338    /// [`BleError::CharacteristicNotFound`] if unknown; otherwise GATT/PDU/crypto.
339    pub async fn read(&mut self, aid: u64, iid: u64) -> Result<CharValue> {
340        let (uuid, format) = self
341            .chars
342            .get(&(aid, iid))
343            .cloned()
344            .ok_or(BleError::CharacteristicNotFound { aid, iid })?;
345        let raw = read_char_raw(
346            self.gatt.as_ref(),
347            &self.secure,
348            &self.reviver,
349            &uuid,
350            iid,
351            self.frag_size,
352        )
353        .await?;
354        db::decode_value(format, &raw)
355    }
356
357    /// Remove a pairing by controller pairing id. Pass this controller's own id
358    /// to un-pair this controller; pass another controller's id (this session
359    /// must hold admin permission) to remove that one.
360    ///
361    /// Runs as an encrypted RemovePairing (State M1, Method 4) write to the
362    /// accessory's Pairing-Pairings characteristic; a reconnect-invalidated
363    /// session is re-verified first.
364    ///
365    /// Removing this controller's **own** pairing is a special case: the
366    /// accessory removes the pairing and tears down the secure session as part
367    /// of the same operation, so the encrypted M2 response is frequently lost or
368    /// undecryptable (the link drops, or the reply is no longer sealed under the
369    /// now-defunct session). Per the HAP self-removal semantics, once the request
370    /// has been written the removal has taken effect, so a transport/crypto
371    /// failure *reading the response* on self-removal is reported as success.
372    ///
373    /// # Errors
374    /// [`BleError::PairingRejected`] if the accessory rejects the request (PDU
375    /// status or a `kTLVType_Error` in the M2 reply); otherwise GATT/PDU/crypto
376    /// errors (except the tolerated self-removal teardown described above).
377    pub async fn remove_pairing(&mut self, controller_id: &str) -> Result<()> {
378        let (uuid, iid) = self.pairings.clone();
379        let removing_self = controller_id == self.reviver.keypair.id;
380        let tlv = encode_remove_pairing(controller_id);
381        let body = pdu::encode_write_body(&tlv);
382        let mut s = self.secure.lock().await;
383        revive_if_stale(self.gatt.as_ref(), &mut s, &self.reviver).await?;
384        s.tid = s.tid.wrapping_add(1);
385        let tid = s.tid;
386        let result = pdu::request_secure(
387            self.gatt.as_ref(),
388            &mut s.session,
389            &uuid,
390            OpCode::CharacteristicWrite,
391            tid,
392            iid,
393            &body,
394            self.frag_size,
395        )
396        .await;
397        match result {
398            Ok(resp) if resp.status != 0 => Err(BleError::PairingRejected(resp.status)),
399            Ok(resp) => expect_remove_m2(&pdu::value_param(&resp.body)?),
400            // The request was written, but reading the sealed M2 back failed.
401            // On self-removal that is the expected session teardown — the
402            // pairing is gone — so swallow the teardown-shaped error.
403            Err(BleError::Disconnected | BleError::Crypto(_)) if removing_self => Ok(()),
404            Err(e) => Err(e),
405        }
406    }
407
408    /// Enable encrypted broadcast notifications for the given characteristic
409    /// instance ids (the HAP BLE accessory id is always 1). Each is an encrypted
410    /// Characteristic-Configuration write (Properties + Broadcast-Interval). Call
411    /// this **while connected**, before disconnecting to receive sleepy events —
412    /// without it the accessory will not emit `0x11` encrypted broadcasts. A
413    /// characteristic that does not support broadcasts is skipped; per-write
414    /// failures are tolerated (best-effort).
415    ///
416    /// # Errors
417    /// Propagates a session re-verify failure.
418    pub async fn enable_broadcasts(&mut self, iids: &[u64]) -> Result<()> {
419        let mut s = self.secure.lock().await;
420        for &iid in iids {
421            let Some((uuid, _)) = self.chars.get(&(1, iid)).cloned() else {
422                continue;
423            };
424            let Ok(iid16) = u16::try_from(iid) else {
425                continue;
426            };
427            revive_if_stale(self.gatt.as_ref(), &mut s, &self.reviver).await?;
428            s.tid = s.tid.wrapping_add(1);
429            let tid = s.tid;
430            let _ = pdu::request_secure(
431                self.gatt.as_ref(),
432                &mut s.session,
433                &uuid,
434                OpCode::CharacteristicConfig,
435                tid,
436                iid16,
437                &ENABLE_BROADCAST_BODY,
438                self.frag_size,
439            )
440            .await;
441        }
442        Ok(())
443    }
444
445    /// Subscribe to value-change events for a characteristic. HAP-BLE connected
446    /// events use the GATT notification only as a **trigger**: when it fires we
447    /// issue an encrypted Characteristic-Read for the new value and publish it
448    /// on [`BleAccessory::events`].
449    ///
450    /// # Errors
451    /// [`BleError::CharacteristicNotFound`] if unknown; otherwise GATT errors.
452    ///
453    /// Connected events are best-effort: if the link drops, this GATT
454    /// subscription ends and is not re-armed (re-arming a sleepy device storms).
455    /// Durable updates arrive via [`BleAccessory::events`] from the broadcast and
456    /// disconnected-event channels.
457    pub async fn subscribe(&mut self, aid: u64, iid: u64) -> Result<()> {
458        let (uuid, format) = self
459            .chars
460            .get(&(aid, iid))
461            .cloned()
462            .ok_or(BleError::CharacteristicNotFound { aid, iid })?;
463        let mut rx = self.gatt.subscribe(&uuid).await?;
464        let tx = self.events_tx.clone();
465        let gatt = self.gatt.clone();
466        let secure = self.secure.clone();
467        let reviver = self.reviver.clone();
468        let frag_size = self.frag_size;
469        let task = tokio::spawn(async move {
470            // The notification carries no value; it signals "read me".
471            while rx.recv().await.is_some() {
472                if let Ok(raw) =
473                    read_char_raw(gatt.as_ref(), &secure, &reviver, &uuid, iid, frag_size).await
474                {
475                    if let Ok(value) = db::decode_value(format, &raw) {
476                        let _ = tx.send(CharacteristicEvent { aid, iid, value });
477                    }
478                }
479            }
480        });
481        self.tasks.push(task);
482        Ok(())
483    }
484
485    /// Watch advertisements and deliver disconnected-event updates. Two paths:
486    ///
487    /// - **Regular (0x06) advertisements:** when the accessory's GSN bumps, read
488    ///   each polled characteristic and publish its value on
489    ///   [`BleAccessory::events`]. Events are deduplicated by `(iid, gsn)`.
490    /// - **Encrypted broadcast (0x11) advertisements:** decrypt the value directly
491    ///   from the advertisement using the stored [`hap_crypto::BroadcastKey`] and
492    ///   publish it — no GATT connection needed.
493    ///
494    /// The advert source is supplied by the caller (the same backend object that
495    /// provides the GATT connection).
496    ///
497    /// # Errors
498    /// [`BleError`] if the advert source cannot start.
499    #[allow(clippy::too_many_lines)]
500    pub async fn watch_sleepy_events(
501        &mut self,
502        advert_source: Arc<dyn crate::gatt::AdvertSource>,
503        device_id: [u8; 6],
504        poll_iids: Vec<(u64, u64)>,
505    ) -> Result<()> {
506        // Pre-resolve poll targets to (aid, iid, uuid, format) so the task needs no
507        // access to self.chars.
508        let mut targets = Vec::new();
509        for (aid, iid) in poll_iids {
510            if let Some((uuid, format)) = self.chars.get(&(aid, iid)).cloned() {
511                targets.push((aid, iid, uuid, format));
512            }
513        }
514        // Build an iid→format map for the 0x11 broadcast-decrypt path.
515        let formats: std::collections::HashMap<u64, CharFormat> = self
516            .chars
517            .iter()
518            .map(|((_, iid), (_, f))| (*iid, *f))
519            .collect();
520        let broadcast_key = self.broadcast_key.clone();
521
522        let mut adverts = advert_source.watch_adverts().await?;
523        let tx = self.events_tx.clone();
524        let gatt = self.gatt.clone();
525        let secure = self.secure.clone();
526        let reviver = self.reviver.clone();
527        let frag = self.frag_size;
528        let last_gsn = self.last_gsn.clone();
529        let emitted = self.emitted.clone();
530        let task = tokio::spawn(async move {
531            while let Some(raw) = adverts.recv().await {
532                match crate::advert::HapAdvert::parse(&raw.manufacturer_data) {
533                    Some(crate::advert::HapAdvert::Regular {
534                        device_id: d, gsn, ..
535                    }) => {
536                        if d != device_id {
537                            continue;
538                        }
539                        {
540                            let mut lg = last_gsn.lock().await;
541                            if !gsn_is_newer(gsn, *lg) {
542                                continue;
543                            }
544                            *lg = gsn;
545                        }
546                        for (aid, iid, uuid, format) in &targets {
547                            if let Ok(raw_val) =
548                                read_char_raw(gatt.as_ref(), &secure, &reviver, uuid, *iid, frag)
549                                    .await
550                            {
551                                if let Ok(value) = db::decode_value(*format, &raw_val) {
552                                    let mut e = emitted.lock().await;
553                                    if e.get(iid).copied() != Some(gsn) {
554                                        e.insert(*iid, gsn);
555                                        drop(e);
556                                        let _ = tx.send(CharacteristicEvent {
557                                            aid: *aid,
558                                            iid: *iid,
559                                            value,
560                                        });
561                                    }
562                                }
563                            }
564                        }
565                    }
566                    Some(crate::advert::HapAdvert::EncryptedNotification {
567                        advertising_id,
568                        payload,
569                    }) => {
570                        if advertising_id != device_id {
571                            continue;
572                        }
573                        let start = *last_gsn.lock().await;
574                        // GSN candidates per aiohomekit: next, current, then a
575                        // forward window up to +100.
576                        let candidates = std::iter::once(start.wrapping_add(1))
577                            .chain(std::iter::once(start))
578                            .chain((2..=100u16).map(|d| start.wrapping_add(d)));
579                        for gsn in candidates {
580                            let Ok(pt) = broadcast_key.open(gsn, &payload, &advertising_id) else {
581                                continue;
582                            };
583                            if pt.len() < 12 {
584                                continue;
585                            }
586                            if u16::from_le_bytes([pt[0], pt[1]]) != gsn {
587                                continue;
588                            }
589                            // stale duplicate: this GSN is not newer than start — ignore.
590                            if !gsn_is_newer(gsn, start) {
591                                break;
592                            }
593                            let iid = u64::from(u16::from_le_bytes([pt[2], pt[3]]));
594                            // Advance last_gsn now — the device's state genuinely moved,
595                            // even if the iid is unknown or the value fails to decode.
596                            {
597                                let mut lg = last_gsn.lock().await;
598                                *lg = gsn;
599                            }
600                            let Some(format) = formats.get(&iid).copied() else {
601                                break;
602                            };
603                            if let Ok(value) = db::decode_value(format, &pt[4..12]) {
604                                let mut e = emitted.lock().await;
605                                if e.get(&iid).copied() != Some(gsn) {
606                                    e.insert(iid, gsn);
607                                    drop(e);
608                                    let _ = tx.send(CharacteristicEvent { aid: 1, iid, value });
609                                }
610                            }
611                            break;
612                        }
613                    }
614                    _ => {}
615                }
616            }
617        });
618        self.tasks.push(task);
619        Ok(())
620    }
621
622    /// An async stream of characteristic events. Each call returns a fresh
623    /// subscriber to the shared event channel.
624    pub fn events(&self) -> impl tokio_stream::Stream<Item = CharacteristicEvent> {
625        tokio_stream::wrappers::BroadcastStream::new(self.events_tx.subscribe())
626            .filter_map(std::result::Result::ok)
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::gatt::{GattCharacteristic, GattService, MockGatt};
634    use hap_crypto::SessionKeys;
635
636    #[allow(clippy::unwrap_used)]
637    fn on_le() -> Vec<u8> {
638        let hex = "00000025000010008000".to_string() + "0026bb765291";
639        let mut b: Vec<u8> = (0..16)
640            .map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap())
641            .collect();
642        b.reverse();
643        b
644    }
645
646    fn on_service() -> GattService {
647        GattService {
648            uuid: "00000043-0000-1000-8000-0026bb765291".into(), // LightBulb
649            iid: 10,
650            characteristics: vec![GattCharacteristic {
651                uuid: "00000025-0000-1000-8000-0026bb765291".into(), // On
652                iid: 11,
653            }],
654        }
655    }
656
657    #[allow(clippy::unwrap_used)]
658    fn sig_resp() -> Vec<u8> {
659        let mut body = Vec::new();
660        let mut w = hap_tlv8::Tlv8Writer::new(&mut body);
661        w.push(crate::pdu::param::CHAR_TYPE, &on_le());
662        w.push(crate::pdu::param::PROPERTIES, &0x0083u16.to_le_bytes()); // read+write+events
663        w.push(
664            crate::pdu::param::PRESENTATION_FORMAT,
665            &[0x01, 0, 0, 0, 0, 0, 0],
666        );
667        let mut resp = vec![0x02, 0x01, 0x00];
668        resp.extend_from_slice(&u16::try_from(body.len()).unwrap().to_le_bytes());
669        resp.extend_from_slice(&body);
670        resp
671    }
672
673    #[allow(clippy::unwrap_used)]
674    async fn handle_with_db() -> (BleAccessory, Arc<MockGatt>) {
675        let gatt = Arc::new(MockGatt::new().with_services(vec![on_service()]));
676        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sig_resp());
677        let session = BleSession::new(SessionKeys {
678            read_key: [0; 32],
679            write_key: [0; 32],
680        });
681        let services = gatt.enumerate().await.unwrap();
682        let accessories = crate::db::build_db(gatt.as_ref(), &services, 512)
683            .await
684            .unwrap();
685        let ctx = SecureContext {
686            session,
687            session_generation: 0,
688            keypair: ControllerKeypair::generate("test-controller".into()),
689            pairing: AccessoryPairing {
690                pairing_id: "AE:EC:86:C0:BF:D7".into(),
691                ltpk: [0; 32],
692            },
693            verify_char: "0000004e-0000-1000-8000-0026bb765291".into(),
694            verify_iid: 1,
695            pairings_char: "00000050-0000-1000-8000-0026bb765291".into(),
696            pairings_iid: 2,
697            broadcast_key: hap_crypto::BroadcastKey::from_bytes([0u8; 32]),
698            initial_gsn: 0,
699        };
700        let h = BleAccessory::new(gatt.clone(), ctx, 512, &services, accessories);
701        (h, gatt)
702    }
703
704    #[tokio::test]
705    #[allow(clippy::unwrap_used)]
706    async fn find_locates_characteristic() {
707        let (h, _g) = handle_with_db().await;
708        let (aid, iid) = h
709            .find(ServiceType::LightBulb, CharacteristicType::On)
710            .unwrap();
711        assert_eq!((aid, iid), (1, 11));
712    }
713
714    #[tokio::test]
715    #[allow(clippy::unwrap_used)]
716    async fn find_missing_errors() {
717        let (h, _g) = handle_with_db().await;
718        let err = h
719            .find(ServiceType::LightBulb, CharacteristicType::Brightness)
720            .unwrap_err();
721        assert!(matches!(err, BleError::CharacteristicNotFound { .. }));
722    }
723
724    #[test]
725    fn encode_remove_pairing_matches_hap_layout() {
726        // State M1, Method RemovePairing(4), Identifier "c2".
727        let tlv = encode_remove_pairing("c2");
728        assert_eq!(
729            tlv,
730            vec![0x06, 0x01, 0x01, 0x00, 0x01, 0x04, 0x01, 0x02, b'c', b'2']
731        );
732    }
733
734    #[test]
735    fn expect_remove_m2_accepts_m2_and_rejects_error() {
736        assert!(expect_remove_m2(&[0x06, 0x01, 0x02]).is_ok());
737        // A kTLVType_Error (0x07) is surfaced as a rejection with its code.
738        assert!(matches!(
739            expect_remove_m2(&[0x07, 0x01, 0x02]),
740            Err(BleError::PairingRejected(2))
741        ));
742        // Anything that is not state M2 is malformed.
743        assert!(matches!(
744            expect_remove_m2(&[0x06, 0x01, 0x01]),
745            Err(BleError::MalformedPdu(_))
746        ));
747    }
748
749    #[tokio::test]
750    #[allow(clippy::unwrap_used)]
751    async fn remove_pairing_writes_request_and_accepts_m2() {
752        let (mut h, gatt) = handle_with_db().await;
753
754        // The accessory replies to the encrypted RemovePairing write with a
755        // sealed success PDU whose value param is a State-M2 TLV8.
756        let m2 = vec![0x06, 0x01, 0x02];
757        let vbody = crate::pdu::encode_value_param(&m2);
758        let mut plain = vec![0x02, 0x01, 0x00];
759        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
760        plain.extend_from_slice(&vbody);
761        let sealed =
762            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
763        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", sealed);
764
765        h.remove_pairing("AE:EC:86:C0:BF:D7").await.unwrap();
766    }
767
768    #[tokio::test]
769    #[allow(clippy::unwrap_used)]
770    async fn remove_own_pairing_tolerates_session_teardown() {
771        // handle_with_db pairs as controller id "test-controller".
772        let (mut h, gatt) = handle_with_db().await;
773        // The accessory tears down the session as it removes us, so the reply is
774        // not validly sealed — open() fails with a crypto error. Removing our OWN
775        // id must still succeed (the removal took effect on write).
776        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", vec![0u8; 24]);
777        h.remove_pairing("test-controller").await.unwrap();
778    }
779
780    #[tokio::test]
781    #[allow(clippy::unwrap_used)]
782    async fn remove_other_pairing_propagates_teardown_error() {
783        // The same undecryptable reply when removing a DIFFERENT controller must
784        // NOT be swallowed — only self-removal tolerates a teardown.
785        let (mut h, gatt) = handle_with_db().await;
786        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", vec![0u8; 24]);
787        let err = h.remove_pairing("some-other-controller").await.unwrap_err();
788        assert!(matches!(err, BleError::Crypto(_)));
789    }
790
791    #[tokio::test]
792    #[allow(clippy::unwrap_used)]
793    async fn subscribe_then_event_decodes_value() {
794        use tokio_stream::StreamExt as _;
795        let (mut h, gatt) = handle_with_db().await;
796
797        // A HAP-BLE connected event is a bare notification (trigger) followed by
798        // an encrypted Characteristic-Read. Queue the sealed read response the
799        // accessory would return (zero session keys, recv counter 0).
800        let mut plain = vec![0x02, 0x01, 0x00];
801        let vbody = crate::pdu::encode_value_param(&[0x01]); // Bool true
802        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
803        plain.extend_from_slice(&vbody);
804        let sealed =
805            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
806        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
807
808        h.subscribe(1, 11).await.unwrap();
809        let mut events = h.events();
810
811        // Push the (empty) notification trigger.
812        gatt.notifier("00000025-0000-1000-8000-0026bb765291")
813            .unwrap()
814            .send(Vec::new())
815            .await
816            .unwrap();
817
818        let ev = events.next().await.unwrap();
819        assert_eq!(ev.iid, 11);
820        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
821    }
822
823    #[tokio::test]
824    #[allow(clippy::unwrap_used)]
825    async fn gsn_bump_triggers_disconnected_event_read() {
826        use tokio_stream::StreamExt as _;
827        let (mut h, gatt) = handle_with_db().await;
828
829        // The catch-up poll will issue an encrypted read for iid 11; queue the sealed
830        // response (zero session keys, recv counter 0) decoding to Bool(true).
831        let mut plain = vec![0x02, 0x01, 0x00];
832        let vbody = crate::pdu::encode_value_param(&[0x01]);
833        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
834        plain.extend_from_slice(&vbody);
835        let sealed =
836            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
837        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
838
839        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
840        h.watch_sleepy_events(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
841            .await
842            .unwrap();
843        let mut events = h.events();
844
845        // Push a 0x06 advert for device [1..6] with GSN 9 (a bump from 0).
846        gatt.advert_sender()
847            .send(crate::gatt::RawAdvert {
848                manufacturer_data: vec![
849                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
850                ],
851            })
852            .await
853            .unwrap();
854
855        let ev = events.next().await.unwrap();
856        assert_eq!(ev.iid, 11);
857        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
858    }
859
860    #[tokio::test]
861    #[allow(clippy::unwrap_used)]
862    async fn encrypted_broadcast_0x11_decrypts_and_emits_event() {
863        use tokio_stream::StreamExt as _;
864        // handle_with_db sets broadcast_key = BroadcastKey::from_bytes([0u8; 32]).
865        let (mut h, gatt) = handle_with_db().await;
866
867        // Seal a 12-byte broadcast plaintext: gsn=1, iid=11 (LightBulb On, Bool),
868        // value bytes = [0x01, 0, 0, 0, 0, 0, 0, 0] (Bool true).
869        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
870        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
871        let mut pt = Vec::new();
872        pt.extend_from_slice(&1u16.to_le_bytes()); // gsn = 1
873        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
874        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // value: Bool true
875        let sealed = key.seal(1, &pt, &aid_bytes);
876
877        // Build a 0x11 manufacturer-data frame: [0x11, 0x00, aid[0..6], sealed...]
878        let mut mfg = vec![0x11u8, 0x00];
879        mfg.extend_from_slice(&aid_bytes);
880        mfg.extend_from_slice(&sealed);
881
882        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
883        // poll_iids is empty — broadcast path needs no poll targets.
884        h.watch_sleepy_events(advert_source, aid_bytes, vec![])
885            .await
886            .unwrap();
887        let mut events = h.events();
888
889        gatt.advert_sender()
890            .send(crate::gatt::RawAdvert {
891                manufacturer_data: mfg,
892            })
893            .await
894            .unwrap();
895
896        let ev = events.next().await.unwrap();
897        assert_eq!(ev.aid, 1);
898        assert_eq!(ev.iid, 11);
899        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
900    }
901
902    #[test]
903    fn gsn_is_newer_handles_wraparound() {
904        assert!(gsn_is_newer(6, 5));
905        assert!(!gsn_is_newer(5, 5));
906        assert!(!gsn_is_newer(4, 5));
907        assert!(gsn_is_newer(1, 65535)); // wrap 65535 -> 1
908        assert!(!gsn_is_newer(65535, 1)); // not newer across the wrap
909    }
910
911    #[tokio::test]
912    #[allow(clippy::unwrap_used)]
913    async fn same_change_via_poll_and_broadcast_emits_once() {
914        use tokio_stream::StreamExt as _;
915        let (mut h, gatt) = handle_with_db().await;
916
917        // Queue the sealed Characteristic-Read response the poll will issue for
918        // iid 11 (same setup as gsn_bump_triggers_disconnected_event_read).
919        let mut plain = vec![0x02, 0x01, 0x00];
920        let vbody = crate::pdu::encode_value_param(&[0x01]);
921        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
922        plain.extend_from_slice(&vbody);
923        let sealed =
924            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
925        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
926
927        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
928        h.watch_sleepy_events(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
929            .await
930            .unwrap();
931        let mut events = h.events();
932
933        // Send 0x06 advert first (GSN 9) — triggers the poll → reads iid 11 → emits event.
934        gatt.advert_sender()
935            .send(crate::gatt::RawAdvert {
936                manufacturer_data: vec![
937                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
938                ],
939            })
940            .await
941            .unwrap();
942
943        // Wait for the poll-triggered event.
944        let ev = events.next().await.unwrap();
945        assert_eq!(ev.iid, 11);
946        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
947
948        // Now send a 0x11 broadcast for the same GSN 9 / iid 11 — must be deduped.
949        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
950        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
951        let mut pt = Vec::new();
952        pt.extend_from_slice(&9u16.to_le_bytes()); // gsn = 9
953        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
954        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // value: Bool true
955        let sealed_bc = key.seal(9, &pt, &aid_bytes);
956
957        let mut mfg = vec![0x11u8, 0x00];
958        mfg.extend_from_slice(&aid_bytes);
959        mfg.extend_from_slice(&sealed_bc);
960
961        gatt.advert_sender()
962            .send(crate::gatt::RawAdvert {
963                manufacturer_data: mfg,
964            })
965            .await
966            .unwrap();
967
968        // The second event (same iid=11, gsn=9) must be deduped — no second emit.
969        let timeout_result =
970            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
971        assert!(
972            timeout_result.is_err(),
973            "expected dedup to suppress the duplicate 0x11 broadcast event, but got one"
974        );
975    }
976
977    // ── negative-path tests for sleepy-device event handling ─────────────────
978
979    /// A 0x06 advert from a foreign device id must be silently dropped — no
980    /// event emitted, no panic.
981    #[tokio::test]
982    #[allow(clippy::unwrap_used)]
983    async fn foreign_device_advert_ignored() {
984        use tokio_stream::StreamExt as _;
985        let (mut h, gatt) = handle_with_db().await;
986
987        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
988        // watch_sleepy_events expects device_id [1,2,3,4,5,6]
989        h.watch_sleepy_events(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
990            .await
991            .unwrap();
992        let mut events = h.events();
993
994        // Send a 0x06 advert whose device_id is [9,9,9,9,9,9] — a foreign device.
995        gatt.advert_sender()
996            .send(crate::gatt::RawAdvert {
997                manufacturer_data: vec![
998                    0x06, 0x21, 0x01, 9, 9, 9, 9, 9, 9, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
999                ],
1000            })
1001            .await
1002            .unwrap();
1003
1004        let timeout_result =
1005            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1006        assert!(
1007            timeout_result.is_err(),
1008            "foreign device advert must not emit an event, but one was received"
1009        );
1010    }
1011
1012    /// A 0x11 broadcast replayed at the same GSN that was already processed must
1013    /// be silently dropped — stale-GSN dedup.
1014    #[tokio::test]
1015    #[allow(clippy::unwrap_used)]
1016    async fn stale_gsn_broadcast_ignored() {
1017        use tokio_stream::StreamExt as _;
1018        let (mut h, gatt) = handle_with_db().await;
1019
1020        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1021        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1022
1023        // Plaintext: gsn=5, iid=11, value=Bool(true)
1024        // Layout: [gsn_le: 2B][iid_le: 2B][value: 8B]
1025        let mut pt = Vec::new();
1026        pt.extend_from_slice(&5u16.to_le_bytes()); // gsn = 5
1027        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1028        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // Bool true
1029        let sealed = key.seal(5, &pt, &aid_bytes);
1030
1031        let mut mfg = vec![0x11u8, 0x00];
1032        mfg.extend_from_slice(&aid_bytes);
1033        mfg.extend_from_slice(&sealed);
1034
1035        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1036        h.watch_sleepy_events(advert_source, aid_bytes, vec![])
1037            .await
1038            .unwrap();
1039        let mut events = h.events();
1040
1041        // First delivery — GSN 5 is fresh (last_gsn starts at 0).
1042        gatt.advert_sender()
1043            .send(crate::gatt::RawAdvert {
1044                manufacturer_data: mfg.clone(),
1045            })
1046            .await
1047            .unwrap();
1048
1049        let ev = events.next().await.unwrap();
1050        assert_eq!(ev.iid, 11);
1051        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1052
1053        // Second delivery — identical GSN 5 is now stale.
1054        gatt.advert_sender()
1055            .send(crate::gatt::RawAdvert {
1056                manufacturer_data: mfg,
1057            })
1058            .await
1059            .unwrap();
1060
1061        let timeout_result =
1062            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1063        assert!(
1064            timeout_result.is_err(),
1065            "duplicate GSN 5 broadcast must not emit a second event"
1066        );
1067    }
1068
1069    /// A 0x11 broadcast sealed with the wrong key must be silently dropped — all
1070    /// GSN candidate decrypts fail the 4-byte tag check, so no event, no panic.
1071    #[tokio::test]
1072    #[allow(clippy::unwrap_used)]
1073    async fn wrong_broadcast_key_ignored() {
1074        use tokio_stream::StreamExt as _;
1075        // handle_with_db installs broadcast_key = BroadcastKey::from_bytes([0u8;32])
1076        let (mut h, gatt) = handle_with_db().await;
1077
1078        // Seal with the WRONG key ([0xFF;32]).
1079        let wrong_key = hap_crypto::BroadcastKey::from_bytes([0xFF; 32]);
1080        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1081
1082        let mut pt = Vec::new();
1083        pt.extend_from_slice(&1u16.to_le_bytes());
1084        pt.extend_from_slice(&11u16.to_le_bytes());
1085        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]);
1086        let sealed = wrong_key.seal(1, &pt, &aid_bytes);
1087
1088        let mut mfg = vec![0x11u8, 0x00];
1089        mfg.extend_from_slice(&aid_bytes);
1090        mfg.extend_from_slice(&sealed);
1091
1092        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1093        h.watch_sleepy_events(advert_source, aid_bytes, vec![])
1094            .await
1095            .unwrap();
1096        let mut events = h.events();
1097
1098        gatt.advert_sender()
1099            .send(crate::gatt::RawAdvert {
1100                manufacturer_data: mfg,
1101            })
1102            .await
1103            .unwrap();
1104
1105        let timeout_result =
1106            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1107        assert!(
1108            timeout_result.is_err(),
1109            "wrong-key broadcast must not emit any event (all candidate opens fail)"
1110        );
1111    }
1112
1113    /// A 0x11 advert whose payload is too short (< 4 bytes after the advertising
1114    /// id) must be silently dropped — `BroadcastKey::open` returns `Err` on
1115    /// `< 4` bytes, so no event, no panic.
1116    #[tokio::test]
1117    #[allow(clippy::unwrap_used)]
1118    async fn malformed_0x11_advert_ignored() {
1119        use tokio_stream::StreamExt as _;
1120        let (mut h, gatt) = handle_with_db().await;
1121
1122        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1123        h.watch_sleepy_events(advert_source, [1, 2, 3, 4, 5, 6], vec![])
1124            .await
1125            .unwrap();
1126        let mut events = h.events();
1127
1128        // advertising_id present, only 2 payload bytes — too short for open().
1129        let manufacturer_data = vec![0x11, 0x00, 1, 2, 3, 4, 5, 6, 0xAA, 0xBB];
1130        gatt.advert_sender()
1131            .send(crate::gatt::RawAdvert { manufacturer_data })
1132            .await
1133            .unwrap();
1134
1135        let timeout_result =
1136            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1137        assert!(
1138            timeout_result.is_err(),
1139            "malformed (too-short payload) 0x11 advert must not emit any event"
1140        );
1141    }
1142
1143    /// A 0x11 broadcast where the embedded GSN in the plaintext does NOT match
1144    /// the nonce GSN must be silently dropped — the self-consistency check
1145    /// (`u16::from_le_bytes(pt[0..2]) == gsn`) fails, so no emit.
1146    #[tokio::test]
1147    #[allow(clippy::unwrap_used)]
1148    async fn broadcast_value_self_inconsistent_gsn_ignored() {
1149        use tokio_stream::StreamExt as _;
1150        let (mut h, gatt) = handle_with_db().await;
1151
1152        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1153        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1154
1155        // Plaintext embeds gsn=3 but is sealed at nonce gsn=7.
1156        // After decryption succeeds at candidate gsn=7, the guard
1157        // `u16::from_le_bytes([pt[0], pt[1]]) != gsn` fires (3 != 7) → no emit.
1158        let mut pt = Vec::new();
1159        pt.extend_from_slice(&3u16.to_le_bytes()); // embedded gsn = 3 (mismatches nonce)
1160        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1161        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // Bool true
1162        let sealed = key.seal(7, &pt, &aid_bytes); // sealed at nonce gsn=7
1163
1164        let mut mfg = vec![0x11u8, 0x00];
1165        mfg.extend_from_slice(&aid_bytes);
1166        mfg.extend_from_slice(&sealed);
1167
1168        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1169        h.watch_sleepy_events(advert_source, aid_bytes, vec![])
1170            .await
1171            .unwrap();
1172        let mut events = h.events();
1173
1174        gatt.advert_sender()
1175            .send(crate::gatt::RawAdvert {
1176                manufacturer_data: mfg,
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            "self-inconsistent GSN (embedded 3 != nonce 7) must not emit any event"
1186        );
1187    }
1188
1189    #[tokio::test]
1190    #[allow(clippy::unwrap_used)]
1191    async fn read_after_reconnect_re_verifies_before_using_session() {
1192        let (mut h, gatt) = handle_with_db().await;
1193
1194        // Queue a perfectly valid sealed read response (recv counter 0) — it
1195        // would decode cleanly if the session were used directly.
1196        let mut plain = vec![0x02, 0x01, 0x00];
1197        let vbody = crate::pdu::encode_value_param(&[0x01]);
1198        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1199        plain.extend_from_slice(&vbody);
1200        let sealed =
1201            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1202        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1203
1204        // Simulate a reconnect: the accessory dropped the session. The read must
1205        // now re-run Pair Verify *before* touching the session. The mock can't
1206        // complete that handshake, so the read surfaces an error rather than
1207        // silently decoding with the dead session.
1208        gatt.bump_generation();
1209        let err = h.read(1, 11).await.unwrap_err();
1210        assert!(
1211            !matches!(err, BleError::CharacteristicNotFound { .. }),
1212            "expected a verify/transport error from the re-verify attempt, got {err:?}"
1213        );
1214    }
1215}