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    /// Commission this accessory onto the Thread network described by `dataset`.
527    ///
528    /// HomeKit Thread accessories receive their Thread credentials over HAP-BLE
529    /// rather than a joiner flow: over the established secure session this writes
530    /// a query and then the operational dataset to the accessory's **Thread
531    /// Control Point** characteristic (`0x0704`). After it succeeds the accessory
532    /// joins that Thread network and becomes reachable over Thread (e.g. via
533    /// `hap-thread`).
534    ///
535    /// The accessory typically drops the BLE link as it switches networks, so a
536    /// transport error on the *provision* write is expected and treated as
537    /// success (mirroring aiohomekit); confirm the outcome by the device
538    /// appearing on the mesh, not by this call's result. The dataset carries a
539    /// secret network key — see [`ThreadDataset`](crate::ThreadDataset), whose
540    /// `Debug` redacts it.
541    ///
542    /// # Errors
543    /// [`BleError::CharacteristicNotFound`] if the accessory exposes no Thread
544    /// Control Point (not a Thread accessory); otherwise a GATT/PDU/crypto error
545    /// from the initial query write.
546    pub async fn thread_provision(&mut self, dataset: &crate::thread::ThreadDataset) -> Result<()> {
547        let (uuid, iid) = self.thread_control_point()?;
548
549        // 1. Query the control point (must succeed — proves we can address it).
550        write_char_raw(
551            self.gatt.as_ref(),
552            &self.secure,
553            &self.reviver,
554            &uuid,
555            iid,
556            &crate::thread::encode_query(),
557            self.frag_size,
558        )
559        .await?;
560
561        // 2. Provision. The accessory leaves BLE for Thread as it applies the
562        //    dataset, so an error here is the expected, non-fatal path.
563        let provision = crate::thread::encode_provision(dataset);
564        match write_char_raw(
565            self.gatt.as_ref(),
566            &self.secure,
567            &self.reviver,
568            &uuid,
569            iid,
570            &provision,
571            self.frag_size,
572        )
573        .await
574        {
575            Ok(()) => tracing::info!("thread provision write acknowledged"),
576            Err(e) => tracing::info!(
577                error = %e,
578                "thread provision write errored — expected as the accessory joins Thread"
579            ),
580        }
581        Ok(())
582    }
583
584    /// Resolve the Thread Control Point characteristic `(uuid, iid)` by its HAP
585    /// UUID, if this accessory exposes one.
586    fn thread_control_point(&self) -> Result<(String, u64)> {
587        self.chars
588            .iter()
589            .find(|(_, (uuid, _))| {
590                uuid.eq_ignore_ascii_case(crate::thread::THREAD_CONTROL_POINT_UUID)
591            })
592            .map(|(&(_, iid), (uuid, _))| (uuid.clone(), iid))
593            .ok_or(BleError::CharacteristicNotFound { aid: 0, iid: 0 })
594    }
595
596    /// The accessory's HAP pairing id (as stored in the pairing record).
597    #[must_use]
598    pub fn pairing_id(&self) -> &str {
599        &self.reviver.pairing.pairing_id
600    }
601
602    /// Enable encrypted broadcast notifications for the given characteristic
603    /// instance ids (the HAP BLE accessory id is always 1). Each is an encrypted
604    /// Characteristic-Configuration write (Properties + Broadcast-Interval). Call
605    /// this **while connected**, before disconnecting to receive sleepy events —
606    /// without it the accessory will not emit `0x11` encrypted broadcasts. A
607    /// characteristic that does not support broadcasts is skipped; per-write
608    /// failures are tolerated (best-effort).
609    ///
610    /// # Errors
611    /// Propagates a session re-verify failure.
612    pub async fn enable_broadcasts(&mut self, iids: &[u64]) -> Result<()> {
613        let mut s = self.secure.lock().await;
614        for &iid in iids {
615            let Some((uuid, _)) = self.chars.get(&(1, iid)).cloned() else {
616                tracing::debug!(
617                    iid,
618                    "enable_broadcasts: characteristic not on this accessory — skipped"
619                );
620                continue;
621            };
622            let Ok(iid16) = u16::try_from(iid) else {
623                tracing::debug!(iid, "enable_broadcasts: iid exceeds u16 — skipped");
624                continue;
625            };
626            revive_if_stale(self.gatt.as_ref(), &mut s, &self.reviver).await?;
627            s.tid = s.tid.wrapping_add(1);
628            let tid = s.tid;
629            // Best-effort: an accessory that doesn't support broadcast on this
630            // characteristic rejects the write with a non-zero status, and a dead
631            // link surfaces as an error — neither aborts. The result is logged
632            // (not returned) so callers stay unchanged; enable `hap_ble=debug` to
633            // see, per iid, whether the accessory accepted the broadcast config.
634            match pdu::request_secure(
635                self.gatt.as_ref(),
636                &mut s.session,
637                &uuid,
638                OpCode::CharacteristicConfig,
639                tid,
640                iid16,
641                &ENABLE_BROADCAST_BODY,
642                self.frag_size,
643            )
644            .await
645            {
646                Ok(r) if r.status == 0 => {
647                    tracing::debug!(iid, "enable_broadcasts: accepted by accessory");
648                }
649                Ok(r) => {
650                    tracing::debug!(
651                        iid,
652                        status = r.status,
653                        "enable_broadcasts: rejected by accessory (non-zero HAP status)"
654                    );
655                }
656                Err(e) => {
657                    tracing::debug!(iid, error = %e, "enable_broadcasts: write failed");
658                }
659            }
660        }
661        Ok(())
662    }
663
664    /// Release the underlying link (so the sleepy accessory advertises again).
665    /// A no-op on backends without a live link.
666    pub async fn disconnect(&self) {
667        self.gatt.disconnect().await;
668    }
669
670    /// Subscribe to value-change events for a characteristic. HAP-BLE connected
671    /// events use the GATT notification only as a **trigger**: when it fires we
672    /// issue an encrypted Characteristic-Read for the new value and publish it
673    /// on [`BleAccessory::events`].
674    ///
675    /// # Errors
676    /// [`BleError::CharacteristicNotFound`] if unknown; otherwise GATT errors.
677    ///
678    /// Connected events are best-effort: if the link drops, this GATT
679    /// subscription ends and is not re-armed (re-arming a sleepy device storms).
680    /// Durable updates arrive via [`BleAccessory::events`] from the broadcast and
681    /// disconnected-event channels.
682    pub async fn subscribe(&mut self, aid: u64, iid: u64) -> Result<()> {
683        let (uuid, format) = self
684            .chars
685            .get(&(aid, iid))
686            .cloned()
687            .ok_or(BleError::CharacteristicNotFound { aid, iid })?;
688        let mut rx = self.gatt.subscribe(&uuid).await?;
689        let tx = self.events_tx.clone();
690        let gatt = self.gatt.clone();
691        let secure = self.secure.clone();
692        let reviver = self.reviver.clone();
693        let frag_size = self.frag_size;
694        let task = tokio::spawn(async move {
695            // The notification carries no value; it signals "read me".
696            while rx.recv().await.is_some() {
697                if let Ok(raw) =
698                    read_char_raw(gatt.as_ref(), &secure, &reviver, &uuid, iid, frag_size).await
699                {
700                    if let Ok(value) = db::decode_value(format, &raw) {
701                        let _ = tx.send(CharacteristicEvent { aid, iid, value });
702                    }
703                }
704            }
705        });
706        self.tasks.push(task);
707        Ok(())
708    }
709
710    /// Watch advertisements and deliver disconnected-event updates. Two paths:
711    ///
712    /// - **Regular (0x06) advertisements:** when the accessory's GSN bumps, read
713    ///   each polled characteristic and publish its value on
714    ///   [`BleAccessory::events`]. Events are deduplicated by `(iid, gsn)`.
715    ///   The poll runs on its own task fed by a watch channel, so bumps coalesce.
716    /// - **Encrypted broadcast (0x11) advertisements:** decrypt the value directly
717    ///   from the advertisement using the stored [`hap_crypto::BroadcastKey`] and
718    ///   publish it — no GATT connection needed.
719    ///
720    /// The advert source is supplied by the caller (the same backend object that
721    /// provides the GATT connection).
722    ///
723    /// # Errors
724    /// [`BleError`] if the advert source cannot start.
725    #[allow(clippy::too_many_lines)]
726    pub async fn watch_sleepy_events_with_source(
727        &mut self,
728        advert_source: Arc<dyn crate::gatt::AdvertSource>,
729        device_id: [u8; 6],
730        poll_iids: Vec<(u64, u64)>,
731    ) -> Result<()> {
732        // Pre-resolve poll targets to (aid, iid, uuid, format) so the task needs no
733        // access to self.chars.
734        let mut targets = Vec::new();
735        for (aid, iid) in poll_iids {
736            if let Some((uuid, format)) = self.chars.get(&(aid, iid)).cloned() {
737                targets.push((aid, iid, uuid, format));
738            }
739        }
740        // Build an iid→format map for the 0x11 broadcast-decrypt path.
741        let formats: std::collections::HashMap<u64, CharFormat> = self
742            .chars
743            .iter()
744            .map(|((_, iid), (_, f))| (*iid, *f))
745            .collect();
746        let broadcast_key = self.broadcast_key.clone();
747
748        let mut adverts = advert_source.watch_adverts().await?;
749
750        // The catch-up poll runs on its own task, fed the latest bumped GSN
751        // through a watch channel: the advert loop must never block on GATT
752        // I/O (a reconnect-read pauses the advert scan and can take seconds),
753        // and a burst of bumps coalesces into one poll of the latest GSN.
754        let (poll_tx, mut poll_rx) = tokio::sync::watch::channel(0u16);
755        if !targets.is_empty() {
756            let gatt = self.gatt.clone();
757            let secure = self.secure.clone();
758            let reviver = self.reviver.clone();
759            let frag = self.frag_size;
760            let poll_events = self.events_tx.clone();
761            let poll_emitted = self.emitted.clone();
762            let poll_task = tokio::spawn(async move {
763                while poll_rx.changed().await.is_ok() {
764                    let gsn = *poll_rx.borrow_and_update();
765                    tracing::debug!(
766                        gsn,
767                        targets = targets.len(),
768                        "catch-up poll firing — reconnecting to read"
769                    );
770                    for (aid, iid, uuid, format) in &targets {
771                        match read_char_raw(gatt.as_ref(), &secure, &reviver, uuid, *iid, frag)
772                            .await
773                        {
774                            Ok(raw_val) => {
775                                if let Ok(value) = db::decode_value(*format, &raw_val) {
776                                    if dedup_should_emit(&poll_emitted, *iid, gsn).await {
777                                        tracing::debug!(
778                                            aid = *aid,
779                                            iid = *iid,
780                                            gsn,
781                                            "catch-up poll emitting event"
782                                        );
783                                        let _ = poll_events.send(CharacteristicEvent {
784                                            aid: *aid,
785                                            iid: *iid,
786                                            value,
787                                        });
788                                    } else {
789                                        tracing::debug!(iid = *iid, gsn, "catch-up poll read ok but (iid,gsn) already emitted — deduped");
790                                    }
791                                }
792                            }
793                            Err(e) => {
794                                tracing::debug!(iid = *iid, error = %e, "catch-up poll read failed");
795                            }
796                        }
797                    }
798                }
799            });
800            self.tasks.push(poll_task);
801        }
802
803        let tx = self.events_tx.clone();
804        let last_gsn = self.last_gsn.clone();
805        let emitted = self.emitted.clone();
806        let advert_task = tokio::spawn(async move {
807            tracing::debug!(target = ?device_id, "sleepy advert watch armed");
808            while let Some(raw) = adverts.recv().await {
809                tracing::trace!(
810                    len = raw.manufacturer_data.len(),
811                    first = ?raw.manufacturer_data.first(),
812                    "advert frame reached the sleepy loop"
813                );
814                match crate::advert::HapAdvert::parse(&raw.manufacturer_data) {
815                    Some(crate::advert::HapAdvert::Regular {
816                        device_id: d, gsn, ..
817                    }) => {
818                        if d != device_id {
819                            tracing::trace!(saw = ?d, target = ?device_id, "0x06 advert: device-id mismatch, ignoring");
820                            continue;
821                        }
822                        {
823                            let mut lg = last_gsn.lock().await;
824                            if !gsn_is_newer(gsn, *lg) {
825                                tracing::debug!(
826                                    gsn,
827                                    last_gsn = *lg,
828                                    "0x06 advert for our device: gsn NOT newer — poll suppressed"
829                                );
830                                continue;
831                            }
832                            tracing::debug!(
833                                gsn,
834                                prev = *lg,
835                                "0x06 advert for our device: gsn bump — triggering poll"
836                            );
837                            *lg = gsn;
838                        }
839                        // Hand the bump to the poll task. A dropped receiver
840                        // (no poll targets) is fine.
841                        let _ = poll_tx.send(gsn);
842                    }
843                    Some(crate::advert::HapAdvert::EncryptedNotification {
844                        advertising_id,
845                        payload,
846                    }) => {
847                        if advertising_id != device_id {
848                            tracing::trace!(saw = ?advertising_id, target = ?device_id, "0x11 broadcast: advertising-id mismatch, ignoring");
849                            continue;
850                        }
851                        tracing::debug!(
852                            ?advertising_id,
853                            "0x11 encrypted broadcast for our device — attempting decrypt"
854                        );
855                        let start = *last_gsn.lock().await;
856                        // GSN candidates per aiohomekit: next, current, then a
857                        // forward window up to +100.
858                        let candidates = std::iter::once(start.wrapping_add(1))
859                            .chain(std::iter::once(start))
860                            .chain((2..=100u16).map(|d| start.wrapping_add(d)));
861                        for gsn in candidates {
862                            let Ok(pt) = broadcast_key.open(gsn, &payload, &advertising_id) else {
863                                continue;
864                            };
865                            if pt.len() < 12 {
866                                continue;
867                            }
868                            if u16::from_le_bytes([pt[0], pt[1]]) != gsn {
869                                continue;
870                            }
871                            // stale duplicate: not newer than start — ignore.
872                            if !gsn_is_newer(gsn, start) {
873                                break;
874                            }
875                            let iid = u64::from(u16::from_le_bytes([pt[2], pt[3]]));
876                            // Advance last_gsn now — the device's state
877                            // genuinely moved, even if the iid is unknown or
878                            // the value fails to decode.
879                            {
880                                let mut lg = last_gsn.lock().await;
881                                *lg = gsn;
882                            }
883                            let Some(format) = formats.get(&iid).copied() else {
884                                break;
885                            };
886                            if let Ok(value) = db::decode_value(format, &pt[4..12]) {
887                                if dedup_should_emit(&emitted, iid, gsn).await {
888                                    let _ = tx.send(CharacteristicEvent { aid: 1, iid, value });
889                                }
890                            }
891                            break;
892                        }
893                    }
894                    _ => {}
895                }
896            }
897        });
898        self.tasks.push(advert_task);
899        Ok(())
900    }
901
902    /// Provide the advert source (the connection) so the self-sourcing
903    /// [`watch_sleepy_events`](Self::watch_sleepy_events) can use it.
904    pub fn set_advert_source(&mut self, src: Arc<dyn crate::gatt::AdvertSource>) {
905        self.advert_source = Some(src);
906    }
907
908    /// Watch for sleepy-device events, self-sourcing the advert source (set via
909    /// [`set_advert_source`](Self::set_advert_source)) and the device id (from
910    /// the pairing id). Arms the same machinery as
911    /// [`watch_sleepy_events_with_source`](Self::watch_sleepy_events_with_source).
912    ///
913    /// # Errors
914    /// [`BleError::NoAdvertSource`] if no source was set; [`BleError::Backend`]
915    /// if the stored pairing id cannot be parsed as a device id; otherwise
916    /// advert/GATT errors.
917    pub async fn watch_sleepy_events(&mut self, poll_iids: Vec<(u64, u64)>) -> Result<()> {
918        let src = self.advert_source.clone().ok_or(BleError::NoAdvertSource)?;
919        let device_id =
920            parse_device_id(self.reviver.pairing.pairing_id.as_str()).ok_or_else(|| {
921                BleError::Backend("malformed pairing id; cannot derive device id".into())
922            })?;
923        self.watch_sleepy_events_with_source(src, device_id, poll_iids)
924            .await
925    }
926
927    /// An async stream of characteristic events. Each call returns a fresh
928    /// subscriber to the shared event channel.
929    pub fn events(&self) -> impl tokio_stream::Stream<Item = CharacteristicEvent> {
930        tokio_stream::wrappers::BroadcastStream::new(self.events_tx.subscribe())
931            .filter_map(std::result::Result::ok)
932    }
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938    use crate::test_support::ble_accessory_with_db;
939
940    #[tokio::test]
941    #[allow(clippy::unwrap_used)]
942    async fn find_locates_characteristic() {
943        let (h, _g) = ble_accessory_with_db().await;
944        let (aid, iid) = h
945            .find(ServiceType::LightBulb, CharacteristicType::On)
946            .unwrap();
947        assert_eq!((aid, iid), (1, 11));
948    }
949
950    #[tokio::test]
951    #[allow(clippy::unwrap_used)]
952    async fn find_missing_errors() {
953        let (h, _g) = ble_accessory_with_db().await;
954        let err = h
955            .find(ServiceType::LightBulb, CharacteristicType::Brightness)
956            .unwrap_err();
957        assert!(matches!(err, BleError::CharacteristicNotFound { .. }));
958    }
959
960    #[test]
961    fn encode_remove_pairing_matches_hap_layout() {
962        // State M1, Method RemovePairing(4), Identifier "c2".
963        let tlv = encode_remove_pairing("c2");
964        assert_eq!(
965            tlv,
966            vec![0x06, 0x01, 0x01, 0x00, 0x01, 0x04, 0x01, 0x02, b'c', b'2']
967        );
968    }
969
970    #[test]
971    fn expect_remove_m2_accepts_m2_and_rejects_error() {
972        assert!(expect_remove_m2(&[0x06, 0x01, 0x02]).is_ok());
973        // A kTLVType_Error (0x07) is surfaced as a rejection with its code.
974        assert!(matches!(
975            expect_remove_m2(&[0x07, 0x01, 0x02]),
976            Err(BleError::PairingRejected(2))
977        ));
978        // Anything that is not state M2 is malformed.
979        assert!(matches!(
980            expect_remove_m2(&[0x06, 0x01, 0x01]),
981            Err(BleError::MalformedPdu(_))
982        ));
983    }
984
985    #[tokio::test]
986    #[allow(clippy::unwrap_used)]
987    async fn remove_pairing_writes_request_and_accepts_m2() {
988        let (mut h, gatt) = ble_accessory_with_db().await;
989
990        // The accessory replies to the encrypted RemovePairing write with a
991        // sealed success PDU whose value param is a State-M2 TLV8.
992        let m2 = vec![0x06, 0x01, 0x02];
993        let vbody = crate::pdu::encode_value_param(&m2);
994        let mut plain = vec![0x02, 0x01, 0x00];
995        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
996        plain.extend_from_slice(&vbody);
997        let sealed =
998            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
999        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", sealed);
1000
1001        h.remove_pairing("AE:EC:86:C0:BF:D7").await.unwrap();
1002    }
1003
1004    #[tokio::test]
1005    #[allow(clippy::unwrap_used)]
1006    async fn remove_own_pairing_tolerates_session_teardown() {
1007        // ble_accessory_with_db pairs as controller id "test-controller".
1008        let (mut h, gatt) = ble_accessory_with_db().await;
1009        // The accessory tears down the session as it removes us, so the reply is
1010        // not validly sealed — open() fails with a crypto error. Removing our OWN
1011        // id must still succeed (the removal took effect on write).
1012        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", vec![0u8; 24]);
1013        h.remove_pairing("test-controller").await.unwrap();
1014    }
1015
1016    #[tokio::test]
1017    #[allow(clippy::unwrap_used)]
1018    async fn remove_other_pairing_propagates_teardown_error() {
1019        // The same undecryptable reply when removing a DIFFERENT controller must
1020        // NOT be swallowed — only self-removal tolerates a teardown.
1021        let (mut h, gatt) = ble_accessory_with_db().await;
1022        gatt.queue_read("00000050-0000-1000-8000-0026bb765291", vec![0u8; 24]);
1023        let err = h.remove_pairing("some-other-controller").await.unwrap_err();
1024        assert!(matches!(err, BleError::Crypto(_)));
1025    }
1026
1027    #[tokio::test]
1028    #[allow(clippy::unwrap_used)]
1029    async fn subscribe_then_event_decodes_value() {
1030        use tokio_stream::StreamExt as _;
1031        let (mut h, gatt) = ble_accessory_with_db().await;
1032
1033        // A HAP-BLE connected event is a bare notification (trigger) followed by
1034        // an encrypted Characteristic-Read. Queue the sealed read response the
1035        // accessory would return (zero session keys, recv counter 0).
1036        let mut plain = vec![0x02, 0x01, 0x00];
1037        let vbody = crate::pdu::encode_value_param(&[0x01]); // Bool true
1038        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1039        plain.extend_from_slice(&vbody);
1040        let sealed =
1041            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1042        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1043
1044        h.subscribe(1, 11).await.unwrap();
1045        let mut events = h.events();
1046
1047        // Push the (empty) notification trigger.
1048        gatt.notifier("00000025-0000-1000-8000-0026bb765291")
1049            .unwrap()
1050            .send(Vec::new())
1051            .await
1052            .unwrap();
1053
1054        let ev = events.next().await.unwrap();
1055        assert_eq!(ev.iid, 11);
1056        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1057    }
1058
1059    #[tokio::test]
1060    #[allow(clippy::unwrap_used)]
1061    async fn gsn_bump_triggers_disconnected_event_read() {
1062        use tokio_stream::StreamExt as _;
1063        let (mut h, gatt) = ble_accessory_with_db().await;
1064
1065        // The catch-up poll will issue an encrypted read for iid 11; queue the sealed
1066        // response (zero session keys, recv counter 0) decoding to Bool(true).
1067        let mut plain = vec![0x02, 0x01, 0x00];
1068        let vbody = crate::pdu::encode_value_param(&[0x01]);
1069        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1070        plain.extend_from_slice(&vbody);
1071        let sealed =
1072            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1073        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1074
1075        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1076        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1077            .await
1078            .unwrap();
1079        let mut events = h.events();
1080
1081        // Push a 0x06 advert for device [1..6] with GSN 9 (a bump from 0).
1082        gatt.advert_sender()
1083            .send(crate::gatt::RawAdvert {
1084                manufacturer_data: vec![
1085                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1086                ],
1087            })
1088            .await
1089            .unwrap();
1090
1091        let ev = events.next().await.unwrap();
1092        assert_eq!(ev.iid, 11);
1093        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1094    }
1095
1096    #[tokio::test]
1097    #[allow(clippy::unwrap_used)]
1098    async fn encrypted_broadcast_0x11_decrypts_and_emits_event() {
1099        use tokio_stream::StreamExt as _;
1100        // ble_accessory_with_db sets broadcast_key = BroadcastKey::from_bytes([0u8; 32]).
1101        let (mut h, gatt) = ble_accessory_with_db().await;
1102
1103        // Seal a 12-byte broadcast plaintext: gsn=1, iid=11 (LightBulb On, Bool),
1104        // value bytes = [0x01, 0, 0, 0, 0, 0, 0, 0] (Bool true).
1105        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1106        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1107        let mut pt = Vec::new();
1108        pt.extend_from_slice(&1u16.to_le_bytes()); // gsn = 1
1109        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1110        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // value: Bool true
1111        let sealed = key.seal(1, &pt, &aid_bytes);
1112
1113        // Build a 0x11 manufacturer-data frame: [0x11, 0x00, aid[0..6], sealed...]
1114        let mut mfg = vec![0x11u8, 0x00];
1115        mfg.extend_from_slice(&aid_bytes);
1116        mfg.extend_from_slice(&sealed);
1117
1118        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1119        // poll_iids is empty — broadcast path needs no poll targets.
1120        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1121            .await
1122            .unwrap();
1123        let mut events = h.events();
1124
1125        gatt.advert_sender()
1126            .send(crate::gatt::RawAdvert {
1127                manufacturer_data: mfg,
1128            })
1129            .await
1130            .unwrap();
1131
1132        let ev = events.next().await.unwrap();
1133        assert_eq!(ev.aid, 1);
1134        assert_eq!(ev.iid, 11);
1135        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1136    }
1137
1138    #[test]
1139    fn gsn_is_newer_handles_wraparound() {
1140        assert!(gsn_is_newer(6, 5));
1141        assert!(!gsn_is_newer(5, 5));
1142        assert!(!gsn_is_newer(4, 5));
1143        assert!(gsn_is_newer(1, 65535)); // wrap 65535 -> 1
1144        assert!(!gsn_is_newer(65535, 1)); // not newer across the wrap
1145    }
1146
1147    #[tokio::test]
1148    #[allow(clippy::unwrap_used)]
1149    async fn same_change_via_poll_and_broadcast_emits_once() {
1150        use tokio_stream::StreamExt as _;
1151        let (mut h, gatt) = ble_accessory_with_db().await;
1152
1153        // Queue the sealed Characteristic-Read response the poll will issue for
1154        // iid 11 (same setup as gsn_bump_triggers_disconnected_event_read).
1155        let mut plain = vec![0x02, 0x01, 0x00];
1156        let vbody = crate::pdu::encode_value_param(&[0x01]);
1157        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1158        plain.extend_from_slice(&vbody);
1159        let sealed =
1160            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1161        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1162
1163        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1164        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1165            .await
1166            .unwrap();
1167        let mut events = h.events();
1168
1169        // Send 0x06 advert first (GSN 9) — triggers the poll → reads iid 11 → emits event.
1170        gatt.advert_sender()
1171            .send(crate::gatt::RawAdvert {
1172                manufacturer_data: vec![
1173                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1174                ],
1175            })
1176            .await
1177            .unwrap();
1178
1179        // Wait for the poll-triggered event.
1180        let ev = events.next().await.unwrap();
1181        assert_eq!(ev.iid, 11);
1182        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1183
1184        // Now send a 0x11 broadcast for the same GSN 9 / iid 11 — must be deduped.
1185        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1186        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1187        let mut pt = Vec::new();
1188        pt.extend_from_slice(&9u16.to_le_bytes()); // gsn = 9
1189        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1190        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // value: Bool true
1191        let sealed_bc = key.seal(9, &pt, &aid_bytes);
1192
1193        let mut mfg = vec![0x11u8, 0x00];
1194        mfg.extend_from_slice(&aid_bytes);
1195        mfg.extend_from_slice(&sealed_bc);
1196
1197        gatt.advert_sender()
1198            .send(crate::gatt::RawAdvert {
1199                manufacturer_data: mfg,
1200            })
1201            .await
1202            .unwrap();
1203
1204        // The second event (same iid=11, gsn=9) must be deduped — no second emit.
1205        let timeout_result =
1206            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1207        assert!(
1208            timeout_result.is_err(),
1209            "expected dedup to suppress the duplicate 0x11 broadcast event, but got one"
1210        );
1211    }
1212
1213    /// The advert loop must not block on the catch-up poll's GATT read: while
1214    /// a poll read is stalled (e.g. a scan-pausing reconnect), a 0x11
1215    /// broadcast must still decrypt and emit. Regression test for running poll
1216    /// reads off the advert task.
1217    #[tokio::test]
1218    #[allow(clippy::unwrap_used)]
1219    async fn broadcast_delivered_while_poll_read_blocked() {
1220        use tokio_stream::StreamExt as _;
1221        let (mut h, gatt) = ble_accessory_with_db().await;
1222
1223        // Stall the poll's encrypted read of iid 11 until released; queue the
1224        // sealed Bool(true) response it eventually returns.
1225        let release = gatt.block_next_read("00000025-0000-1000-8000-0026bb765291");
1226        let mut plain = vec![0x02, 0x01, 0x00];
1227        let vbody = crate::pdu::encode_value_param(&[0x01]);
1228        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1229        plain.extend_from_slice(&vbody);
1230        let sealed =
1231            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1232        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1233
1234        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1235        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1236            .await
1237            .unwrap();
1238        let mut events = h.events();
1239
1240        // 0x06 bump to GSN 9 — the poll starts its read and stalls on the gate.
1241        gatt.advert_sender()
1242            .send(crate::gatt::RawAdvert {
1243                manufacturer_data: vec![
1244                    0x06, 0x21, 0x01, 1, 2, 3, 4, 5, 6, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1245                ],
1246            })
1247            .await
1248            .unwrap();
1249
1250        // A 0x11 broadcast for GSN 10 carrying Bool(false) — the advert loop
1251        // must process it while the poll read is still stalled.
1252        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1253        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1254        let mut pt = Vec::new();
1255        pt.extend_from_slice(&10u16.to_le_bytes()); // gsn = 10
1256        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1257        pt.extend_from_slice(&[0x00, 0, 0, 0, 0, 0, 0, 0]); // Bool false
1258        let sealed_bc = key.seal(10, &pt, &aid_bytes);
1259        let mut mfg = vec![0x11u8, 0x00];
1260        mfg.extend_from_slice(&aid_bytes);
1261        mfg.extend_from_slice(&sealed_bc);
1262        gatt.advert_sender()
1263            .send(crate::gatt::RawAdvert {
1264                manufacturer_data: mfg,
1265            })
1266            .await
1267            .unwrap();
1268
1269        // The broadcast event (Bool(false)) must arrive FIRST: the poll read is
1270        // still blocked. With the old inline poll this times out because the
1271        // advert loop is stuck inside the read.
1272        let ev = tokio::time::timeout(std::time::Duration::from_secs(2), events.next())
1273            .await
1274            .unwrap()
1275            .unwrap();
1276        assert_eq!(ev.iid, 11);
1277        assert_eq!(ev.value, hap_model::format::CharValue::Bool(false));
1278
1279        // Release the stalled read; the poll's event (GSN 9, Bool(true)) follows.
1280        release.notify_one();
1281        let ev2 = tokio::time::timeout(std::time::Duration::from_secs(2), events.next())
1282            .await
1283            .unwrap()
1284            .unwrap();
1285        assert_eq!(ev2.iid, 11);
1286        assert_eq!(ev2.value, hap_model::format::CharValue::Bool(true));
1287    }
1288
1289    // ── negative-path tests for sleepy-device event handling ─────────────────
1290
1291    /// A 0x06 advert from a foreign device id must be silently dropped — no
1292    /// event emitted, no panic.
1293    #[tokio::test]
1294    #[allow(clippy::unwrap_used)]
1295    async fn foreign_device_advert_ignored() {
1296        use tokio_stream::StreamExt as _;
1297        let (mut h, gatt) = ble_accessory_with_db().await;
1298
1299        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1300        // watch_sleepy_events expects device_id [1,2,3,4,5,6]
1301        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![(1, 11)])
1302            .await
1303            .unwrap();
1304        let mut events = h.events();
1305
1306        // Send a 0x06 advert whose device_id is [9,9,9,9,9,9] — a foreign device.
1307        gatt.advert_sender()
1308            .send(crate::gatt::RawAdvert {
1309                manufacturer_data: vec![
1310                    0x06, 0x21, 0x01, 9, 9, 9, 9, 9, 9, 0x01, 0x00, 0x09, 0x00, 0x01, 0x00,
1311                ],
1312            })
1313            .await
1314            .unwrap();
1315
1316        let timeout_result =
1317            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1318        assert!(
1319            timeout_result.is_err(),
1320            "foreign device advert must not emit an event, but one was received"
1321        );
1322    }
1323
1324    /// A 0x11 broadcast replayed at the same GSN that was already processed must
1325    /// be silently dropped — stale-GSN dedup.
1326    #[tokio::test]
1327    #[allow(clippy::unwrap_used)]
1328    async fn stale_gsn_broadcast_ignored() {
1329        use tokio_stream::StreamExt as _;
1330        let (mut h, gatt) = ble_accessory_with_db().await;
1331
1332        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1333        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1334
1335        // Plaintext: gsn=5, iid=11, value=Bool(true)
1336        // Layout: [gsn_le: 2B][iid_le: 2B][value: 8B]
1337        let mut pt = Vec::new();
1338        pt.extend_from_slice(&5u16.to_le_bytes()); // gsn = 5
1339        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1340        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // Bool true
1341        let sealed = key.seal(5, &pt, &aid_bytes);
1342
1343        let mut mfg = vec![0x11u8, 0x00];
1344        mfg.extend_from_slice(&aid_bytes);
1345        mfg.extend_from_slice(&sealed);
1346
1347        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1348        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1349            .await
1350            .unwrap();
1351        let mut events = h.events();
1352
1353        // First delivery — GSN 5 is fresh (last_gsn starts at 0).
1354        gatt.advert_sender()
1355            .send(crate::gatt::RawAdvert {
1356                manufacturer_data: mfg.clone(),
1357            })
1358            .await
1359            .unwrap();
1360
1361        let ev = events.next().await.unwrap();
1362        assert_eq!(ev.iid, 11);
1363        assert_eq!(ev.value, hap_model::format::CharValue::Bool(true));
1364
1365        // Second delivery — identical GSN 5 is now stale.
1366        gatt.advert_sender()
1367            .send(crate::gatt::RawAdvert {
1368                manufacturer_data: mfg,
1369            })
1370            .await
1371            .unwrap();
1372
1373        let timeout_result =
1374            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1375        assert!(
1376            timeout_result.is_err(),
1377            "duplicate GSN 5 broadcast must not emit a second event"
1378        );
1379    }
1380
1381    /// A 0x11 broadcast sealed with the wrong key must be silently dropped — all
1382    /// GSN candidate decrypts fail the 4-byte tag check, so no event, no panic.
1383    #[tokio::test]
1384    #[allow(clippy::unwrap_used)]
1385    async fn wrong_broadcast_key_ignored() {
1386        use tokio_stream::StreamExt as _;
1387        // ble_accessory_with_db installs broadcast_key = BroadcastKey::from_bytes([0u8;32])
1388        let (mut h, gatt) = ble_accessory_with_db().await;
1389
1390        // Seal with the WRONG key ([0xFF;32]).
1391        let wrong_key = hap_crypto::BroadcastKey::from_bytes([0xFF; 32]);
1392        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1393
1394        let mut pt = Vec::new();
1395        pt.extend_from_slice(&1u16.to_le_bytes());
1396        pt.extend_from_slice(&11u16.to_le_bytes());
1397        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]);
1398        let sealed = wrong_key.seal(1, &pt, &aid_bytes);
1399
1400        let mut mfg = vec![0x11u8, 0x00];
1401        mfg.extend_from_slice(&aid_bytes);
1402        mfg.extend_from_slice(&sealed);
1403
1404        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1405        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1406            .await
1407            .unwrap();
1408        let mut events = h.events();
1409
1410        gatt.advert_sender()
1411            .send(crate::gatt::RawAdvert {
1412                manufacturer_data: mfg,
1413            })
1414            .await
1415            .unwrap();
1416
1417        let timeout_result =
1418            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1419        assert!(
1420            timeout_result.is_err(),
1421            "wrong-key broadcast must not emit any event (all candidate opens fail)"
1422        );
1423    }
1424
1425    /// A 0x11 advert whose payload is too short (< 4 bytes after the advertising
1426    /// id) must be silently dropped — `BroadcastKey::open` returns `Err` on
1427    /// `< 4` bytes, so no event, no panic.
1428    #[tokio::test]
1429    #[allow(clippy::unwrap_used)]
1430    async fn malformed_0x11_advert_ignored() {
1431        use tokio_stream::StreamExt as _;
1432        let (mut h, gatt) = ble_accessory_with_db().await;
1433
1434        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1435        h.watch_sleepy_events_with_source(advert_source, [1, 2, 3, 4, 5, 6], vec![])
1436            .await
1437            .unwrap();
1438        let mut events = h.events();
1439
1440        // advertising_id present, only 2 payload bytes — too short for open().
1441        let manufacturer_data = vec![0x11, 0x00, 1, 2, 3, 4, 5, 6, 0xAA, 0xBB];
1442        gatt.advert_sender()
1443            .send(crate::gatt::RawAdvert { manufacturer_data })
1444            .await
1445            .unwrap();
1446
1447        let timeout_result =
1448            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1449        assert!(
1450            timeout_result.is_err(),
1451            "malformed (too-short payload) 0x11 advert must not emit any event"
1452        );
1453    }
1454
1455    /// A 0x11 broadcast where the embedded GSN in the plaintext does NOT match
1456    /// the nonce GSN must be silently dropped — the self-consistency check
1457    /// (`u16::from_le_bytes(pt[0..2]) == gsn`) fails, so no emit.
1458    #[tokio::test]
1459    #[allow(clippy::unwrap_used)]
1460    async fn broadcast_value_self_inconsistent_gsn_ignored() {
1461        use tokio_stream::StreamExt as _;
1462        let (mut h, gatt) = ble_accessory_with_db().await;
1463
1464        let key = hap_crypto::BroadcastKey::from_bytes([0u8; 32]);
1465        let aid_bytes: [u8; 6] = [1, 2, 3, 4, 5, 6];
1466
1467        // Plaintext embeds gsn=3 but is sealed at nonce gsn=7.
1468        // After decryption succeeds at candidate gsn=7, the guard
1469        // `u16::from_le_bytes([pt[0], pt[1]]) != gsn` fires (3 != 7) → no emit.
1470        let mut pt = Vec::new();
1471        pt.extend_from_slice(&3u16.to_le_bytes()); // embedded gsn = 3 (mismatches nonce)
1472        pt.extend_from_slice(&11u16.to_le_bytes()); // iid = 11
1473        pt.extend_from_slice(&[0x01, 0, 0, 0, 0, 0, 0, 0]); // Bool true
1474        let sealed = key.seal(7, &pt, &aid_bytes); // sealed at nonce gsn=7
1475
1476        let mut mfg = vec![0x11u8, 0x00];
1477        mfg.extend_from_slice(&aid_bytes);
1478        mfg.extend_from_slice(&sealed);
1479
1480        let advert_source: std::sync::Arc<dyn crate::gatt::AdvertSource> = gatt.clone();
1481        h.watch_sleepy_events_with_source(advert_source, aid_bytes, vec![])
1482            .await
1483            .unwrap();
1484        let mut events = h.events();
1485
1486        gatt.advert_sender()
1487            .send(crate::gatt::RawAdvert {
1488                manufacturer_data: mfg,
1489            })
1490            .await
1491            .unwrap();
1492
1493        let timeout_result =
1494            tokio::time::timeout(std::time::Duration::from_millis(200), events.next()).await;
1495        assert!(
1496            timeout_result.is_err(),
1497            "self-inconsistent GSN (embedded 3 != nonce 7) must not emit any event"
1498        );
1499    }
1500
1501    #[tokio::test]
1502    #[allow(clippy::unwrap_used)]
1503    async fn read_after_reconnect_re_verifies_before_using_session() {
1504        let (mut h, gatt) = ble_accessory_with_db().await;
1505
1506        // Queue a perfectly valid sealed read response (recv counter 0) — it
1507        // would decode cleanly if the session were used directly.
1508        let mut plain = vec![0x02, 0x01, 0x00];
1509        let vbody = crate::pdu::encode_value_param(&[0x01]);
1510        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1511        plain.extend_from_slice(&vbody);
1512        let sealed =
1513            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1514        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1515
1516        // Simulate a reconnect: the accessory dropped the session. The read must
1517        // now re-run Pair Verify *before* touching the session. The mock can't
1518        // complete that handshake, so the read surfaces an error rather than
1519        // silently decoding with the dead session.
1520        gatt.bump_generation();
1521        let err = h.read(1, 11).await.unwrap_err();
1522        assert!(
1523            !matches!(err, BleError::CharacteristicNotFound { .. }),
1524            "expected a verify/transport error from the re-verify attempt, got {err:?}"
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn dedup_emits_once_per_gsn_and_never_downgrades() {
1530        let emitted = Mutex::new(HashMap::new());
1531        // Fresh (iid, gsn): emit and record.
1532        assert!(dedup_should_emit(&emitted, 11, 9).await);
1533        // Same gsn again: suppressed.
1534        assert!(!dedup_should_emit(&emitted, 11, 9).await);
1535        // Newer gsn: emit, record moves forward.
1536        assert!(dedup_should_emit(&emitted, 11, 10).await);
1537        // Out-of-order older gsn (stalled poll racing a broadcast): still
1538        // emits (distinct gsn) but must NOT downgrade the stored record …
1539        assert!(dedup_should_emit(&emitted, 11, 9).await);
1540        // … so a repeat of the newest gsn stays suppressed.
1541        assert!(!dedup_should_emit(&emitted, 11, 10).await);
1542        // Wraparound: 1 is newer than 65535 in RFC 1982 order.
1543        assert!(dedup_should_emit(&emitted, 12, 65535).await);
1544        assert!(dedup_should_emit(&emitted, 12, 1).await);
1545        assert!(!dedup_should_emit(&emitted, 12, 1).await);
1546    }
1547
1548    #[tokio::test]
1549    #[allow(clippy::unwrap_used)]
1550    async fn write_sends_secure_pdu_and_accepts_success() {
1551        let (mut h, gatt) = ble_accessory_with_db().await;
1552        // Sealed empty success response (control, tid, status=0), zero keys.
1553        let plain = vec![0x02, 0x01, 0x00];
1554        let sealed =
1555            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1556        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1557        h.write(1, 11, hap_model::format::CharValue::Bool(true))
1558            .await
1559            .unwrap();
1560    }
1561
1562    #[tokio::test]
1563    #[allow(clippy::unwrap_used)]
1564    async fn write_surfaces_nonzero_pdu_status() {
1565        let (mut h, gatt) = ble_accessory_with_db().await;
1566        let plain = vec![0x02, 0x01, 0x06]; // status 6 = invalid request
1567        let sealed =
1568            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1569        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1570        let err = h
1571            .write(1, 11, hap_model::format::CharValue::Bool(true))
1572            .await
1573            .unwrap_err();
1574        assert!(matches!(err, BleError::RequestRejected(6)));
1575    }
1576
1577    #[tokio::test]
1578    #[allow(clippy::unwrap_used)]
1579    async fn thread_provision_writes_query_then_tolerates_provision_teardown() {
1580        let (mut h, gatt) = ble_accessory_with_db().await;
1581        // Give the accessory a Thread Control Point characteristic (aid 1, iid 99).
1582        let cp = "00000704-0000-1000-8000-0026bb765291";
1583        let (_, fmt) = h.chars.get(&(1, 11)).unwrap().clone();
1584        h.chars.insert((1, 99), (cp.to_string(), fmt));
1585
1586        // The query write gets a sealed success; the provision write finds no
1587        // queued response (the accessory has left BLE for Thread), so its read
1588        // fails — which thread_provision must tolerate.
1589        let plain = vec![0x02, 0x01, 0x00];
1590        let sealed =
1591            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1592        gatt.queue_read(cp, sealed);
1593
1594        let dataset = crate::thread::ThreadDataset {
1595            network_name: "OpenThread-89d7".into(),
1596            channel: 24,
1597            pan_id: 0x89d7,
1598            ext_pan_id: [0x78, 0x96, 0x21, 0x7f, 0x78, 0x7f, 0x6e, 0xbe],
1599            network_key: [0u8; 16],
1600        };
1601        // Ok despite the provision write's teardown error.
1602        h.thread_provision(&dataset).await.unwrap();
1603    }
1604
1605    #[tokio::test]
1606    #[allow(clippy::unwrap_used)]
1607    async fn thread_provision_errors_without_a_control_point() {
1608        let (mut h, _g) = ble_accessory_with_db().await;
1609        let dataset = crate::thread::ThreadDataset {
1610            network_name: "OpenThread-89d7".into(),
1611            channel: 24,
1612            pan_id: 0x89d7,
1613            ext_pan_id: [0u8; 8],
1614            network_key: [0u8; 16],
1615        };
1616        let err = h.thread_provision(&dataset).await.unwrap_err();
1617        assert!(matches!(err, BleError::CharacteristicNotFound { .. }));
1618    }
1619
1620    #[tokio::test]
1621    #[allow(clippy::unwrap_used)]
1622    async fn pairing_id_exposes_the_stored_pairing() {
1623        let (h, _g) = ble_accessory_with_db().await;
1624        assert_eq!(h.pairing_id(), "AE:EC:86:C0:BF:D7");
1625    }
1626
1627    #[tokio::test]
1628    #[allow(clippy::unwrap_used)]
1629    async fn disconnect_is_callable_on_the_accessory() {
1630        let (h, _g) = ble_accessory_with_db().await;
1631        h.disconnect().await; // MockGatt uses the default no-op; must compile + run
1632    }
1633
1634    #[tokio::test]
1635    #[allow(clippy::unwrap_used)]
1636    async fn self_sourcing_watch_errors_without_source() {
1637        let (mut h, _g) = ble_accessory_with_db().await;
1638        // No advert source set → must error, never silently no-op.
1639        let err = h.watch_sleepy_events(vec![(1, 11)]).await.unwrap_err();
1640        assert!(matches!(err, BleError::NoAdvertSource));
1641    }
1642
1643    #[tokio::test]
1644    #[allow(clippy::unwrap_used)]
1645    async fn self_sourcing_watch_emits_via_set_source() {
1646        use tokio_stream::StreamExt as _;
1647        let (mut h, gatt) = ble_accessory_with_db().await;
1648        // fixture pairing id is "AE:EC:86:C0:BF:D7" → device_id AE:EC:86:C0:BF:D7
1649        h.set_advert_source(gatt.clone() as std::sync::Arc<dyn crate::gatt::AdvertSource>);
1650        // queue the sealed read the poll will issue for iid 11 (Bool true)
1651        let mut plain = vec![0x02, 0x01, 0x00];
1652        let vbody = crate::pdu::encode_value_param(&[0x01]);
1653        plain.extend_from_slice(&u16::try_from(vbody.len()).unwrap().to_le_bytes());
1654        plain.extend_from_slice(&vbody);
1655        let sealed =
1656            hap_crypto::aead::chacha20poly1305_seal(&[0u8; 32], &[0u8; 12], &[], &plain).unwrap();
1657        gatt.queue_read("00000025-0000-1000-8000-0026bb765291", sealed);
1658        h.watch_sleepy_events(vec![(1, 11)]).await.unwrap();
1659        let mut events = h.events();
1660        // 0x06 advert for device AE:EC:86:C0:BF:D7 (0xAE,0xEC,0x86,0xC0,0xBF,0xD7), GSN 9
1661        gatt.advert_sender()
1662            .send(crate::gatt::RawAdvert {
1663                manufacturer_data: vec![
1664                    0x06, 0x21, 0x01, 0xAE, 0xEC, 0x86, 0xC0, 0xBF, 0xD7, 0x01, 0x00, 0x09, 0x00,
1665                    0x01, 0x00,
1666                ],
1667            })
1668            .await
1669            .unwrap();
1670        let ev = events.next().await.unwrap();
1671        assert_eq!((ev.aid, ev.iid), (1, 11));
1672    }
1673}