Skip to main content

hap_ble/
bluest_gatt.rs

1//! A [`GattConnection`] backed by the `bluest` crate, with a **reconnect-and-
2//! resume supervisor**: sleepy HAP accessories drop the link every few
3//! operations during the long attribute-database sweep, so each operation
4//! reconnects (re-discovering its characteristic handles by UUID) and retries
5//! on a clean disconnect, resuming where it left off.
6
7use crate::error::{BleError, Result};
8use crate::gatt::{
9    u16_le, AdvertSource, GattCharacteristic, GattConnection, GattService, RawAdvert,
10    HAP_INSTANCE_ID_DESC, HAP_SERVICE_ID_CHAR,
11};
12use crate::scan_gate::ScanGate;
13use async_trait::async_trait;
14use bluest::error::ErrorKind;
15use bluest::{Adapter, Characteristic, Device};
16use std::collections::HashMap;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::sync::{mpsc, Mutex};
21
22/// Per-attempt timeout for re-establishing the link (connect + service
23/// discovery). On macOS a `connect_device` attempted while a scan is running can
24/// hang indefinitely; bounding it (as aiohomekit does via `bleak_retry_connector`)
25/// turns a wedged connect into a failed attempt the backstop can retry.
26const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
27
28/// Per-attempt timeout for the pre-connect teardown (disconnect + adapter
29/// wait). On the pause-ack-timeout escape path these run while a scan may
30/// still be live and can hang exactly like the connect; bounding them turns
31/// that into a failed attempt the backstop retries instead of a wedge that
32/// blocks every later operation behind the scan gate's lock.
33const TEARDOWN_TIMEOUT: Duration = Duration::from_secs(5);
34
35/// The HAP Service-Signature characteristic — appears in *every* service. Only
36/// the one in the Protocol Information service is addressable/used; the rest are
37/// dropped during discovery so the (UUID-keyed) handle map doesn't collide and
38/// the generate-broadcast-key write reaches the correct characteristic.
39const SERVICE_SIGNATURE_CHAR: &str = "000000a5-0000-1000-8000-0026bb765291";
40/// The HAP Protocol Information service — the one whose Service-Signature char is
41/// the generate-broadcast-key target (matches aiohomekit's service-scoped lookup).
42const PROTOCOL_INFO_SERVICE: &str = "000000a2-0000-1000-8000-0026bb765291";
43
44/// Consecutive reconnects allowed *within a single operation* before it gives up
45/// (a runaway backstop). This bounds one stuck read/write, not the connection's
46/// lifetime — a sleepy accessory may legitimately drop the link on most
47/// operations, so a healthy long-lived subscription can far exceed this in
48/// aggregate; only a link that will not stay up for one op long enough to make
49/// progress trips it.
50const MAX_OP_RECONNECTS: u32 = 8;
51
52/// Map a bluest error to a [`BleError`], classifying link-loss conditions as
53/// [`BleError::Disconnected`] (so the supervisor reconnects) from bluest's typed
54/// [`ErrorKind`] rather than by string-matching. A [`Timeout`](ErrorKind::Timeout)
55/// is treated as a disconnect: on some platforms a dropped link surfaces as a
56/// read/write timeout, and a reconnect+retry against a merely-slow accessory is
57/// cheap and self-correcting. A [`NotFound`](ErrorKind::NotFound) is too: on
58/// macOS an operation against a slept accessory's stale characteristic handle
59/// reports it, and the reconnect re-discovers the handles.
60// By value for ergonomic `.map_err(be)`.
61#[allow(clippy::needless_pass_by_value)]
62pub(crate) fn be(e: bluest::Error) -> BleError {
63    match e.kind() {
64        ErrorKind::NotConnected
65        | ErrorKind::AdapterUnavailable
66        | ErrorKind::ConnectionFailed
67        | ErrorKind::ServiceChanged
68        | ErrorKind::NotReady
69        | ErrorKind::NotFound
70        | ErrorKind::Timeout => BleError::Disconnected,
71        _ => BleError::Backend(e.to_string()),
72    }
73}
74
75/// Whether an error means the link dropped (so reconnecting may recover).
76fn is_disconnect(e: &BleError) -> bool {
77    matches!(e, BleError::Disconnected)
78}
79
80/// The discovered structure of one service: its UUID and its characteristics'
81/// UUIDs (stable across reconnects, unlike the bluest handles).
82#[derive(Clone)]
83struct ServiceShape {
84    uuid: String,
85    char_uuids: Vec<String>,
86}
87
88/// A `GattConnection` over a connected `bluest` [`Device`] that reconnects and
89/// retries on a dropped link.
90pub struct BluestConnection {
91    adapter: Adapter,
92    device: Device,
93    /// Lowercased characteristic UUID -> the (current) bluest handle.
94    chars: Mutex<HashMap<String, Characteristic>>,
95    /// The service/characteristic UUID structure (stable across reconnects).
96    shape: Vec<ServiceShape>,
97    /// Increments on every reconnect — also the backstop count. A change since a
98    /// secure session was established means the accessory dropped that session.
99    generation: AtomicU64,
100    /// Coordinates the continuous advert scan with connects: on macOS
101    /// CoreBluetooth a connect cannot complete while a scan is running, so
102    /// `reconnect`/`disconnect` pause the scan for their duration.
103    scan_gate: Arc<ScanGate>,
104}
105
106impl BluestConnection {
107    /// Wrap an already-connected device, discovering its services and
108    /// characteristics.
109    ///
110    /// # Errors
111    /// Returns [`BleError::Backend`] on a bluest discovery failure.
112    pub async fn new(adapter: Adapter, device: Device) -> Result<Self> {
113        let (chars, shape) = Self::discover(&device).await?;
114        Ok(Self {
115            adapter,
116            device,
117            chars: Mutex::new(chars),
118            shape,
119            generation: AtomicU64::new(0),
120            scan_gate: ScanGate::new(),
121        })
122    }
123
124    /// Release the active GATT link. A sleepy HAP accessory only advertises and
125    /// emits encrypted broadcasts while disconnected, and on macOS CoreBluetooth
126    /// filters a connected peripheral out of scan results — so a caller watching
127    /// for sleepy events must disconnect after setup. A subsequent encrypted
128    /// operation (e.g. a disconnected-event poll read) transparently reconnects
129    /// via the supervisor.
130    pub async fn disconnect(&self) {
131        // Pause the scan for the teardown: on macOS a disconnect cannot
132        // complete while a scan is running.
133        let _scan_pause = self.scan_gate.pause().await;
134        let _ = tokio::time::timeout(
135            TEARDOWN_TIMEOUT,
136            self.adapter.disconnect_device(&self.device),
137        )
138        .await;
139    }
140
141    async fn discover(
142        device: &Device,
143    ) -> Result<(HashMap<String, Characteristic>, Vec<ServiceShape>)> {
144        let mut chars = HashMap::new();
145        let mut shape = Vec::new();
146        for svc in device.discover_services().await.map_err(be)? {
147            let svc_uuid = svc.uuid().to_string().to_ascii_lowercase();
148            let is_protocol_info = svc_uuid == PROTOCOL_INFO_SERVICE;
149            let mut char_uuids = Vec::new();
150            for ch in svc.discover_characteristics().await.map_err(be)? {
151                let uuid = ch.uuid().to_string().to_ascii_lowercase();
152                char_uuids.push(uuid.clone());
153                // The Service-Signature char exists in every service and they all
154                // share one UUID; keep only the Protocol Information service's so
155                // the UUID-keyed handle map resolves the generate-broadcast-key
156                // target deterministically (and survives reconnects, unlike an
157                // iid-keyed map that would need a re-sweep).
158                if uuid == SERVICE_SIGNATURE_CHAR && !is_protocol_info {
159                    continue;
160                }
161                chars.insert(uuid, ch);
162            }
163            shape.push(ServiceShape {
164                uuid: svc.uuid().to_string(),
165                char_uuids,
166            });
167        }
168        Ok((chars, shape))
169    }
170
171    /// Re-establish the link and rebuild the characteristic handle map, advancing
172    /// the link [`generation`](Self::generation). The UUID structure
173    /// ([`shape`](Self::shape)) is unchanged.
174    async fn reconnect(&self) -> Result<()> {
175        self.generation.fetch_add(1, Ordering::SeqCst);
176        // Own the radio for the whole teardown + connect: on macOS
177        // CoreBluetooth a connect (or disconnect/wait_available) cannot
178        // complete while a scan is running. The guard resumes the scan when
179        // dropped — on success and on every error path alike.
180        let _scan_pause = self.scan_gate.pause().await;
181        let _ = tokio::time::timeout(TEARDOWN_TIMEOUT, async {
182            let _ = self.adapter.disconnect_device(&self.device).await;
183            let _ = self.adapter.wait_available().await;
184        })
185        .await;
186        // Bound the connect + service discovery: a connect attempted while a scan
187        // is running can wedge on macOS, so a timeout surfaces as a recoverable
188        // disconnect that the per-operation backstop retries rather than hanging.
189        let establish = async {
190            self.adapter
191                .connect_device(&self.device)
192                .await
193                .map_err(be)?;
194            Self::discover(&self.device).await
195        };
196        let (fresh, _shape) = tokio::time::timeout(CONNECT_TIMEOUT, establish)
197            .await
198            .map_err(|_| BleError::Disconnected)??;
199        *self.chars.lock().await = fresh;
200        Ok(())
201    }
202
203    /// Reconnect for one in-flight operation, giving up once a single operation
204    /// has forced [`MAX_OP_RECONNECTS`] reconnects without making progress (the
205    /// link will not stay up long enough to complete it). `attempts` is the
206    /// per-operation reconnect count, owned by the caller's retry loop — it does
207    /// not bound the connection's lifetime.
208    async fn reconnect_bounded(&self, attempts: &mut u32) -> Result<()> {
209        *attempts += 1;
210        if *attempts > MAX_OP_RECONNECTS {
211            return Err(BleError::Disconnected);
212        }
213        self.reconnect().await
214    }
215
216    /// Look up the current handle for a characteristic UUID.
217    async fn handle(&self, char_uuid: &str) -> Result<Characteristic> {
218        self.chars
219            .lock()
220            .await
221            .get(&char_uuid.to_ascii_lowercase())
222            .cloned()
223            .ok_or(BleError::MalformedPdu("gatt characteristic not found"))
224    }
225
226    /// Read a characteristic's HAP instance-id descriptor, reconnecting on drop.
227    async fn read_iid(&self, char_uuid: &str) -> Result<Option<u16>> {
228        let mut attempts = 0;
229        loop {
230            let ch = self.handle(char_uuid).await?;
231            let attempt = async {
232                let descriptors = ch.discover_descriptors().await.map_err(be)?;
233                let Some(desc) = descriptors.iter().find(|d| {
234                    d.uuid()
235                        .to_string()
236                        .eq_ignore_ascii_case(HAP_INSTANCE_ID_DESC)
237                }) else {
238                    return Ok(None);
239                };
240                Ok(u16_le(&desc.read().await.map_err(be)?))
241            }
242            .await;
243            match attempt {
244                Ok(v) => return Ok(v),
245                Err(ref e) if is_disconnect(e) => self.reconnect_bounded(&mut attempts).await?,
246                Err(e) => return Err(e),
247            }
248        }
249    }
250}
251
252#[async_trait]
253impl GattConnection for BluestConnection {
254    async fn instance_id(&self, char_uuid: &str) -> Result<u16> {
255        self.read_iid(char_uuid)
256            .await?
257            .ok_or(BleError::MalformedPdu("no instance id descriptor"))
258    }
259
260    async fn max_write(&self) -> usize {
261        // The MTU is connection-wide, so any characteristic's max write works.
262        let ch = self.chars.lock().await.values().next().cloned();
263        ch.and_then(|c| c.max_write_len().ok())
264            .map_or(crate::gatt::DEFAULT_FRAGMENT_SIZE, |n| n.clamp(20, 512))
265    }
266
267    async fn generation(&self) -> u64 {
268        self.generation.load(Ordering::SeqCst)
269    }
270
271    async fn write(&self, char_uuid: &str, value: &[u8]) -> Result<()> {
272        let mut attempts = 0;
273        loop {
274            let ch = self.handle(char_uuid).await?;
275            match ch.write(value).await.map_err(be) {
276                Ok(()) => return Ok(()),
277                Err(ref e) if is_disconnect(e) => self.reconnect_bounded(&mut attempts).await?,
278                Err(e) => return Err(e),
279            }
280        }
281    }
282
283    async fn read(&self, char_uuid: &str) -> Result<Vec<u8>> {
284        let mut attempts = 0;
285        loop {
286            let ch = self.handle(char_uuid).await?;
287            match ch.read().await.map_err(be) {
288                Ok(v) => return Ok(v),
289                Err(ref e) if is_disconnect(e) => self.reconnect_bounded(&mut attempts).await?,
290                Err(e) => return Err(e),
291            }
292        }
293    }
294
295    // Connected GATT notify is BEST-EFFORT: the spawned task ends when the
296    // notification stream ends (a link drop). It is deliberately NOT re-armed and
297    // does NOT reconnect — a sleepy accessory intentionally drops idle links, so
298    // auto-reconnecting here causes a reconnect storm (validated on hardware).
299    // Durable events come from the advertisement channels (broadcast +
300    // disconnected-event poll); the session re-verifies lazily on the next read.
301    async fn subscribe(&self, char_uuid: &str) -> Result<mpsc::Receiver<Vec<u8>>> {
302        let ch = self.handle(char_uuid).await?;
303        let (tx, rx) = mpsc::channel(16);
304        tokio::spawn(async move {
305            use tokio_stream::StreamExt as _;
306            if let Ok(mut stream) = ch.notify().await {
307                while let Some(item) = stream.next().await {
308                    let Ok(v) = item else { break };
309                    if tx.send(v).await.is_err() {
310                        break;
311                    }
312                }
313            }
314        });
315        Ok(rx)
316    }
317
318    async fn enumerate(&self) -> Result<Vec<GattService>> {
319        let mut services = Vec::new();
320        for svc in &self.shape {
321            let mut characteristics = Vec::new();
322            for char_uuid in &svc.char_uuids {
323                // The Service-Instance-ID characteristic is not a HAP
324                // characteristic; its value would need a paired read.
325                if char_uuid.eq_ignore_ascii_case(HAP_SERVICE_ID_CHAR) {
326                    continue;
327                }
328                // The Service-Signature char is a service-level signature, not a
329                // model characteristic — skip it (it also shares a UUID across
330                // services, so reading it here would yield duplicate iids).
331                if char_uuid.eq_ignore_ascii_case(SERVICE_SIGNATURE_CHAR) {
332                    continue;
333                }
334                // Per-characteristic resilient instance-id read: resumes the
335                // sweep across the device's periodic disconnects.
336                if let Some(iid) = self.read_iid(char_uuid).await? {
337                    characteristics.push(GattCharacteristic {
338                        uuid: char_uuid.clone(),
339                        iid,
340                    });
341                }
342            }
343            services.push(GattService {
344                uuid: svc.uuid.clone(),
345                iid: 0,
346                characteristics,
347            });
348        }
349        Ok(services)
350    }
351}
352
353/// Apple's Bluetooth company identifier; HAP advertisements live under it.
354const APPLE_COMPANY_ID: u16 = 0x004C;
355
356#[async_trait]
357impl AdvertSource for BluestConnection {
358    /// Stream Apple HAP advertisements by running a continuous adapter scan.
359    ///
360    /// Spawns a background task that feeds every Apple (company id `0x004C`)
361    /// manufacturer-data frame into the returned channel. Forwarding is
362    /// best-effort: frames are dropped when the receiver falls behind, not
363    /// queued unboundedly, to ensure the task never blocks on backpressure
364    /// and can always respond to a pause request. While a connect owns the
365    /// radio (the [`ScanGate`] is paused) the task drops its scan stream and
366    /// restarts it on resume. The task stops for good when the receiver is
367    /// dropped or the adapter's scan stream ends.
368    ///
369    /// Intended for a single active watcher per connection: concurrent scan tasks
370    /// would share one scanning flag and corrupt the pause-ack protocol.
371    ///
372    /// # Errors
373    /// Returns [`crate::error::BleError`] on adapter/scan failures.
374    async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
375        let adapter = self.adapter.clone();
376        let gate = self.scan_gate.clone();
377        let (tx, rx) = mpsc::channel(32);
378        tokio::spawn(async move {
379            use tokio_stream::StreamExt as _;
380            let mut pause = gate.pause_watch();
381            loop {
382                // Hold off while a connect owns the radio.
383                while *pause.borrow_and_update() {
384                    if pause.changed().await.is_err() {
385                        return;
386                    }
387                }
388                let Ok(mut scan) = adapter.scan(&[]).await else {
389                    return;
390                };
391                gate.set_scanning(true);
392                let stopped_for_pause = loop {
393                    tokio::select! {
394                        changed = pause.changed() => {
395                            match changed {
396                                Ok(()) if *pause.borrow_and_update() => break true,
397                                Ok(()) => {}
398                                Err(_) => break false,
399                            }
400                        }
401                        adv = scan.next() => {
402                            let Some(adv) = adv else { break false };
403                            let Some(md) = adv.adv_data.manufacturer_data else {
404                                continue;
405                            };
406                            if md.company_id != APPLE_COMPANY_ID {
407                                continue;
408                            }
409                            // Non-blocking send: a stalled consumer must not
410                            // wedge this task inside an await where it cannot
411                            // see a pause request. Adverts are a lossy,
412                            // repeating medium — dropping a frame under
413                            // backpressure is safe; holding the radio is not.
414                            match tx.try_send(RawAdvert {
415                                manufacturer_data: md.data,
416                            }) {
417                                Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
418                                Err(mpsc::error::TrySendError::Closed(_)) => {
419                                    break false; // receiver dropped — stop scanning
420                                }
421                            }
422                        }
423                    }
424                };
425                drop(scan);
426                gate.set_scanning(false);
427                if !stopped_for_pause {
428                    return;
429                }
430            }
431        });
432        Ok(rx)
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    /// A slept accessory's stale handle surfaces as bluest `NotFound` on
441    /// macOS; it must classify as a recoverable disconnect so the supervisor
442    /// reconnects (safe only because reconnect pauses the scan and bounds the
443    /// connect).
444    #[test]
445    fn not_found_maps_to_disconnected() {
446        let e = bluest::Error::from(ErrorKind::NotFound);
447        assert!(matches!(be(e), BleError::Disconnected));
448    }
449}