Skip to main content

hap_ble/
accessory.rs

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