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