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, PROTOCOL_INFO_SERVICE, SERVICE_SIGNATURE_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/// Consecutive reconnects allowed *within a single operation* before it gives up
36/// (a runaway backstop). This bounds one stuck read/write, not the connection's
37/// lifetime — a sleepy accessory may legitimately drop the link on most
38/// operations, so a healthy long-lived subscription can far exceed this in
39/// aggregate; only a link that will not stay up for one op long enough to make
40/// progress trips it.
41const MAX_OP_RECONNECTS: u32 = 8;
42
43/// Map a bluest error to a [`BleError`], classifying link-loss conditions as
44/// [`BleError::Disconnected`] (so the supervisor reconnects). Primarily from
45/// bluest's typed [`ErrorKind`]: a [`Timeout`](ErrorKind::Timeout) is treated as
46/// a disconnect (on some platforms a dropped link surfaces as a read/write
47/// timeout, and a reconnect+retry against a merely-slow accessory is cheap and
48/// self-correcting); a [`NotFound`](ErrorKind::NotFound) too (on macOS an
49/// operation against a slept accessory's stale characteristic handle reports it,
50/// and the reconnect re-discovers the handles). Linux/BlueZ, however, collapses a
51/// "device not connected" GATT error into an untyped [`Other`](ErrorKind::Other),
52/// so a message fallback recovers that one case — see [`classify`].
53// By value for ergonomic `.map_err(be)`.
54#[allow(clippy::needless_pass_by_value)]
55pub(crate) fn be(e: bluest::Error) -> BleError {
56    classify(e.kind(), &e.to_string())
57}
58
59/// Classify a bluest error's kind + message into a [`BleError`]. Split out so
60/// the Linux message-recovery path (below) is unit-testable without
61/// constructing a backend error with a specific message.
62fn classify(kind: ErrorKind, msg: &str) -> BleError {
63    match 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        // Linux/BlueZ collapses "device not connected" into a generic error
72        // (bluer `Failed` → bluest `ErrorKind::Other`), losing the typed signal
73        // CoreBluetooth surfaces as `NotConnected`. Recover it from the message
74        // so the reconnect-and-retry supervisor fires on Linux exactly as it
75        // does on macOS — otherwise a sleepy catch-up poll reads on a link that
76        // was never re-established and every read returns "Not connected".
77        _ if msg.to_ascii_lowercase().contains("not connected") => BleError::Disconnected,
78        _ => BleError::Backend(msg.to_string()),
79    }
80}
81
82/// Whether an error means the link dropped (so reconnecting may recover).
83fn is_disconnect(e: &BleError) -> bool {
84    matches!(e, BleError::Disconnected)
85}
86
87/// The discovered structure of one service: its UUID and its characteristics'
88/// UUIDs (stable across reconnects, unlike the bluest handles).
89#[derive(Clone)]
90struct ServiceShape {
91    uuid: String,
92    char_uuids: Vec<String>,
93    /// The HAP service instance id (read from the service's Service-Instance-ID
94    /// characteristic). `0` unless captured — currently read only for the
95    /// Protocol-Information service, whose iid is the generate-broadcast-key
96    /// request target.
97    service_iid: u16,
98}
99
100/// A `GattConnection` over a connected `bluest` [`Device`] that reconnects and
101/// retries on a dropped link.
102pub struct BluestConnection {
103    adapter: Adapter,
104    device: Device,
105    /// Lowercased characteristic UUID -> the (current) bluest handle.
106    chars: Mutex<HashMap<String, Characteristic>>,
107    /// The service/characteristic UUID structure (stable across reconnects).
108    shape: Vec<ServiceShape>,
109    /// Increments on every reconnect — also the backstop count. A change since a
110    /// secure session was established means the accessory dropped that session.
111    generation: AtomicU64,
112    /// Coordinates the continuous advert scan with connects: on macOS
113    /// CoreBluetooth a connect cannot complete while a scan is running, so
114    /// `reconnect`/`disconnect` pause the scan for their duration.
115    scan_gate: Arc<ScanGate>,
116}
117
118impl BluestConnection {
119    /// Wrap an already-connected device, discovering its services and
120    /// characteristics.
121    ///
122    /// # Errors
123    /// Returns [`BleError::Backend`] on a bluest discovery failure.
124    pub async fn new(adapter: Adapter, device: Device) -> Result<Self> {
125        let (chars, shape) = Self::discover(&device).await?;
126        Ok(Self {
127            adapter,
128            device,
129            chars: Mutex::new(chars),
130            shape,
131            generation: AtomicU64::new(0),
132            scan_gate: ScanGate::new(),
133        })
134    }
135
136    async fn discover(
137        device: &Device,
138    ) -> Result<(HashMap<String, Characteristic>, Vec<ServiceShape>)> {
139        let mut chars = HashMap::new();
140        let mut shape = Vec::new();
141        for svc in device.discover_services().await.map_err(be)? {
142            let svc_uuid = svc.uuid().to_string().to_ascii_lowercase();
143            let is_protocol_info = svc_uuid == PROTOCOL_INFO_SERVICE;
144            let mut char_uuids = Vec::new();
145            let mut service_iid = 0u16;
146            for ch in svc.discover_characteristics().await.map_err(be)? {
147                let uuid = ch.uuid().to_string().to_ascii_lowercase();
148                char_uuids.push(uuid.clone());
149                // The generate-broadcast-key PDU carries the Protocol-Information
150                // SERVICE's instance id (aiohomekit's `hap_char.service.iid`),
151                // not the Service-Signature characteristic's own iid. Read it from
152                // this service's Service-Instance-ID characteristic value now,
153                // while we hold the exact per-service handle — its UUID is shared
154                // across every service, so the UUID-keyed map can't recover it.
155                if is_protocol_info && uuid == HAP_SERVICE_ID_CHAR {
156                    service_iid = u16_le(&ch.read().await.map_err(be)?).unwrap_or(0);
157                }
158                // The Service-Signature char exists in every service and they all
159                // share one UUID; keep only the Protocol Information service's so
160                // the UUID-keyed handle map resolves the generate-broadcast-key
161                // target deterministically (and survives reconnects, unlike an
162                // iid-keyed map that would need a re-sweep).
163                if uuid == SERVICE_SIGNATURE_CHAR && !is_protocol_info {
164                    continue;
165                }
166                chars.insert(uuid, ch);
167            }
168            shape.push(ServiceShape {
169                uuid: svc.uuid().to_string(),
170                char_uuids,
171                service_iid,
172            });
173        }
174        Ok((chars, shape))
175    }
176
177    /// Re-establish the link and rebuild the characteristic handle map, advancing
178    /// the link [`generation`](Self::generation). The UUID structure
179    /// ([`shape`](Self::shape)) is unchanged.
180    async fn reconnect(&self) -> Result<()> {
181        self.generation.fetch_add(1, Ordering::SeqCst);
182        // Own the radio for the whole teardown + connect: on macOS
183        // CoreBluetooth a connect (or disconnect/wait_available) cannot
184        // complete while a scan is running. The guard resumes the scan when
185        // dropped — on success and on every error path alike.
186        let _scan_pause = self.scan_gate.pause().await;
187        let _ = tokio::time::timeout(TEARDOWN_TIMEOUT, async {
188            let _ = self.adapter.disconnect_device(&self.device).await;
189            let _ = self.adapter.wait_available().await;
190        })
191        .await;
192        // Bound the connect + service discovery: a connect attempted while a scan
193        // is running can wedge on macOS, so a timeout surfaces as a recoverable
194        // disconnect that the per-operation backstop retries rather than hanging.
195        let establish = async {
196            self.adapter
197                .connect_device(&self.device)
198                .await
199                .map_err(be)?;
200            Self::discover(&self.device).await
201        };
202        let (fresh, _shape) = tokio::time::timeout(CONNECT_TIMEOUT, establish)
203            .await
204            .map_err(|_| BleError::Disconnected)??;
205        *self.chars.lock().await = fresh;
206        Ok(())
207    }
208
209    /// Reconnect for one in-flight operation, giving up once a single operation
210    /// has forced [`MAX_OP_RECONNECTS`] reconnects without making progress (the
211    /// link will not stay up long enough to complete it). `attempts` is the
212    /// per-operation reconnect count, owned by the caller's retry loop — it does
213    /// not bound the connection's lifetime.
214    async fn reconnect_bounded(&self, attempts: &mut u32) -> Result<()> {
215        *attempts += 1;
216        if *attempts > MAX_OP_RECONNECTS {
217            return Err(BleError::Disconnected);
218        }
219        self.reconnect().await
220    }
221
222    /// Look up the current handle for a characteristic UUID.
223    async fn handle(&self, char_uuid: &str) -> Result<Characteristic> {
224        self.chars
225            .lock()
226            .await
227            .get(&char_uuid.to_ascii_lowercase())
228            .cloned()
229            .ok_or(BleError::MalformedPdu("gatt characteristic not found"))
230    }
231
232    /// Read a characteristic's HAP instance-id descriptor, reconnecting on drop.
233    async fn read_iid(&self, char_uuid: &str) -> Result<Option<u16>> {
234        let mut attempts = 0;
235        loop {
236            let ch = self.handle(char_uuid).await?;
237            let attempt = async {
238                let descriptors = ch.discover_descriptors().await.map_err(be)?;
239                let Some(desc) = descriptors.iter().find(|d| {
240                    d.uuid()
241                        .to_string()
242                        .eq_ignore_ascii_case(HAP_INSTANCE_ID_DESC)
243                }) else {
244                    return Ok(None);
245                };
246                Ok(u16_le(&desc.read().await.map_err(be)?))
247            }
248            .await;
249            match attempt {
250                Ok(v) => return Ok(v),
251                Err(ref e) if is_disconnect(e) => self.reconnect_bounded(&mut attempts).await?,
252                Err(e) => return Err(e),
253            }
254        }
255    }
256}
257
258#[async_trait]
259impl GattConnection for BluestConnection {
260    async fn instance_id(&self, char_uuid: &str) -> Result<u16> {
261        self.read_iid(char_uuid)
262            .await?
263            .ok_or(BleError::MalformedPdu("no instance id descriptor"))
264    }
265
266    async fn max_write(&self) -> usize {
267        // The MTU is connection-wide, so any characteristic's max write works.
268        let ch = self.chars.lock().await.values().next().cloned();
269        ch.and_then(|c| c.max_write_len().ok())
270            .map_or(crate::gatt::DEFAULT_FRAGMENT_SIZE, |n| n.clamp(20, 512))
271    }
272
273    async fn generation(&self) -> u64 {
274        self.generation.load(Ordering::SeqCst)
275    }
276
277    async fn write(&self, char_uuid: &str, value: &[u8]) -> Result<()> {
278        let mut attempts = 0;
279        loop {
280            let ch = self.handle(char_uuid).await?;
281            match ch.write(value).await.map_err(be) {
282                Ok(()) => return Ok(()),
283                Err(ref e) if is_disconnect(e) => self.reconnect_bounded(&mut attempts).await?,
284                Err(e) => return Err(e),
285            }
286        }
287    }
288
289    async fn read(&self, char_uuid: &str) -> Result<Vec<u8>> {
290        let mut attempts = 0;
291        loop {
292            let ch = self.handle(char_uuid).await?;
293            match ch.read().await.map_err(be) {
294                Ok(v) => return Ok(v),
295                Err(ref e) if is_disconnect(e) => self.reconnect_bounded(&mut attempts).await?,
296                Err(e) => return Err(e),
297            }
298        }
299    }
300
301    // Connected GATT notify is BEST-EFFORT: the spawned task ends when the
302    // notification stream ends (a link drop). It is deliberately NOT re-armed and
303    // does NOT reconnect — a sleepy accessory intentionally drops idle links, so
304    // auto-reconnecting here causes a reconnect storm (validated on hardware).
305    // Durable events come from the advertisement channels (broadcast +
306    // disconnected-event poll); the session re-verifies lazily on the next read.
307    async fn subscribe(&self, char_uuid: &str) -> Result<mpsc::Receiver<Vec<u8>>> {
308        let ch = self.handle(char_uuid).await?;
309        let (tx, rx) = mpsc::channel(16);
310        tokio::spawn(async move {
311            use tokio_stream::StreamExt as _;
312            if let Ok(mut stream) = ch.notify().await {
313                while let Some(item) = stream.next().await {
314                    let Ok(v) = item else { break };
315                    if tx.send(v).await.is_err() {
316                        break;
317                    }
318                }
319            }
320        });
321        Ok(rx)
322    }
323
324    async fn enumerate(&self) -> Result<Vec<GattService>> {
325        let mut services = Vec::new();
326        for svc in &self.shape {
327            let is_protocol_info = svc.uuid.eq_ignore_ascii_case(PROTOCOL_INFO_SERVICE);
328            let mut characteristics = Vec::new();
329            for char_uuid in &svc.char_uuids {
330                // The Service-Instance-ID characteristic is not a HAP
331                // characteristic; its value would need a paired read.
332                if char_uuid.eq_ignore_ascii_case(HAP_SERVICE_ID_CHAR) {
333                    continue;
334                }
335                // The Service-Signature char is a service-level signature that
336                // shares one UUID across every service. Keep only the
337                // Protocol-Information service's so its instance id is
338                // discoverable (the generate-broadcast-key request target);
339                // dropping it everywhere else avoids duplicate iids. `build_db`
340                // then skips it so it never becomes a model characteristic.
341                if char_uuid.eq_ignore_ascii_case(SERVICE_SIGNATURE_CHAR) && !is_protocol_info {
342                    continue;
343                }
344                // Per-characteristic resilient instance-id read: resumes the
345                // sweep across the device's periodic disconnects.
346                if let Some(iid) = self.read_iid(char_uuid).await? {
347                    characteristics.push(GattCharacteristic {
348                        uuid: char_uuid.clone(),
349                        iid,
350                    });
351                }
352            }
353            services.push(GattService {
354                uuid: svc.uuid.clone(),
355                iid: svc.service_iid,
356                characteristics,
357            });
358        }
359        Ok(services)
360    }
361
362    async fn disconnect(&self) {
363        // Pause the scan for the teardown: on macOS a disconnect cannot
364        // complete while a scan is running.
365        let _scan_pause = self.scan_gate.pause().await;
366        let _ = tokio::time::timeout(
367            TEARDOWN_TIMEOUT,
368            self.adapter.disconnect_device(&self.device),
369        )
370        .await;
371    }
372}
373
374/// Apple's Bluetooth company identifier; HAP advertisements live under it.
375const APPLE_COMPANY_ID: u16 = 0x004C;
376
377#[async_trait]
378impl AdvertSource for BluestConnection {
379    /// Stream Apple HAP advertisements by running a continuous adapter scan.
380    ///
381    /// Spawns a background task that feeds every Apple (company id `0x004C`)
382    /// manufacturer-data frame into the returned channel. Forwarding is
383    /// best-effort: frames are dropped when the receiver falls behind, not
384    /// queued unboundedly, to ensure the task never blocks on backpressure
385    /// and can always respond to a pause request. While a connect owns the
386    /// radio (the [`ScanGate`] is paused) the task drops its scan stream and
387    /// restarts it on resume. The task stops for good when the receiver is
388    /// dropped or the adapter's scan stream ends.
389    ///
390    /// Intended for a single active watcher per connection: concurrent scan tasks
391    /// would share one scanning flag and corrupt the pause-ack protocol.
392    ///
393    /// # Errors
394    /// Returns [`crate::error::BleError`] on adapter/scan failures.
395    async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
396        let adapter = self.adapter.clone();
397        let gate = self.scan_gate.clone();
398        let (tx, rx) = mpsc::channel(32);
399        tokio::spawn(async move {
400            use tokio_stream::StreamExt as _;
401            let mut pause = gate.pause_watch();
402            loop {
403                // Hold off while a connect owns the radio.
404                while *pause.borrow_and_update() {
405                    if pause.changed().await.is_err() {
406                        return;
407                    }
408                }
409                let Ok(mut scan) = adapter.scan(&[]).await else {
410                    tracing::warn!("hap-ble advert scan failed to start");
411                    return;
412                };
413                tracing::debug!("hap-ble advert scan started");
414                gate.set_scanning(true);
415                let stopped_for_pause = loop {
416                    tokio::select! {
417                        changed = pause.changed() => {
418                            match changed {
419                                Ok(()) if *pause.borrow_and_update() => break true,
420                                Ok(()) => {}
421                                Err(_) => break false,
422                            }
423                        }
424                        adv = scan.next() => {
425                            let Some(adv) = adv else { break false };
426                            let Some(md) = adv.adv_data.manufacturer_data else {
427                                continue;
428                            };
429                            if md.company_id != APPLE_COMPANY_ID {
430                                continue;
431                            }
432                            // Every Apple (0x004C) frame the backend delivers. On
433                            // BlueZ this is the key diagnostic: if HAP 0x06 adverts
434                            // (first byte 0x06) don't appear here on each wave, the
435                            // backend is coalescing repeated adverts.
436                            tracing::trace!(
437                                len = md.data.len(),
438                                first = ?md.data.first(),
439                                "apple manufacturer advert from scan"
440                            );
441                            // Non-blocking send: a stalled consumer must not
442                            // wedge this task inside an await where it cannot
443                            // see a pause request. Adverts are a lossy,
444                            // repeating medium — dropping a frame under
445                            // backpressure is safe; holding the radio is not.
446                            match tx.try_send(RawAdvert {
447                                manufacturer_data: md.data,
448                            }) {
449                                Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
450                                Err(mpsc::error::TrySendError::Closed(_)) => {
451                                    break false; // receiver dropped — stop scanning
452                                }
453                            }
454                        }
455                    }
456                };
457                drop(scan);
458                gate.set_scanning(false);
459                if !stopped_for_pause {
460                    return;
461                }
462            }
463        });
464        Ok(rx)
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    /// A slept accessory's stale handle surfaces as bluest `NotFound` on
473    /// macOS; it must classify as a recoverable disconnect so the supervisor
474    /// reconnects (safe only because reconnect pauses the scan and bounds the
475    /// connect).
476    #[test]
477    fn not_found_maps_to_disconnected() {
478        let e = bluest::Error::from(ErrorKind::NotFound);
479        assert!(matches!(be(e), BleError::Disconnected));
480    }
481
482    /// Linux/BlueZ collapses "device not connected" into `ErrorKind::Other`
483    /// (bluer `Failed`), so the typed match misses it. The message-recovery
484    /// path must still classify it as a disconnect so the catch-up poll's
485    /// reconnect fires (the macOS `NotConnected` kind maps directly). This is
486    /// the root cause of the sleepy poll failing on the Pi with "Not connected".
487    #[test]
488    fn bluez_not_connected_message_maps_to_disconnected() {
489        assert!(matches!(
490            classify(
491                ErrorKind::Other,
492                "Bluetooth operation failed: Not connected"
493            ),
494            BleError::Disconnected
495        ));
496        // A genuine unrelated error stays a Backend error (no spurious reconnect).
497        assert!(matches!(
498            classify(
499                ErrorKind::Other,
500                "characteristic write failed: invalid value"
501            ),
502            BleError::Backend(_)
503        ));
504        // The typed disconnect kinds still classify (regression guard).
505        assert!(matches!(
506            classify(ErrorKind::NotConnected, ""),
507            BleError::Disconnected
508        ));
509        assert!(matches!(
510            classify(ErrorKind::NotFound, ""),
511            BleError::Disconnected
512        ));
513    }
514}