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}
122
123#[cfg(test)]
124impl Default for MockGatt {
125    fn default() -> Self {
126        let (advert_tx, advert_rx) = mpsc::channel(16);
127        Self {
128            values: std::sync::Mutex::new(std::collections::HashMap::new()),
129            queued: std::sync::Mutex::new(std::collections::HashMap::new()),
130            services: std::sync::Mutex::new(Vec::new()),
131            senders: std::sync::Mutex::new(std::collections::HashMap::new()),
132            generation: std::sync::atomic::AtomicU64::new(0),
133            advert_tx,
134            advert_rx: std::sync::Mutex::new(Some(advert_rx)),
135        }
136    }
137}
138
139#[cfg(test)]
140#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
141impl MockGatt {
142    pub(crate) fn new() -> Self {
143        Self::default()
144    }
145
146    pub(crate) fn with_services(self, services: Vec<GattService>) -> Self {
147        *self.services.lock().unwrap() = services;
148        self
149    }
150
151    /// Queue a canned response that the next `read` of `char_uuid` returns
152    /// instead of the last-written value.
153    #[allow(dead_code)] // used by later tasks (PDU transport / pairing / db)
154    pub(crate) fn queue_read(&self, char_uuid: &str, value: Vec<u8>) {
155        self.queued
156            .lock()
157            .unwrap()
158            .entry(char_uuid.to_string())
159            .or_default()
160            .push_back(value);
161    }
162
163    /// A sender that pushes a notification to subscribers of `char_uuid`.
164    #[allow(dead_code)] // used by later tasks (events)
165    pub(crate) fn notifier(&self, char_uuid: &str) -> Option<mpsc::Sender<Vec<u8>>> {
166        self.senders.lock().unwrap().get(char_uuid).cloned()
167    }
168
169    /// Advance the link generation, simulating a reconnect that invalidated any
170    /// secure session minted at an earlier generation.
171    #[allow(dead_code)] // used by the reconnect tests
172    pub(crate) fn bump_generation(&self) {
173        self.generation
174            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
175    }
176
177    /// A sender that pushes a raw advert to a `watch_adverts` subscriber.
178    #[allow(dead_code)] // used by later tasks (broadcast / disconnected-event poll)
179    pub(crate) fn advert_sender(&self) -> mpsc::Sender<RawAdvert> {
180        self.advert_tx.clone()
181    }
182}
183
184#[cfg(test)]
185#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
186#[async_trait]
187impl AdvertSource for MockGatt {
188    async fn watch_adverts(&self) -> Result<mpsc::Receiver<RawAdvert>> {
189        // Hand out the single receiver once; a closed one thereafter.
190        self.advert_rx.lock().unwrap().take().map_or_else(
191            || {
192                let (_tx, rx) = mpsc::channel(1);
193                Ok(rx)
194            },
195            Ok,
196        )
197    }
198}
199
200#[cfg(test)]
201#[allow(clippy::unwrap_used)] // test double: lock poisoning is not a real concern in single-process tests
202#[async_trait]
203impl GattConnection for MockGatt {
204    async fn instance_id(&self, char_uuid: &str) -> Result<u16> {
205        self.services
206            .lock()
207            .unwrap()
208            .iter()
209            .flat_map(|s| &s.characteristics)
210            .find(|c| c.uuid.eq_ignore_ascii_case(char_uuid))
211            .map(|c| c.iid)
212            .ok_or(crate::error::BleError::CharacteristicNotFound { aid: 0, iid: 0 })
213    }
214
215    async fn write(&self, char_uuid: &str, value: &[u8]) -> Result<()> {
216        self.values
217            .lock()
218            .unwrap()
219            .insert(char_uuid.to_string(), value.to_vec());
220        Ok(())
221    }
222
223    async fn read(&self, char_uuid: &str) -> Result<Vec<u8>> {
224        if let Some(q) = self.queued.lock().unwrap().get_mut(char_uuid) {
225            if let Some(v) = q.pop_front() {
226                return Ok(v);
227            }
228        }
229        Ok(self
230            .values
231            .lock()
232            .unwrap()
233            .get(char_uuid)
234            .cloned()
235            .unwrap_or_default())
236    }
237
238    async fn subscribe(&self, char_uuid: &str) -> Result<mpsc::Receiver<Vec<u8>>> {
239        let (tx, rx) = mpsc::channel(8);
240        self.senders
241            .lock()
242            .unwrap()
243            .insert(char_uuid.to_string(), tx);
244        Ok(rx)
245    }
246
247    async fn enumerate(&self) -> Result<Vec<GattService>> {
248        Ok(self.services.lock().unwrap().clone())
249    }
250
251    async fn generation(&self) -> u64 {
252        self.generation.load(std::sync::atomic::Ordering::SeqCst)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[tokio::test]
261    #[allow(clippy::unwrap_used)]
262    async fn mock_echoes_written_value_on_read() {
263        let gatt = MockGatt::new();
264        gatt.write("char-a", &[1, 2, 3]).await.unwrap();
265        assert_eq!(gatt.read("char-a").await.unwrap(), vec![1, 2, 3]);
266    }
267
268    #[tokio::test]
269    #[allow(clippy::unwrap_used)]
270    async fn mock_enumerate_returns_seeded_db() {
271        let svc = GattService {
272            uuid: "svc".into(),
273            iid: 1,
274            characteristics: vec![GattCharacteristic {
275                uuid: "c".into(),
276                iid: 2,
277            }],
278        };
279        let gatt = MockGatt::new().with_services(vec![svc.clone()]);
280        assert_eq!(gatt.enumerate().await.unwrap(), vec![svc]);
281    }
282}