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 /// Release the active link. A sleepy HAP accessory only advertises and
79 /// emits encrypted broadcasts while disconnected, so a caller watching for
80 /// sleepy events must disconnect after setup. Default no-op for backends and
81 /// mocks with no live link.
82 async fn disconnect(&self) {}
83}
84
85/// A raw advertisement observed by a backend scanner: the Apple (0x004C)
86/// manufacturer-data bytes. Parsed into a [`crate::advert::HapAdvert`] by callers.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct RawAdvert {
89 /// Apple manufacturer-data payload (the bytes after the 0x004C company id).
90 pub manufacturer_data: Vec<u8>,
91}
92
93/// A source of continuous BLE advertisements, used post-pairing to receive
94/// sleepy-device events with no active connection. Separate from
95/// [`GattConnection`] (the connected I/O seam) so each has one responsibility.
96/// Backends without a scanner return an immediately-closed receiver.
97#[async_trait]
98pub trait AdvertSource: Send + Sync {
99 /// Stream Apple HAP advertisements as they arrive.
100 ///
101 /// # Errors
102 /// Returns [`crate::error::BleError`] on backend scanner failures.
103 async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
104 let (_tx, rx) = mpsc::channel(1);
105 Ok(rx)
106 }
107}
108
109/// Conservative HAP-BLE fragment size when the negotiated ATT MTU is unknown;
110/// fits any MTU >= 183.
111pub(crate) const DEFAULT_FRAGMENT_SIZE: usize = 180;
112
113/// An in-memory `GattConnection` for tests. Reads return the last written value
114/// per characteristic; `subscribe` returns a channel whose `Sender` is exposed
115/// via [`MockGatt::notifier`] so tests can push events; `enumerate` returns a
116/// seeded service list. Optionally, per-characteristic canned read responses can
117/// be queued with [`MockGatt::queue_read`] (FIFO) to script request/response.
118#[cfg(any(test, feature = "test-support"))]
119pub struct MockGatt {
120 values: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
121 queued:
122 std::sync::Mutex<std::collections::HashMap<String, std::collections::VecDeque<Vec<u8>>>>,
123 services: std::sync::Mutex<Vec<GattService>>,
124 senders: std::sync::Mutex<std::collections::HashMap<String, mpsc::Sender<Vec<u8>>>>,
125 generation: std::sync::atomic::AtomicU64,
126 advert_tx: mpsc::Sender<RawAdvert>,
127 advert_rx: std::sync::Mutex<Option<mpsc::Receiver<RawAdvert>>>,
128 /// UUID -> gate that the next `read` of that characteristic awaits.
129 blocked:
130 std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<tokio::sync::Notify>>>,
131}
132
133#[cfg(any(test, feature = "test-support"))]
134impl Default for MockGatt {
135 fn default() -> Self {
136 let (advert_tx, advert_rx) = mpsc::channel(16);
137 Self {
138 values: std::sync::Mutex::new(std::collections::HashMap::new()),
139 queued: std::sync::Mutex::new(std::collections::HashMap::new()),
140 services: std::sync::Mutex::new(Vec::new()),
141 senders: std::sync::Mutex::new(std::collections::HashMap::new()),
142 generation: std::sync::atomic::AtomicU64::new(0),
143 advert_tx,
144 advert_rx: std::sync::Mutex::new(Some(advert_rx)),
145 blocked: std::sync::Mutex::new(std::collections::HashMap::new()),
146 }
147 }
148}
149
150#[cfg(any(test, feature = "test-support"))]
151#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
152impl MockGatt {
153 /// Create a new empty mock GATT device.
154 pub fn new() -> Self {
155 Self::default()
156 }
157
158 /// Seed this mock with a service list.
159 ///
160 /// # Panics
161 ///
162 /// Panics if the internal service list lock is poisoned (should never happen
163 /// in a single-threaded test).
164 #[must_use]
165 pub fn with_services(self, services: Vec<GattService>) -> Self {
166 *self.services.lock().unwrap() = services;
167 self
168 }
169
170 /// Queue a canned response that the next `read` of `char_uuid` returns
171 /// instead of the last-written value.
172 ///
173 /// # Panics
174 ///
175 /// Panics if the internal queued reads lock is poisoned (should never happen
176 /// in a single-threaded test).
177 #[allow(dead_code)] // used by later tasks (PDU transport / pairing / db)
178 pub fn queue_read(&self, char_uuid: &str, value: Vec<u8>) {
179 self.queued
180 .lock()
181 .unwrap()
182 .entry(char_uuid.to_string())
183 .or_default()
184 .push_back(value);
185 }
186
187 /// A sender that pushes a notification to subscribers of `char_uuid`.
188 ///
189 /// # Panics
190 ///
191 /// Panics if the internal senders lock is poisoned (should never happen in a
192 /// single-threaded test).
193 #[allow(dead_code)] // used by later tasks (events)
194 pub fn notifier(&self, char_uuid: &str) -> Option<mpsc::Sender<Vec<u8>>> {
195 self.senders.lock().unwrap().get(char_uuid).cloned()
196 }
197
198 /// Advance the link generation, simulating a reconnect that invalidated any
199 /// secure session minted at an earlier generation.
200 #[allow(dead_code)] // used by the reconnect tests
201 pub fn bump_generation(&self) {
202 // No panics: the generation counter does not use locks.
203 self.generation
204 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
205 }
206
207 /// A sender that pushes a raw advert to a `watch_adverts` subscriber.
208 #[allow(dead_code)] // used by later tasks (broadcast / disconnected-event poll)
209 pub fn advert_sender(&self) -> mpsc::Sender<RawAdvert> {
210 // No panics: we simply clone a channel sender.
211 self.advert_tx.clone()
212 }
213
214 /// Make the next `read` of `char_uuid` await the returned `Notify` before
215 /// serving its (queued or last-written) value — simulates a slow read
216 /// (e.g. a reconnect-read) so tests can assert other work is not blocked.
217 ///
218 /// # Panics
219 ///
220 /// Panics if the internal blocked reads lock is poisoned (should never happen
221 /// in a single-threaded test).
222 #[allow(dead_code)] // used by the poll-off-advert-task tests
223 pub fn block_next_read(&self, char_uuid: &str) -> std::sync::Arc<tokio::sync::Notify> {
224 let gate = std::sync::Arc::new(tokio::sync::Notify::new());
225 self.blocked
226 .lock()
227 .unwrap()
228 .insert(char_uuid.to_string(), gate.clone());
229 gate
230 }
231}
232
233#[cfg(any(test, feature = "test-support"))]
234#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
235#[async_trait]
236impl AdvertSource for MockGatt {
237 async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
238 // Hand out the single receiver once; a closed one thereafter.
239 self.advert_rx.lock().unwrap().take().map_or_else(
240 || {
241 let (_tx, rx) = mpsc::channel(1);
242 Ok(rx)
243 },
244 Ok,
245 )
246 }
247}
248
249#[cfg(any(test, feature = "test-support"))]
250#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
251#[async_trait]
252impl GattConnection for MockGatt {
253 async fn instance_id(&self, char_uuid: &str) -> Result<u16> {
254 self.services
255 .lock()
256 .unwrap()
257 .iter()
258 .flat_map(|s| &s.characteristics)
259 .find(|c| c.uuid.eq_ignore_ascii_case(char_uuid))
260 .map(|c| c.iid)
261 .ok_or(crate::error::BleError::CharacteristicNotFound { aid: 0, iid: 0 })
262 }
263
264 async fn write(&self, char_uuid: &str, value: &[u8]) -> Result<()> {
265 self.values
266 .lock()
267 .unwrap()
268 .insert(char_uuid.to_string(), value.to_vec());
269 Ok(())
270 }
271
272 async fn read(&self, char_uuid: &str) -> Result<Vec<u8>> {
273 let gate = self.blocked.lock().unwrap().remove(char_uuid);
274 if let Some(gate) = gate {
275 gate.notified().await;
276 }
277 if let Some(q) = self.queued.lock().unwrap().get_mut(char_uuid) {
278 if let Some(v) = q.pop_front() {
279 return Ok(v);
280 }
281 }
282 Ok(self
283 .values
284 .lock()
285 .unwrap()
286 .get(char_uuid)
287 .cloned()
288 .unwrap_or_default())
289 }
290
291 async fn subscribe(&self, char_uuid: &str) -> Result<mpsc::Receiver<Vec<u8>>> {
292 let (tx, rx) = mpsc::channel(8);
293 self.senders
294 .lock()
295 .unwrap()
296 .insert(char_uuid.to_string(), tx);
297 Ok(rx)
298 }
299
300 async fn enumerate(&self) -> Result<Vec<GattService>> {
301 Ok(self.services.lock().unwrap().clone())
302 }
303
304 async fn generation(&self) -> u64 {
305 self.generation.load(std::sync::atomic::Ordering::SeqCst)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[tokio::test]
314 #[allow(clippy::unwrap_used)]
315 async fn mock_echoes_written_value_on_read() {
316 let gatt = MockGatt::new();
317 gatt.write("char-a", &[1, 2, 3]).await.unwrap();
318 assert_eq!(gatt.read("char-a").await.unwrap(), vec![1, 2, 3]);
319 }
320
321 #[tokio::test]
322 #[allow(clippy::unwrap_used)]
323 async fn mock_enumerate_returns_seeded_db() {
324 let svc = GattService {
325 uuid: "svc".into(),
326 iid: 1,
327 characteristics: vec![GattCharacteristic {
328 uuid: "c".into(),
329 iid: 2,
330 }],
331 };
332 let gatt = MockGatt::new().with_services(vec![svc.clone()]);
333 assert_eq!(gatt.enumerate().await.unwrap(), vec![svc]);
334 }
335}