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