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