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/// 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}