1use 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
11const APPLE_COMPANY_ID: u16 = 0x004C;
13
14pub 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
44pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct DiscoveredBleAccessory {
73 pub peripheral_id: String,
76 pub device_id: String,
78 pub category: u16,
80 pub global_state_number: u16,
82 pub config_number: u8,
84 pub paired: bool,
86 pub setup_hash: Option<[u8; 4]>,
90}
91
92pub(crate) fn parse_hap_advert(
95 mfg: &[u8],
96 peripheral_id: String,
97) -> Option<DiscoveredBleAccessory> {
98 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 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]); v.extend_from_slice(&5u16.to_le_bytes()); v.extend_from_slice(&7u16.to_le_bytes()); v.push(2); v.push(2); v.extend_from_slice(&[0x12, 0x34]); 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 assert!(!d.paired);
170 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 let mfg = [
185 0x06, 0x31, 0x01, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x0A, 0x00, 0x05, 0x00, 0x02, 0x02, 0x5c, 0x8a, 0x27, 0x40, ];
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"); }
196
197 #[test]
198 #[allow(clippy::unwrap_used)]
199 fn setup_hash_absent_on_short_advert() {
200 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}