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