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