ohea_lock/
lib.rs

1//! # Ohea Lock
2//!
3//! A Rust library for controlling Ohea Lock BLE smart locks.
4//!
5//! This library provides a protocol-focused implementation for communicating with
6//! Ohea Lock devices over Bluetooth Low Energy. The core library is transport-agnostic,
7//! with optional btleplug support for desktop platforms.
8//!
9//! ## Features
10//!
11//! - `btleplug-support`: Enable btleplug-based transport for desktop platforms.
12//!
13//! ## Architecture
14//!
15//! The library is structured in layers:
16//!
17//! 1. **Protocol Layer** ([`protocol`]): UUIDs, constants, and protocol types.
18//! 2. **Transport Layer** ([`transport`]): Abstract trait for BLE operations.
19//! 3. **High-Level API** ([`OheaLock`]): Convenient interface for lock operations.
20//!
21//! ## Example
22//!
23//! ```ignore
24//! use ohea_lock::{OheaLock, btleplug::BtleplugTransport};
25//!
26//! // Assuming `peripheral` is an already-paired btleplug Peripheral
27//! let transport = BtleplugTransport::connect_and_discover(peripheral).await?;
28//! let lock = OheaLock::new(transport);
29//!
30//! // Get device info
31//! let info = lock.get_device_info().await?;
32//! println!("Battery: {}%", info.battery_level);
33//!
34//! // Control the lock
35//! lock.unlock().await?;
36//! lock.lock().await?;
37//! ```
38
39#![warn(missing_docs)]
40#![warn(clippy::all)]
41#![warn(clippy::pedantic)]
42#![allow(clippy::module_name_repetitions)]
43
44pub mod error;
45pub mod protocol;
46pub mod transport;
47
48#[cfg(feature = "btleplug-support")]
49pub mod btleplug_impl;
50
51// Re-exports for convenience
52pub use error::{Error, Result};
53pub use protocol::{DeviceInfo, LockState};
54pub use transport::{Transport, TransportExt};
55
56#[cfg(feature = "btleplug-support")]
57pub use btleplug_impl::BtleplugTransport;
58
59use crate::protocol::{
60    BATTERY_LEVEL_CHAR_UUID, COMMAND_CHAR_UUID, DEVICE_NAME_CHAR_UUID, FIRMWARE_REVISION_CHAR_UUID,
61    LOCK_POSITION_CHAR_UUID, LOCK_STATE_CHAR_UUID, STATUS_CHAR_UUID,
62};
63
64/// High-level interface for controlling an Ohea Lock device.
65///
66/// This struct wraps a [`Transport`] implementation and provides convenient
67/// methods for common lock operations.
68///
69/// # Example
70///
71/// ```ignore
72/// let lock = OheaLock::new(transport);
73/// let state = lock.get_lock_state().await?;
74/// if state.is_locked() {
75///     lock.unlock().await?;
76/// }
77/// ```
78pub struct OheaLock<T: Transport> {
79    transport: T,
80    initialized: bool,
81}
82
83impl<T: Transport> OheaLock<T> {
84    /// Create a new `OheaLock` instance with the given transport.
85    #[must_use]
86    pub const fn new(transport: T) -> Self {
87        Self {
88            transport,
89            initialized: false,
90        }
91    }
92
93    /// Get the underlying transport.
94    #[must_use]
95    pub const fn transport(&self) -> &T {
96        &self.transport
97    }
98
99    /// Get a mutable reference to the underlying transport.
100    pub fn transport_mut(&mut self) -> &mut T {
101        &mut self.transport
102    }
103
104    /// Initialize the session with the lock.
105    ///
106    /// This sends the initialization command to the command register.
107    /// Should be called after connecting to a newly paired device.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the write fails.
112    pub async fn initialize(&mut self) -> Result<()> {
113        let cmd = protocol::build_init_command();
114        self.transport.write(COMMAND_CHAR_UUID, &cmd).await?;
115        self.initialized = true;
116        Ok(())
117    }
118
119    /// Get the current lock state.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the read fails or returns invalid data.
124    pub async fn get_lock_state(&self) -> Result<LockState> {
125        let data = self.transport.read(LOCK_STATE_CHAR_UUID).await?;
126        let byte = data
127            .first()
128            .copied()
129            .ok_or_else(|| Error::InvalidResponse("empty lock state".to_string()))?;
130        LockState::try_from(byte)
131    }
132
133    /// Get the current lock position.
134    ///
135    /// This may differ from lock state during transitions.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the read fails.
140    pub async fn get_lock_position(&self) -> Result<u8> {
141        self.transport.read_byte(LOCK_POSITION_CHAR_UUID).await
142    }
143
144    /// Unlock the lock.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the write fails.
149    pub async fn unlock(&self) -> Result<()> {
150        self.transport
151            .write(LOCK_STATE_CHAR_UUID, &[LockState::Unlocked.as_byte()])
152            .await
153    }
154
155    /// Lock the lock.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the write fails.
160    pub async fn lock(&self) -> Result<()> {
161        self.transport
162            .write(LOCK_STATE_CHAR_UUID, &[LockState::Locked.as_byte()])
163            .await
164    }
165
166    /// Set the lock state.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the write fails.
171    pub async fn set_lock_state(&self, state: LockState) -> Result<()> {
172        self.transport
173            .write(LOCK_STATE_CHAR_UUID, &[state.as_byte()])
174            .await
175    }
176
177    /// Get the battery level as a percentage (0-100).
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the read fails.
182    pub async fn get_battery_level(&self) -> Result<u8> {
183        self.transport.read_byte(BATTERY_LEVEL_CHAR_UUID).await
184    }
185
186    /// Get the firmware version string.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the read fails or the response is not valid UTF-8.
191    pub async fn get_firmware_version(&self) -> Result<String> {
192        self.transport.read_string(FIRMWARE_REVISION_CHAR_UUID).await
193    }
194
195    /// Get the device name.
196    ///
197    /// First attempts to read from the Device Name characteristic (0x2A00).
198    /// Falls back to the local name from advertising data if the characteristic
199    /// is not available.
200    ///
201    /// # Errors
202    ///
203    /// Returns an error if both methods fail.
204    pub async fn get_device_name(&self) -> Result<String> {
205        self.transport
206            .read_string(DEVICE_NAME_CHAR_UUID)
207            .await
208            .or_else(|_| {
209                self.transport
210                    .local_name()
211                    .ok_or_else(|| Error::InvalidResponse("device name not available".into()))
212            })
213    }
214
215    /// Get comprehensive device information.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if any of the underlying reads fail.
220    pub async fn get_device_info(&self) -> Result<DeviceInfo> {
221        let name = self.get_device_name().await?;
222        let firmware_version = self.get_firmware_version().await?;
223        let battery_level = self.get_battery_level().await?;
224
225        Ok(DeviceInfo {
226            name,
227            firmware_version,
228            battery_level,
229        })
230    }
231
232    /// Get the current status byte.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the read fails.
237    pub async fn get_status(&self) -> Result<u8> {
238        self.transport.read_byte(STATUS_CHAR_UUID).await
239    }
240
241    /// Subscribe to lock state notifications.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if subscription fails.
246    pub async fn subscribe_lock_state(&self) -> Result<()> {
247        self.transport.subscribe(LOCK_STATE_CHAR_UUID).await
248    }
249
250    /// Subscribe to status notifications.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if subscription fails.
255    pub async fn subscribe_status(&self) -> Result<()> {
256        self.transport.subscribe(STATUS_CHAR_UUID).await
257    }
258
259    /// Subscribe to battery level notifications.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if subscription fails.
264    pub async fn subscribe_battery_level(&self) -> Result<()> {
265        self.transport.subscribe(BATTERY_LEVEL_CHAR_UUID).await
266    }
267
268    /// Unsubscribe from all notifications.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if any unsubscription fails.
273    pub async fn unsubscribe_all(&self) -> Result<()> {
274        // Ignore errors for individual unsubscriptions
275        let _ = self.transport.unsubscribe(LOCK_STATE_CHAR_UUID).await;
276        let _ = self.transport.unsubscribe(STATUS_CHAR_UUID).await;
277        let _ = self.transport.unsubscribe(BATTERY_LEVEL_CHAR_UUID).await;
278        Ok(())
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use async_trait::async_trait;
286    use std::collections::HashMap;
287    use std::sync::Arc;
288    use tokio::sync::RwLock;
289
290    /// Mock transport for testing OheaLock API.
291    struct MockTransport {
292        responses: Arc<RwLock<HashMap<uuid::Uuid, Vec<u8>>>>,
293        writes: Arc<RwLock<Vec<(uuid::Uuid, Vec<u8>)>>>,
294        subscribed: Arc<RwLock<Vec<uuid::Uuid>>>,
295    }
296
297    impl MockTransport {
298        fn new() -> Self {
299            Self {
300                responses: Arc::new(RwLock::new(HashMap::new())),
301                writes: Arc::new(RwLock::new(Vec::new())),
302                subscribed: Arc::new(RwLock::new(Vec::new())),
303            }
304        }
305
306        async fn set_response(&self, uuid: uuid::Uuid, data: Vec<u8>) {
307            self.responses.write().await.insert(uuid, data);
308        }
309
310        async fn last_write(&self) -> Option<(uuid::Uuid, Vec<u8>)> {
311            self.writes.read().await.last().cloned()
312        }
313    }
314
315    #[async_trait]
316    impl Transport for MockTransport {
317        async fn read(&self, char_uuid: uuid::Uuid) -> Result<Vec<u8>> {
318            self.responses
319                .read()
320                .await
321                .get(&char_uuid)
322                .cloned()
323                .ok_or_else(|| Error::CharacteristicNotFound("mock"))
324        }
325
326        async fn write(&self, char_uuid: uuid::Uuid, data: &[u8]) -> Result<()> {
327            self.writes.write().await.push((char_uuid, data.to_vec()));
328            Ok(())
329        }
330
331        async fn write_without_response(&self, char_uuid: uuid::Uuid, data: &[u8]) -> Result<()> {
332            self.write(char_uuid, data).await
333        }
334
335        async fn subscribe(&self, char_uuid: uuid::Uuid) -> Result<()> {
336            self.subscribed.write().await.push(char_uuid);
337            Ok(())
338        }
339
340        async fn unsubscribe(&self, char_uuid: uuid::Uuid) -> Result<()> {
341            self.subscribed.write().await.retain(|&u| u != char_uuid);
342            Ok(())
343        }
344
345        fn is_connected(&self) -> bool {
346            true
347        }
348    }
349
350    // =========================================================================
351    // LockState Unit Tests
352    // =========================================================================
353
354    #[test]
355    fn lock_state_conversion() {
356        assert_eq!(LockState::from_byte(0x00), Some(LockState::Locked));
357        assert_eq!(LockState::from_byte(0x01), Some(LockState::Unlocked));
358        assert_eq!(LockState::from_byte(0x02), None);
359
360        assert_eq!(LockState::Locked.as_byte(), 0x00);
361        assert_eq!(LockState::Unlocked.as_byte(), 0x01);
362    }
363
364    #[test]
365    fn lock_state_predicates() {
366        assert!(LockState::Locked.is_locked());
367        assert!(!LockState::Locked.is_unlocked());
368        assert!(!LockState::Unlocked.is_locked());
369        assert!(LockState::Unlocked.is_unlocked());
370    }
371
372    // =========================================================================
373    // OheaLock Constructor Tests
374    // =========================================================================
375
376    #[test]
377    fn ohea_lock_new_is_not_initialized() {
378        let transport = MockTransport::new();
379        let lock = OheaLock::new(transport);
380        assert!(!lock.initialized);
381    }
382
383    #[test]
384    fn ohea_lock_transport_accessor() {
385        let transport = MockTransport::new();
386        let lock = OheaLock::new(transport);
387        assert!(lock.transport().is_connected());
388    }
389
390    // =========================================================================
391    // OheaLock Async Operation Tests
392    // =========================================================================
393
394    #[tokio::test]
395    async fn initialize_sends_correct_command() {
396        let transport = MockTransport::new();
397        let mut lock = OheaLock::new(transport);
398
399        lock.initialize().await.unwrap();
400
401        assert!(lock.initialized);
402        let (uuid, data) = lock.transport().last_write().await.unwrap();
403        assert_eq!(uuid, COMMAND_CHAR_UUID);
404        assert_eq!(data, protocol::build_init_command());
405    }
406
407    #[tokio::test]
408    async fn get_lock_state_returns_locked() {
409        let transport = MockTransport::new();
410        transport.set_response(LOCK_STATE_CHAR_UUID, vec![0x00]).await;
411        let lock = OheaLock::new(transport);
412
413        let state = lock.get_lock_state().await.unwrap();
414        assert_eq!(state, LockState::Locked);
415    }
416
417    #[tokio::test]
418    async fn get_lock_state_returns_unlocked() {
419        let transport = MockTransport::new();
420        transport.set_response(LOCK_STATE_CHAR_UUID, vec![0x01]).await;
421        let lock = OheaLock::new(transport);
422
423        let state = lock.get_lock_state().await.unwrap();
424        assert_eq!(state, LockState::Unlocked);
425    }
426
427    #[tokio::test]
428    async fn get_lock_state_errors_on_invalid_value() {
429        let transport = MockTransport::new();
430        transport.set_response(LOCK_STATE_CHAR_UUID, vec![0xFF]).await;
431        let lock = OheaLock::new(transport);
432
433        let result = lock.get_lock_state().await;
434        assert!(result.is_err());
435    }
436
437    #[tokio::test]
438    async fn unlock_writes_correct_value() {
439        let transport = MockTransport::new();
440        let lock = OheaLock::new(transport);
441
442        lock.unlock().await.unwrap();
443
444        let (uuid, data) = lock.transport().last_write().await.unwrap();
445        assert_eq!(uuid, LOCK_STATE_CHAR_UUID);
446        assert_eq!(data, vec![0x01]);
447    }
448
449    #[tokio::test]
450    async fn lock_writes_correct_value() {
451        let transport = MockTransport::new();
452        let lock = OheaLock::new(transport);
453
454        lock.lock().await.unwrap();
455
456        let (uuid, data) = lock.transport().last_write().await.unwrap();
457        assert_eq!(uuid, LOCK_STATE_CHAR_UUID);
458        assert_eq!(data, vec![0x00]);
459    }
460
461    #[tokio::test]
462    async fn get_battery_level_returns_percentage() {
463        let transport = MockTransport::new();
464        transport.set_response(BATTERY_LEVEL_CHAR_UUID, vec![0x64]).await; // 100%
465        let lock = OheaLock::new(transport);
466
467        let level = lock.get_battery_level().await.unwrap();
468        assert_eq!(level, 100);
469    }
470
471    #[tokio::test]
472    async fn get_firmware_version_returns_string() {
473        let transport = MockTransport::new();
474        transport.set_response(FIRMWARE_REVISION_CHAR_UUID, b"1.0".to_vec()).await;
475        let lock = OheaLock::new(transport);
476
477        let version = lock.get_firmware_version().await.unwrap();
478        assert_eq!(version, "1.0");
479    }
480
481    #[tokio::test]
482    async fn get_device_name_returns_ohea_lock() {
483        let transport = MockTransport::new();
484        transport.set_response(DEVICE_NAME_CHAR_UUID, b"Ohea Lock".to_vec()).await;
485        let lock = OheaLock::new(transport);
486
487        let name = lock.get_device_name().await.unwrap();
488        assert_eq!(name, "Ohea Lock");
489    }
490
491    #[tokio::test]
492    async fn get_device_info_aggregates_all_fields() {
493        let transport = MockTransport::new();
494        transport.set_response(DEVICE_NAME_CHAR_UUID, b"Ohea Lock".to_vec()).await;
495        transport.set_response(FIRMWARE_REVISION_CHAR_UUID, b"1.0".to_vec()).await;
496        transport.set_response(BATTERY_LEVEL_CHAR_UUID, vec![0x64]).await;
497        let lock = OheaLock::new(transport);
498
499        let info = lock.get_device_info().await.unwrap();
500
501        assert_eq!(info.name, "Ohea Lock");
502        assert_eq!(info.firmware_version, "1.0");
503        assert_eq!(info.battery_level, 100);
504    }
505
506    // =========================================================================
507    // Subscription Tests
508    // =========================================================================
509
510    #[tokio::test]
511    async fn subscribe_lock_state_subscribes_to_correct_uuid() {
512        let transport = MockTransport::new();
513        let lock = OheaLock::new(transport);
514
515        lock.subscribe_lock_state().await.unwrap();
516
517        let subscribed = lock.transport().subscribed.read().await;
518        assert!(subscribed.contains(&LOCK_STATE_CHAR_UUID));
519    }
520
521    #[tokio::test]
522    async fn unsubscribe_all_does_not_error() {
523        let transport = MockTransport::new();
524        let lock = OheaLock::new(transport);
525
526        // Should not error even if nothing is subscribed
527        let result = lock.unsubscribe_all().await;
528        assert!(result.is_ok());
529    }
530}
531