Skip to main content

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}