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