1#![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
51pub 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
64pub struct OheaLock<T: Transport> {
79 transport: T,
80 initialized: bool,
81}
82
83impl<T: Transport> OheaLock<T> {
84 #[must_use]
86 pub const fn new(transport: T) -> Self {
87 Self {
88 transport,
89 initialized: false,
90 }
91 }
92
93 #[must_use]
95 pub const fn transport(&self) -> &T {
96 &self.transport
97 }
98
99 pub fn transport_mut(&mut self) -> &mut T {
101 &mut self.transport
102 }
103
104 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 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 pub async fn get_lock_position(&self) -> Result<u8> {
141 self.transport.read_byte(LOCK_POSITION_CHAR_UUID).await
142 }
143
144 pub async fn unlock(&self) -> Result<()> {
150 self.transport
151 .write(LOCK_STATE_CHAR_UUID, &[LockState::Unlocked.as_byte()])
152 .await
153 }
154
155 pub async fn lock(&self) -> Result<()> {
161 self.transport
162 .write(LOCK_STATE_CHAR_UUID, &[LockState::Locked.as_byte()])
163 .await
164 }
165
166 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 pub async fn get_battery_level(&self) -> Result<u8> {
183 self.transport.read_byte(BATTERY_LEVEL_CHAR_UUID).await
184 }
185
186 pub async fn get_firmware_version(&self) -> Result<String> {
192 self.transport.read_string(FIRMWARE_REVISION_CHAR_UUID).await
193 }
194
195 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 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 pub async fn get_status(&self) -> Result<u8> {
238 self.transport.read_byte(STATUS_CHAR_UUID).await
239 }
240
241 pub async fn subscribe_lock_state(&self) -> Result<()> {
247 self.transport.subscribe(LOCK_STATE_CHAR_UUID).await
248 }
249
250 pub async fn subscribe_status(&self) -> Result<()> {
256 self.transport.subscribe(STATUS_CHAR_UUID).await
257 }
258
259 pub async fn subscribe_battery_level(&self) -> Result<()> {
265 self.transport.subscribe(BATTERY_LEVEL_CHAR_UUID).await
266 }
267
268 pub async fn unsubscribe_all(&self) -> Result<()> {
274 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 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 #[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 #[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 #[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; 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 #[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 let result = lock.unsubscribe_all().await;
528 assert!(result.is_ok());
529 }
530}
531