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/// Connect to a discovered accessory and return a resilient GATT link.
45///
46/// # Errors
47/// Returns [`BleError`] on connect/discovery failure.
48pub async fn connect_gatt(accessory: &DiscoveredBleAccessory) -> Result<Arc<BluestConnection>> {
49    let adapter = Adapter::default()
50        .await
51        .ok_or(BleError::AccessoryNotFound)?;
52    adapter.wait_available().await.map_err(be)?;
53    // A fresh adapter only knows peripherals it has itself scanned, so we scan
54    // here to locate the target (sleepy accessories advertise intermittently).
55    let mut stream = adapter.scan(&[]).await.map_err(be)?;
56    let mut device = None;
57    let deadline = tokio::time::Instant::now() + Duration::from_secs(40);
58    while let Ok(Some(adv)) = tokio::time::timeout_at(deadline, stream.next()).await {
59        if adv.device.id().to_string() == accessory.peripheral_id {
60            device = Some(adv.device);
61            break;
62        }
63    }
64    drop(stream);
65    let device = device.ok_or(BleError::AccessoryNotFound)?;
66    adapter.connect_device(&device).await.map_err(be)?;
67    Ok(Arc::new(BluestConnection::new(adapter, device).await?))
68}
69
70/// A HAP accessory found while scanning over BLE.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct DiscoveredBleAccessory {
73    /// The BLE peripheral identifier (platform-specific address/UUID string)
74    /// used to reconnect to this device.
75    pub peripheral_id: String,
76    /// The HAP device id (6-byte address, lowercase colon-separated hex).
77    pub device_id: String,
78    /// The accessory category identifier (ACID).
79    pub category: u16,
80    /// The HAP global state number (GSN) from the advertisement.
81    pub global_state_number: u16,
82    /// The configuration number (`c#`); a change means the DB changed.
83    pub config_number: u8,
84    /// Whether the accessory advertises as already paired.
85    pub paired: bool,
86    /// The accessory's setup hash from the advertisement (4 bytes at `[15..19]`),
87    /// if the advert is long enough to include it. Used to precisely match a
88    /// scanned QR to this accessory.
89    pub setup_hash: Option<[u8; 4]>,
90}
91
92/// Parse a HAP manufacturer-data payload (the bytes after the 0x004C company id)
93/// into a [`DiscoveredBleAccessory`]. Returns `None` if it is not a HAP advert.
94pub(crate) fn parse_hap_advert(
95    mfg: &[u8],
96    peripheral_id: String,
97) -> Option<DiscoveredBleAccessory> {
98    // Minimum length 15 for the base discovery payload (through compat version).
99    if mfg.len() < 15 {
100        return None;
101    }
102    let parsed = crate::advert::HapAdvert::parse(mfg)?;
103    let crate::advert::HapAdvert::Regular {
104        device_id,
105        gsn,
106        paired,
107    } = parsed
108    else {
109        return None;
110    };
111    let device_id_str = {
112        use std::fmt::Write as _;
113        device_id.iter().fold(String::new(), |mut s, b| {
114            if !s.is_empty() {
115                s.push(':');
116            }
117            let _ = write!(s, "{b:02x}");
118            s
119        })
120    };
121    let category = u16::from_le_bytes([mfg[9], mfg[10]]);
122    let config_number = mfg[13];
123    let setup_hash = if mfg.len() >= 19 {
124        Some([mfg[15], mfg[16], mfg[17], mfg[18]])
125    } else {
126        None
127    };
128    Some(DiscoveredBleAccessory {
129        peripheral_id,
130        device_id: device_id_str,
131        category,
132        global_state_number: gsn,
133        config_number,
134        paired,
135        setup_hash,
136    })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    // A HAP manufacturer-data payload (Apple company id 0x004C). Layout:
144    // [0]=0x06 (HomeKit type), [1]=STL (subtype<<5 | length=17), [2]=status flags,
145    // [3..9]=device id (6 bytes), [9..11]=ACID category (u16 LE),
146    // [11..13]=GSN (u16 LE), [13]=config number, [14]=compatible version,
147    // [15..19]=setup hash (4 bytes).
148    fn sample_mfg() -> Vec<u8> {
149        let mut v = vec![0x06, (1 << 5) | 0x11, 0x01];
150        v.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); // device id
151        v.extend_from_slice(&5u16.to_le_bytes()); // category 5
152        v.extend_from_slice(&7u16.to_le_bytes()); // GSN 7
153        v.push(2); // config number
154        v.push(2); // compatible version
155        v.extend_from_slice(&[0x12, 0x34]); // setup hash
156        v
157    }
158
159    #[test]
160    #[allow(clippy::unwrap_used)]
161    fn parses_hap_manufacturer_data() {
162        let d = parse_hap_advert(&sample_mfg(), "11:22:33:44:55:66".into()).unwrap();
163        assert_eq!(d.device_id, "aa:bb:cc:dd:ee:ff");
164        assert_eq!(d.category, 5);
165        assert_eq!(d.global_state_number, 7);
166        assert_eq!(d.config_number, 2);
167        assert_eq!(d.peripheral_id, "11:22:33:44:55:66");
168        // status flag bit0 set in our sample = the "not paired" advertisement.
169        assert!(!d.paired);
170        // sample_mfg() is 17 bytes, so setup_hash is None.
171        assert_eq!(d.setup_hash, None);
172    }
173
174    #[test]
175    fn rejects_non_hap_advert() {
176        assert!(parse_hap_advert(&[0x01, 0x02], "x".into()).is_none());
177    }
178
179    #[test]
180    #[allow(clippy::unwrap_used)]
181    fn parses_setup_hash_when_present() {
182        // 0x06 advert: type, STL, SF, device_id[6], ACID[2], GSN[2], config,
183        // compat, setup_hash[4]  → 19 bytes.
184        let mfg = [
185            0x06, 0x31, 0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, // type/stl/sf/devid
186            0x0A, 0x00, // ACID (category 10)
187            0x05, 0x00, // GSN
188            0x02, // config number
189            0x02, // compatible version
190            0x5c, 0x8a, 0x27, 0x40, // setup hash
191        ];
192        let d = parse_hap_advert(&mfg, "periph-1".into()).unwrap();
193        assert_eq!(d.setup_hash, Some([0x5c, 0x8a, 0x27, 0x40]));
194        assert_eq!(d.device_id, "aa:bb:cc:dd:ee:ff"); // NOTE: parser lowercases
195    }
196
197    #[test]
198    #[allow(clippy::unwrap_used)]
199    fn setup_hash_absent_on_short_advert() {
200        // 17-byte advert (no 4-byte hash) → setup_hash None, still parses.
201        let mfg = [
202            0x06, 0x31, 0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x0A, 0x00, 0x05, 0x00, 0x02,
203            0x02, 0x12, 0x34,
204        ];
205        let d = parse_hap_advert(&mfg, "periph-1".into()).unwrap();
206        assert_eq!(d.setup_hash, None);
207    }
208}