hap_ble/gatt.rs
1//! The GATT I/O seam. `GattConnection` is the boundary the rest of the crate is
2//! written against; `MockGatt` drives it in CI, `BluestConnection` (see bluest_gatt) on hardware.
3//!
4//! The `MockGatt` type is a testing seam — exempt from semver guarantees.
5
6use crate::error::Result;
7use async_trait::async_trait;
8use tokio::sync::mpsc;
9
10/// The HAP Characteristic-Instance-ID GATT descriptor. Each HAP characteristic
11/// carries one; its value is the characteristic's 16-bit instance id (LE), which
12/// HAP-BLE PDUs address by.
13pub(crate) const HAP_INSTANCE_ID_DESC: &str = "dc46f0fe-81d2-4616-b5d9-6abdd796939a";
14
15/// The HAP Service-Instance-ID characteristic (read-only, no descriptor) that
16/// appears in every HAP service; its value is the service's 16-bit instance id.
17pub(crate) const HAP_SERVICE_ID_CHAR: &str = "e604e95d-a759-4817-87d3-aa005083a0d1";
18
19/// Read a 16-bit little-endian value from the first two bytes, if present.
20pub(crate) fn u16_le(v: &[u8]) -> Option<u16> {
21 match v {
22 [lo, hi, ..] => Some(u16::from_le_bytes([*lo, *hi])),
23 _ => None,
24 }
25}
26
27/// One GATT characteristic discovered on the accessory.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct GattCharacteristic {
30 /// The 128-bit characteristic UUID (canonical 36-char string).
31 pub uuid: String,
32 /// The HAP characteristic instance id (from its Instance-ID descriptor).
33 pub iid: u16,
34}
35
36/// One GATT service and its characteristics.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct GattService {
39 /// The 128-bit service UUID (canonical 36-char string).
40 pub uuid: String,
41 /// The HAP service instance id.
42 pub iid: u16,
43 /// Characteristics under this service.
44 pub characteristics: Vec<GattCharacteristic>,
45}
46
47/// The transport seam: read/write/subscribe a characteristic and enumerate the
48/// GATT database. One real impl (`bluest`), one mock (tests).
49#[async_trait]
50pub trait GattConnection: Send + Sync {
51 /// Write a value to a characteristic identified by its UUID.
52 async fn write(&self, char_uuid: &str, value: &[u8]) -> Result<()>;
53 /// Read a characteristic's current value by UUID.
54 async fn read(&self, char_uuid: &str) -> Result<Vec<u8>>;
55 /// Subscribe to notifications on a characteristic; the receiver yields raw
56 /// notification payloads.
57 async fn subscribe(&self, char_uuid: &str) -> Result<mpsc::Receiver<Vec<u8>>>;
58 /// Read one characteristic's HAP instance id (its Instance-ID descriptor)
59 /// without walking the whole tree — used to address the pairing
60 /// characteristics before the (slow) full database sweep.
61 async fn instance_id(&self, char_uuid: &str) -> Result<u16>;
62 /// Enumerate the accessory's services and characteristics (with iids).
63 async fn enumerate(&self) -> Result<Vec<GattService>>;
64 /// The maximum bytes that fit in a single GATT write — the HAP-BLE PDU
65 /// fragment size. Backends that can't determine the negotiated MTU return a
66 /// conservative default.
67 async fn max_write(&self) -> usize {
68 DEFAULT_FRAGMENT_SIZE
69 }
70 /// A monotonically increasing link-generation counter that advances on every
71 /// reconnect. A secure session minted at generation *g* is invalidated when
72 /// the accessory drops the link (the count moves past *g*), so the holder
73 /// must re-run Pair Verify before its next encrypted operation. Backends
74 /// without a reconnect supervisor never invalidate sessions and return 0.
75 async fn generation(&self) -> u64 {
76 0
77 }
78}
79
80/// A raw advertisement observed by a backend scanner: the Apple (0x004C)
81/// manufacturer-data bytes. Parsed into a [`crate::advert::HapAdvert`] by callers.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct RawAdvert {
84 /// Apple manufacturer-data payload (the bytes after the 0x004C company id).
85 pub manufacturer_data: Vec<u8>,
86}
87
88/// A source of continuous BLE advertisements, used post-pairing to receive
89/// sleepy-device events with no active connection. Separate from
90/// [`GattConnection`] (the connected I/O seam) so each has one responsibility.
91/// Backends without a scanner return an immediately-closed receiver.
92#[async_trait]
93pub trait AdvertSource: Send + Sync {
94 /// Stream Apple HAP advertisements as they arrive.
95 ///
96 /// # Errors
97 /// Returns [`crate::error::BleError`] on backend scanner failures.
98 async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
99 let (_tx, rx) = mpsc::channel(1);
100 Ok(rx)
101 }
102}
103
104/// Conservative HAP-BLE fragment size when the negotiated ATT MTU is unknown;
105/// fits any MTU >= 183.
106pub(crate) const DEFAULT_FRAGMENT_SIZE: usize = 180;
107
108/// An in-memory `GattConnection` for tests. Reads return the last written value
109/// per characteristic; `subscribe` returns a channel whose `Sender` is exposed
110/// via [`MockGatt::notifier`] so tests can push events; `enumerate` returns a
111/// seeded service list. Optionally, per-characteristic canned read responses can
112/// be queued with [`MockGatt::queue_read`] (FIFO) to script request/response.
113#[cfg(any(test, feature = "test-support"))]
114pub struct MockGatt {
115 values: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
116 queued:
117 std::sync::Mutex<std::collections::HashMap<String, std::collections::VecDeque<Vec<u8>>>>,
118 services: std::sync::Mutex<Vec<GattService>>,
119 senders: std::sync::Mutex<std::collections::HashMap<String, mpsc::Sender<Vec<u8>>>>,
120 generation: std::sync::atomic::AtomicU64,
121 advert_tx: mpsc::Sender<RawAdvert>,
122 advert_rx: std::sync::Mutex<Option<mpsc::Receiver<RawAdvert>>>,
123 /// UUID -> gate that the next `read` of that characteristic awaits.
124 blocked:
125 std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<tokio::sync::Notify>>>,
126}
127
128#[cfg(any(test, feature = "test-support"))]
129impl Default for MockGatt {
130 fn default() -> Self {
131 let (advert_tx, advert_rx) = mpsc::channel(16);
132 Self {
133 values: std::sync::Mutex::new(std::collections::HashMap::new()),
134 queued: std::sync::Mutex::new(std::collections::HashMap::new()),
135 services: std::sync::Mutex::new(Vec::new()),
136 senders: std::sync::Mutex::new(std::collections::HashMap::new()),
137 generation: std::sync::atomic::AtomicU64::new(0),
138 advert_tx,
139 advert_rx: std::sync::Mutex::new(Some(advert_rx)),
140 blocked: std::sync::Mutex::new(std::collections::HashMap::new()),
141 }
142 }
143}
144
145#[cfg(any(test, feature = "test-support"))]
146#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
147impl MockGatt {
148 /// Create a new empty mock GATT device.
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 /// Seed this mock with a service list.
154 ///
155 /// # Panics
156 ///
157 /// Panics if the internal service list lock is poisoned (should never happen
158 /// in a single-threaded test).
159 #[must_use]
160 pub fn with_services(self, services: Vec<GattService>) -> Self {
161 *self.services.lock().unwrap() = services;
162 self
163 }
164
165 /// Queue a canned response that the next `read` of `char_uuid` returns
166 /// instead of the last-written value.
167 ///
168 /// # Panics
169 ///
170 /// Panics if the internal queued reads lock is poisoned (should never happen
171 /// in a single-threaded test).
172 #[allow(dead_code)] // used by later tasks (PDU transport / pairing / db)
173 pub fn queue_read(&self, char_uuid: &str, value: Vec<u8>) {
174 self.queued
175 .lock()
176 .unwrap()
177 .entry(char_uuid.to_string())
178 .or_default()
179 .push_back(value);
180 }
181
182 /// A sender that pushes a notification to subscribers of `char_uuid`.
183 ///
184 /// # Panics
185 ///
186 /// Panics if the internal senders lock is poisoned (should never happen in a
187 /// single-threaded test).
188 #[allow(dead_code)] // used by later tasks (events)
189 pub fn notifier(&self, char_uuid: &str) -> Option<mpsc::Sender<Vec<u8>>> {
190 self.senders.lock().unwrap().get(char_uuid).cloned()
191 }
192
193 /// Advance the link generation, simulating a reconnect that invalidated any
194 /// secure session minted at an earlier generation.
195 #[allow(dead_code)] // used by the reconnect tests
196 pub fn bump_generation(&self) {
197 // No panics: the generation counter does not use locks.
198 self.generation
199 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
200 }
201
202 /// A sender that pushes a raw advert to a `watch_adverts` subscriber.
203 #[allow(dead_code)] // used by later tasks (broadcast / disconnected-event poll)
204 pub fn advert_sender(&self) -> mpsc::Sender<RawAdvert> {
205 // No panics: we simply clone a channel sender.
206 self.advert_tx.clone()
207 }
208
209 /// Make the next `read` of `char_uuid` await the returned `Notify` before
210 /// serving its (queued or last-written) value — simulates a slow read
211 /// (e.g. a reconnect-read) so tests can assert other work is not blocked.
212 ///
213 /// # Panics
214 ///
215 /// Panics if the internal blocked reads lock is poisoned (should never happen
216 /// in a single-threaded test).
217 #[allow(dead_code)] // used by the poll-off-advert-task tests
218 pub fn block_next_read(&self, char_uuid: &str) -> std::sync::Arc<tokio::sync::Notify> {
219 let gate = std::sync::Arc::new(tokio::sync::Notify::new());
220 self.blocked
221 .lock()
222 .unwrap()
223 .insert(char_uuid.to_string(), gate.clone());
224 gate
225 }
226}
227
228#[cfg(any(test, feature = "test-support"))]
229#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
230#[async_trait]
231impl AdvertSource for MockGatt {
232 async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
233 // Hand out the single receiver once; a closed one thereafter.
234 self.advert_rx.lock().unwrap().take().map_or_else(
235 || {
236 let (_tx, rx) = mpsc::channel(1);
237 Ok(rx)
238 },
239 Ok,
240 )
241 }
242}
243
244#[cfg(any(test, feature = "test-support"))]
245#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
246#[async_trait]
247impl GattConnection for MockGatt {
248 async fn instance_id(&self, char_uuid: &str) -> Result<u16> {
249 self.services
250 .lock()
251 .unwrap()
252 .iter()
253 .flat_map(|s| &s.characteristics)
254 .find(|c| c.uuid.eq_ignore_ascii_case(char_uuid))
255 .map(|c| c.iid)
256 .ok_or(crate::error::BleError::CharacteristicNotFound { aid: 0, iid: 0 })
257 }
258
259 async fn write(&self, char_uuid: &str, value: &[u8]) -> Result<()> {
260 self.values
261 .lock()
262 .unwrap()
263 .insert(char_uuid.to_string(), value.to_vec());
264 Ok(())
265 }
266
267 async fn read(&self, char_uuid: &str) -> Result<Vec<u8>> {
268 let gate = self.blocked.lock().unwrap().remove(char_uuid);
269 if let Some(gate) = gate {
270 gate.notified().await;
271 }
272 if let Some(q) = self.queued.lock().unwrap().get_mut(char_uuid) {
273 if let Some(v) = q.pop_front() {
274 return Ok(v);
275 }
276 }
277 Ok(self
278 .values
279 .lock()
280 .unwrap()
281 .get(char_uuid)
282 .cloned()
283 .unwrap_or_default())
284 }
285
286 async fn subscribe(&self, char_uuid: &str) -> Result<mpsc::Receiver<Vec<u8>>> {
287 let (tx, rx) = mpsc::channel(8);
288 self.senders
289 .lock()
290 .unwrap()
291 .insert(char_uuid.to_string(), tx);
292 Ok(rx)
293 }
294
295 async fn enumerate(&self) -> Result<Vec<GattService>> {
296 Ok(self.services.lock().unwrap().clone())
297 }
298
299 async fn generation(&self) -> u64 {
300 self.generation.load(std::sync::atomic::Ordering::SeqCst)
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[tokio::test]
309 #[allow(clippy::unwrap_used)]
310 async fn mock_echoes_written_value_on_read() {
311 let gatt = MockGatt::new();
312 gatt.write("char-a", &[1, 2, 3]).await.unwrap();
313 assert_eq!(gatt.read("char-a").await.unwrap(), vec![1, 2, 3]);
314 }
315
316 #[tokio::test]
317 #[allow(clippy::unwrap_used)]
318 async fn mock_enumerate_returns_seeded_db() {
319 let svc = GattService {
320 uuid: "svc".into(),
321 iid: 1,
322 characteristics: vec![GattCharacteristic {
323 uuid: "c".into(),
324 iid: 2,
325 }],
326 };
327 let gatt = MockGatt::new().with_services(vec![svc.clone()]);
328 assert_eq!(gatt.enumerate().await.unwrap(), vec![svc]);
329 }
330}