Skip to main content

hap_ble/
discovery.rs

1//! BLE discovery: parse the HAP manufacturer advertisement and scan for
2//! accessories.
3
4use crate::bluest_gatt::{be, BluestConnection};
5use crate::error::{BleError, Result};
6use bluest::Adapter;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio_stream::StreamExt as _;
10
11/// Apple's Bluetooth company identifier; HAP advertisements live under it.
12const APPLE_COMPANY_ID: u16 = 0x004C;
13
14/// Scan for HAP accessories advertising over BLE for `timeout`.
15///
16/// # Errors
17/// Returns [`BleError::Backend`] on adapter/scan failures.
18pub async fn scan(timeout: Duration) -> Result<Vec<DiscoveredBleAccessory>> {
19    let adapter = Adapter::default()
20        .await
21        .ok_or(BleError::AccessoryNotFound)?;
22    adapter.wait_available().await.map_err(be)?;
23    let mut stream = adapter.scan(&[]).await.map_err(be)?;
24
25    let mut found = Vec::new();
26    let mut seen = std::collections::HashSet::new();
27    let deadline = tokio::time::Instant::now() + timeout;
28    while let Ok(Some(adv)) = tokio::time::timeout_at(deadline, stream.next()).await {
29        let Some(mfg) = adv.adv_data.manufacturer_data else {
30            continue;
31        };
32        if mfg.company_id != APPLE_COMPANY_ID {
33            continue;
34        }
35        if let Some(acc) = parse_hap_advert(&mfg.data, adv.device.id().to_string()) {
36            if seen.insert(acc.peripheral_id.clone()) {
37                found.push(acc);
38            }
39        }
40    }
41    Ok(found)
42}
43
44/// Scan for HAP accessories incrementally, yielding each one as it is heard.
45///
46/// Like [`scan`], but instead of blocking for the full `timeout` window it
47/// returns a channel receiver immediately; each accessory is sent as soon as
48/// its HAP advertisement is parsed, deduplicated by peripheral id within the
49/// window. The channel closes when the window elapses — or as soon as the
50/// receiver is dropped, which tears the scan down early: the background task
51/// drops its scan stream (stopping the radio scan) and then the adapter
52/// itself, so no central or (on Linux) D-Bus session outlives the call.
53///
54/// # Errors
55/// Returns [`BleError::AccessoryNotFound`] if no BLE adapter is present and
56/// [`BleError::Backend`] on adapter/scan-start failures. Failures after the
57/// scan has started end the stream (the channel closes) rather than surfacing
58/// an error.
59pub async fn scan_stream(
60    timeout: Duration,
61) -> Result<tokio::sync::mpsc::Receiver<DiscoveredBleAccessory>> {
62    let adapter = Adapter::default()
63        .await
64        .ok_or(BleError::AccessoryNotFound)?;
65    adapter.wait_available().await.map_err(be)?;
66
67    let (tx, rx) = tokio::sync::mpsc::channel::<DiscoveredBleAccessory>(16);
68    // `Adapter::scan` borrows the adapter, so the scan stream must be created
69    // inside the task that owns it; a oneshot reports scan-start success back
70    // so this function's error behavior matches `scan`.
71    let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<Result<()>>();
72    tokio::spawn(async move {
73        let mut stream = match adapter.scan(&[]).await {
74            Ok(s) => {
75                let _ = ready_tx.send(Ok(()));
76                s
77            }
78            Err(e) => {
79                let _ = ready_tx.send(Err(be(e)));
80                return;
81            }
82        };
83        let mut seen = std::collections::HashSet::new();
84        let deadline = tokio::time::Instant::now() + timeout;
85        loop {
86            tokio::select! {
87                // The consumer dropped its receiver: stop scanning early.
88                () = tx.closed() => break,
89                next = tokio::time::timeout_at(deadline, stream.next()) => {
90                    match next {
91                        Err(_) | Ok(None) => break,
92                        Ok(Some(adv)) => {
93                            let Some(mfg) = adv.adv_data.manufacturer_data else {
94                                continue;
95                            };
96                            if mfg.company_id != APPLE_COMPANY_ID {
97                                continue;
98                            }
99                            if let Some(acc) =
100                                parse_hap_advert(&mfg.data, adv.device.id().to_string())
101                            {
102                                if seen.insert(acc.peripheral_id.clone())
103                                    && tx.send(acc).await.is_err()
104                                {
105                                    break;
106                                }
107                            }
108                        }
109                    }
110                }
111            }
112        }
113        // Deterministic teardown: stop the radio scan, then release the
114        // central (on Linux this closes the bluer session's D-Bus connection).
115        drop(stream);
116        drop(adapter);
117    });
118    match ready_rx.await {
119        Ok(Ok(())) => Ok(rx),
120        Ok(Err(e)) => Err(e),
121        // The scan task died before reporting; treat as a backend failure.
122        Err(_recv) => Err(BleError::Backend("scan task exited early".into())),
123    }
124}
125
126/// Connect to a discovered accessory and return a resilient GATT link.
127///
128/// # Errors
129/// Returns [`BleError`] on connect/discovery failure.
130pub async fn connect_gatt(accessory: &DiscoveredBleAccessory) -> Result<Arc<BluestConnection>> {
131    let adapter = Adapter::default()
132        .await
133        .ok_or(BleError::AccessoryNotFound)?;
134    adapter.wait_available().await.map_err(be)?;
135    // A fresh adapter only knows peripherals it has itself scanned, so we scan
136    // here to locate the target (sleepy accessories advertise intermittently).
137    let mut stream = adapter.scan(&[]).await.map_err(be)?;
138    let mut device = None;
139    let deadline = tokio::time::Instant::now() + Duration::from_secs(40);
140    while let Ok(Some(adv)) = tokio::time::timeout_at(deadline, stream.next()).await {
141        if adv.device.id().to_string() == accessory.peripheral_id {
142            device = Some(adv.device);
143            break;
144        }
145    }
146    drop(stream);
147    let device = device.ok_or(BleError::AccessoryNotFound)?;
148    adapter.connect_device(&device).await.map_err(be)?;
149    Ok(Arc::new(BluestConnection::new(adapter, device).await?))
150}
151
152/// A HAP accessory found while scanning over BLE.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct DiscoveredBleAccessory {
155    /// The BLE peripheral identifier (platform-specific address/UUID string)
156    /// used to reconnect to this device.
157    pub peripheral_id: String,
158    /// The HAP device id (6-byte address, lowercase colon-separated hex).
159    pub device_id: String,
160    /// The accessory category identifier (ACID).
161    pub category: u16,
162    /// The HAP global state number (GSN) from the advertisement.
163    pub global_state_number: u16,
164    /// The configuration number (`c#`); a change means the DB changed.
165    pub config_number: u8,
166    /// Whether the accessory advertises as already paired.
167    pub paired: bool,
168    /// The accessory's setup hash from the advertisement (4 bytes at `[15..19]`),
169    /// if the advert is long enough to include it. Used to precisely match a
170    /// scanned QR to this accessory.
171    pub setup_hash: Option<[u8; 4]>,
172}
173
174/// Parse a HAP manufacturer-data payload (the bytes after the 0x004C company id)
175/// into a [`DiscoveredBleAccessory`]. Returns `None` if it is not a HAP advert.
176pub(crate) fn parse_hap_advert(
177    mfg: &[u8],
178    peripheral_id: String,
179) -> Option<DiscoveredBleAccessory> {
180    // Minimum length 15 for the base discovery payload (through compat version).
181    if mfg.len() < 15 {
182        return None;
183    }
184    let parsed = crate::advert::HapAdvert::parse(mfg)?;
185    let crate::advert::HapAdvert::Regular {
186        device_id,
187        gsn,
188        paired,
189    } = parsed
190    else {
191        return None;
192    };
193    let device_id_str = {
194        use std::fmt::Write as _;
195        device_id.iter().fold(String::new(), |mut s, b| {
196            if !s.is_empty() {
197                s.push(':');
198            }
199            let _ = write!(s, "{b:02x}");
200            s
201        })
202    };
203    let category = u16::from_le_bytes([mfg[9], mfg[10]]);
204    let config_number = mfg[13];
205    let setup_hash = if mfg.len() >= 19 {
206        Some([mfg[15], mfg[16], mfg[17], mfg[18]])
207    } else {
208        None
209    };
210    Some(DiscoveredBleAccessory {
211        peripheral_id,
212        device_id: device_id_str,
213        category,
214        global_state_number: gsn,
215        config_number,
216        paired,
217        setup_hash,
218    })
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    // A HAP manufacturer-data payload (Apple company id 0x004C). Layout:
226    // [0]=0x06 (HomeKit type), [1]=STL (subtype<<5 | length=17), [2]=status flags,
227    // [3..9]=device id (6 bytes), [9..11]=ACID category (u16 LE),
228    // [11..13]=GSN (u16 LE), [13]=config number, [14]=compatible version,
229    // [15..19]=setup hash (4 bytes).
230    fn sample_mfg() -> Vec<u8> {
231        let mut v = vec![0x06, (1 << 5) | 0x11, 0x01];
232        v.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); // device id
233        v.extend_from_slice(&5u16.to_le_bytes()); // category 5
234        v.extend_from_slice(&7u16.to_le_bytes()); // GSN 7
235        v.push(2); // config number
236        v.push(2); // compatible version
237        v.extend_from_slice(&[0x12, 0x34]); // setup hash
238        v
239    }
240
241    #[test]
242    #[allow(clippy::unwrap_used)]
243    fn parses_hap_manufacturer_data() {
244        let d = parse_hap_advert(&sample_mfg(), "11:22:33:44:55:66".into()).unwrap();
245        assert_eq!(d.device_id, "aa:bb:cc:dd:ee:ff");
246        assert_eq!(d.category, 5);
247        assert_eq!(d.global_state_number, 7);
248        assert_eq!(d.config_number, 2);
249        assert_eq!(d.peripheral_id, "11:22:33:44:55:66");
250        // status flag bit0 set in our sample = the "not paired" advertisement.
251        assert!(!d.paired);
252        // sample_mfg() is 17 bytes, so setup_hash is None.
253        assert_eq!(d.setup_hash, None);
254    }
255
256    #[test]
257    fn rejects_non_hap_advert() {
258        assert!(parse_hap_advert(&[0x01, 0x02], "x".into()).is_none());
259    }
260
261    #[test]
262    #[allow(clippy::unwrap_used)]
263    fn parses_setup_hash_when_present() {
264        // 0x06 advert: type, STL, SF, device_id[6], ACID[2], GSN[2], config,
265        // compat, setup_hash[4]  → 19 bytes.
266        let mfg = [
267            0x06, 0x31, 0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // type/stl/sf/devid
268            0x0A, 0x00, // ACID (category 10)
269            0x05, 0x00, // GSN
270            0x02, // config number
271            0x02, // compatible version
272            0x5c, 0x8a, 0x27, 0x40, // setup hash
273        ];
274        let d = parse_hap_advert(&mfg, "periph-1".into()).unwrap();
275        assert_eq!(d.setup_hash, Some([0x5c, 0x8a, 0x27, 0x40]));
276        assert_eq!(d.device_id, "aa:bb:cc:dd:ee:ff"); // NOTE: parser lowercases
277    }
278
279    #[test]
280    #[allow(clippy::unwrap_used)]
281    fn setup_hash_absent_on_short_advert() {
282        // 17-byte advert (no 4-byte hash) → setup_hash None, still parses.
283        let mfg = [
284            0x06, 0x31, 0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x0A, 0x00, 0x05, 0x00, 0x02,
285            0x02, 0x12, 0x34,
286        ];
287        let d = parse_hap_advert(&mfg, "periph-1".into()).unwrap();
288        assert_eq!(d.setup_hash, None);
289    }
290}