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}
87
88/// Parse a HAP manufacturer-data payload (the bytes after the 0x004C company id)
89/// into a [`DiscoveredBleAccessory`]. Returns `None` if it is not a HAP advert.
90pub(crate) fn parse_hap_advert(
91    mfg: &[u8],
92    peripheral_id: String,
93) -> Option<DiscoveredBleAccessory> {
94    // Minimum length 17 for the full discovery payload (type 0x06 only).
95    if mfg.len() < 17 {
96        return None;
97    }
98    let parsed = crate::advert::HapAdvert::parse(mfg)?;
99    let crate::advert::HapAdvert::Regular {
100        device_id,
101        gsn,
102        paired,
103    } = parsed
104    else {
105        return None;
106    };
107    let device_id_str = {
108        use std::fmt::Write as _;
109        device_id.iter().fold(String::new(), |mut s, b| {
110            if !s.is_empty() {
111                s.push(':');
112            }
113            let _ = write!(s, "{b:02x}");
114            s
115        })
116    };
117    let category = u16::from_le_bytes([mfg[9], mfg[10]]);
118    let config_number = mfg[13];
119    Some(DiscoveredBleAccessory {
120        peripheral_id,
121        device_id: device_id_str,
122        category,
123        global_state_number: gsn,
124        config_number,
125        paired,
126    })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    // A HAP manufacturer-data payload (Apple company id 0x004C). Layout:
134    // [0]=0x06 (HomeKit type), [1]=STL (subtype<<5 | length=17), [2]=status flags,
135    // [3..9]=device id (6 bytes), [9..11]=ACID category (u16 LE),
136    // [11..13]=GSN (u16 LE), [13]=config number, [14]=compatible version,
137    // [15..17]=setup hash.
138    fn sample_mfg() -> Vec<u8> {
139        let mut v = vec![0x06, (1 << 5) | 0x11, 0x01];
140        v.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); // device id
141        v.extend_from_slice(&5u16.to_le_bytes()); // category 5
142        v.extend_from_slice(&7u16.to_le_bytes()); // GSN 7
143        v.push(2); // config number
144        v.push(2); // compatible version
145        v.extend_from_slice(&[0x12, 0x34]); // setup hash
146        v
147    }
148
149    #[test]
150    #[allow(clippy::unwrap_used)]
151    fn parses_hap_manufacturer_data() {
152        let d = parse_hap_advert(&sample_mfg(), "11:22:33:44:55:66".into()).unwrap();
153        assert_eq!(d.device_id, "aa:bb:cc:dd:ee:ff");
154        assert_eq!(d.category, 5);
155        assert_eq!(d.global_state_number, 7);
156        assert_eq!(d.config_number, 2);
157        assert_eq!(d.peripheral_id, "11:22:33:44:55:66");
158        // status flag bit0 set in our sample = the "not paired" advertisement.
159        assert!(!d.paired);
160    }
161
162    #[test]
163    fn rejects_non_hap_advert() {
164        assert!(parse_hap_advert(&[0x01, 0x02], "x".into()).is_none());
165    }
166}