ohea_lock/
protocol.rs

1//! Protocol constants and types for Ohea Lock BLE communication.
2//!
3//! This module contains all the UUIDs, handles, and protocol-specific types
4//! derived from packet capture analysis.
5
6use uuid::Uuid;
7
8// =============================================================================
9// Bluetooth Base UUID
10// =============================================================================
11
12/// Bluetooth Base UUID: 00000000-0000-1000-8000-00805F9B34FB
13/// Used to expand 16-bit UUIDs to 128-bit.
14const BLUETOOTH_BASE_UUID: u128 = 0x0000_0000_0000_1000_8000_0080_5F9B_34FB;
15
16/// Convert a 16-bit BLE UUID to a full 128-bit UUID.
17#[must_use]
18const fn uuid_from_u16(short: u16) -> Uuid {
19    Uuid::from_u128(BLUETOOTH_BASE_UUID | ((short as u128) << 96))
20}
21
22// =============================================================================
23// Service UUIDs
24// =============================================================================
25
26/// Custom Ohea Lock service UUID.
27/// Handles: 0x0025-0x003F
28pub const LOCK_SERVICE_UUID: Uuid = Uuid::from_u128(0x0A0F0001_0000_1000_8000_00805F9B34FB);
29
30/// Standard Battery Service UUID (0x180F).
31pub const BATTERY_SERVICE_UUID: Uuid = uuid_from_u16(0x180F);
32
33/// Standard Device Information Service UUID (0x180A).
34pub const DEVICE_INFO_SERVICE_UUID: Uuid = uuid_from_u16(0x180A);
35
36/// Standard Generic Access Service UUID (0x1800).
37pub const GENERIC_ACCESS_SERVICE_UUID: Uuid = uuid_from_u16(0x1800);
38
39/// Dialog Semiconductor OTA Service UUID (0xFEF5).
40pub const DIALOG_OTA_SERVICE_UUID: Uuid = uuid_from_u16(0xFEF5);
41
42// =============================================================================
43// Lock Service Characteristic UUIDs
44// =============================================================================
45
46/// Lock state characteristic - Read/Write/Notify.
47/// Handle: 0x0027
48/// Values: 0x00 = locked, 0x01 = unlocked
49pub const LOCK_STATE_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0001_0000_1000_8000_00805F9B34FB);
50
51/// Status notification characteristic - Notify.
52/// Handle: 0x002B
53pub const STATUS_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0011_0000_1000_8000_00805F9B34FB);
54
55/// Lock position characteristic - Read.
56/// Handle: 0x002F
57pub const LOCK_POSITION_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0002_0000_1000_8000_00805F9B34FB);
58
59/// Command register characteristic - Write.
60/// Handle: 0x0032
61pub const COMMAND_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0003_0000_1000_8000_00805F9B34FB);
62
63/// Device info characteristic - Read.
64/// Handle: 0x0035
65pub const DEVICE_INFO_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0004_0000_1000_8000_00805F9B34FB);
66
67/// Extended info characteristic - Read.
68/// Handle: 0x0038
69pub const EXTENDED_INFO_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F1004_0000_1000_8000_00805F9B34FB);
70
71/// Configuration characteristic - Write.
72/// Handle: 0x003B
73pub const CONFIG_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0005_0000_1000_8000_00805F9B34FB);
74
75/// Additional control characteristic - Write.
76/// Handle: 0x003E
77pub const CONTROL_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0006_0000_1000_8000_00805F9B34FB);
78
79// =============================================================================
80// Standard Characteristic UUIDs
81// =============================================================================
82
83/// Battery Level characteristic UUID (0x2A19).
84pub const BATTERY_LEVEL_CHAR_UUID: Uuid = uuid_from_u16(0x2A19);
85
86/// Firmware Revision String characteristic UUID (0x2A26).
87pub const FIRMWARE_REVISION_CHAR_UUID: Uuid = uuid_from_u16(0x2A26);
88
89/// Device Name characteristic UUID (0x2A00).
90pub const DEVICE_NAME_CHAR_UUID: Uuid = uuid_from_u16(0x2A00);
91
92// =============================================================================
93// Protocol Types
94// =============================================================================
95
96/// Lock state enumeration.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98#[repr(u8)]
99pub enum LockState {
100    /// Lock is in locked position.
101    Unlocked = 0x00,
102    /// Lock is in unlocked position.
103    Locked = 0x01,
104}
105
106impl LockState {
107    /// Create a `LockState` from a raw byte value.
108    #[must_use]
109    pub const fn from_byte(byte: u8) -> Option<Self> {
110        match byte {
111            0x00 => Some(Self::Unlocked),
112            0x01 => Some(Self::Locked),
113            _ => None,
114        }
115    }
116
117    /// Convert to raw byte value.
118    #[must_use]
119    pub const fn as_byte(self) -> u8 {
120        self as u8
121    }
122
123    /// Returns `true` if the lock is locked.
124    #[must_use]
125    pub const fn is_locked(self) -> bool {
126        matches!(self, Self::Locked)
127    }
128
129    /// Returns `true` if the lock is unlocked.
130    #[must_use]
131    pub const fn is_unlocked(self) -> bool {
132        matches!(self, Self::Unlocked)
133    }
134}
135
136impl TryFrom<u8> for LockState {
137    type Error = crate::Error;
138
139    fn try_from(value: u8) -> Result<Self, Self::Error> {
140        Self::from_byte(value)
141            .ok_or_else(|| crate::Error::InvalidResponse(format!("invalid lock state: {value:#04x}")))
142    }
143}
144
145impl From<LockState> for u8 {
146    fn from(state: LockState) -> Self {
147        state.as_byte()
148    }
149}
150
151/// Device information retrieved from the lock.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DeviceInfo {
154    /// Device name (e.g., "Ohea Lock").
155    pub name: String,
156    /// Firmware revision string (e.g., "1.0").
157    pub firmware_version: String,
158    /// Battery level as percentage (0-100).
159    pub battery_level: u8,
160}
161
162/// Manufacturer ID found in advertising data.
163pub const MANUFACTURER_ID: u16 = 0x0A0F;
164
165/// Device name as advertised.
166pub const DEVICE_NAME: &str = "Ohea Lock";
167
168// =============================================================================
169// Command Builders
170// =============================================================================
171
172/// Build an initialization command for the command register.
173///
174/// This command is sent after pairing to initialize the session.
175/// Observed format: `[0x1A, 0x01, 0x04, 0x00, 0x31]`
176#[must_use]
177pub fn build_init_command() -> [u8; 5] {
178    [0x1A, 0x01, 0x04, 0x00, 0x31]
179}
180
181// =============================================================================
182// ATT Error Codes
183// =============================================================================
184
185/// ATT Error code for Insufficient Authentication.
186pub const ATT_ERR_INSUFFICIENT_AUTH: u8 = 0x05;
187
188/// ATT Error code for Attribute Not Found.
189pub const ATT_ERR_ATTR_NOT_FOUND: u8 = 0x0A;
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    // =========================================================================
196    // UUID Construction Tests
197    // =========================================================================
198
199    #[test]
200    fn uuid_from_u16_constructs_correct_bluetooth_uuid() {
201        // Battery Level 0x2A19 -> 00002A19-0000-1000-8000-00805F9B34FB
202        let expected = Uuid::parse_str("00002A19-0000-1000-8000-00805F9B34FB").unwrap();
203        assert_eq!(uuid_from_u16(0x2A19), expected);
204    }
205
206    #[test]
207    fn standard_service_uuids_are_correct() {
208        assert_eq!(
209            BATTERY_SERVICE_UUID,
210            Uuid::parse_str("0000180F-0000-1000-8000-00805F9B34FB").unwrap()
211        );
212        assert_eq!(
213            DEVICE_INFO_SERVICE_UUID,
214            Uuid::parse_str("0000180A-0000-1000-8000-00805F9B34FB").unwrap()
215        );
216        assert_eq!(
217            GENERIC_ACCESS_SERVICE_UUID,
218            Uuid::parse_str("00001800-0000-1000-8000-00805F9B34FB").unwrap()
219        );
220    }
221
222    #[test]
223    fn lock_service_uuid_matches_manufacturer_spec() {
224        assert_eq!(
225            LOCK_SERVICE_UUID,
226            Uuid::parse_str("0A0F0001-0000-1000-8000-00805F9B34FB").unwrap()
227        );
228    }
229
230    #[test]
231    fn lock_characteristic_uuids_are_correct() {
232        let cases = [
233            (LOCK_STATE_CHAR_UUID, "0A0F0001-0000-1000-8000-00805F9B34FB"),
234            (STATUS_CHAR_UUID, "0A0F0011-0000-1000-8000-00805F9B34FB"),
235            (LOCK_POSITION_CHAR_UUID, "0A0F0002-0000-1000-8000-00805F9B34FB"),
236            (COMMAND_CHAR_UUID, "0A0F0003-0000-1000-8000-00805F9B34FB"),
237            (DEVICE_INFO_CHAR_UUID, "0A0F0004-0000-1000-8000-00805F9B34FB"),
238            (EXTENDED_INFO_CHAR_UUID, "0A0F1004-0000-1000-8000-00805F9B34FB"),
239            (CONFIG_CHAR_UUID, "0A0F0005-0000-1000-8000-00805F9B34FB"),
240            (CONTROL_CHAR_UUID, "0A0F0006-0000-1000-8000-00805F9B34FB"),
241        ];
242        for (uuid, expected) in cases {
243            assert_eq!(uuid, Uuid::parse_str(expected).unwrap(), "UUID mismatch for {expected}");
244        }
245    }
246
247    #[test]
248    fn standard_characteristic_uuids_are_correct() {
249        assert_eq!(
250            BATTERY_LEVEL_CHAR_UUID,
251            Uuid::parse_str("00002A19-0000-1000-8000-00805F9B34FB").unwrap()
252        );
253        assert_eq!(
254            FIRMWARE_REVISION_CHAR_UUID,
255            Uuid::parse_str("00002A26-0000-1000-8000-00805F9B34FB").unwrap()
256        );
257        assert_eq!(
258            DEVICE_NAME_CHAR_UUID,
259            Uuid::parse_str("00002A00-0000-1000-8000-00805F9B34FB").unwrap()
260        );
261    }
262
263    // =========================================================================
264    // LockState Tests
265    // =========================================================================
266
267    #[test]
268    fn lock_state_from_byte_valid_values() {
269        assert_eq!(LockState::from_byte(0x00), Some(LockState::Locked));
270        assert_eq!(LockState::from_byte(0x01), Some(LockState::Unlocked));
271    }
272
273    #[test]
274    fn lock_state_from_byte_invalid_values() {
275        (0x02..=0xFF).for_each(|b| assert_eq!(LockState::from_byte(b), None));
276    }
277
278    #[test]
279    fn lock_state_as_byte_roundtrip() {
280        [LockState::Locked, LockState::Unlocked]
281            .into_iter()
282            .for_each(|s| assert_eq!(LockState::from_byte(s.as_byte()), Some(s)));
283    }
284
285    #[test]
286    fn lock_state_predicates() {
287        assert!(LockState::Locked.is_locked());
288        assert!(!LockState::Locked.is_unlocked());
289        assert!(!LockState::Unlocked.is_locked());
290        assert!(LockState::Unlocked.is_unlocked());
291    }
292
293    #[test]
294    fn lock_state_try_from_valid() {
295        assert_eq!(LockState::try_from(0x00).unwrap(), LockState::Locked);
296        assert_eq!(LockState::try_from(0x01).unwrap(), LockState::Unlocked);
297    }
298
299    #[test]
300    fn lock_state_try_from_invalid() {
301        assert!(LockState::try_from(0x02).is_err());
302        assert!(LockState::try_from(0xFF).is_err());
303    }
304
305    #[test]
306    fn lock_state_into_u8() {
307        assert_eq!(u8::from(LockState::Locked), 0x00);
308        assert_eq!(u8::from(LockState::Unlocked), 0x01);
309    }
310
311    // =========================================================================
312    // DeviceInfo Tests
313    // =========================================================================
314
315    #[test]
316    fn device_info_equality() {
317        let a = DeviceInfo {
318            name: "Ohea Lock".to_string(),
319            firmware_version: "1.0".to_string(),
320            battery_level: 100,
321        };
322        let b = a.clone();
323        assert_eq!(a, b);
324    }
325
326    // =========================================================================
327    // Constants Tests
328    // =========================================================================
329
330    #[test]
331    fn device_name_matches_packet_capture() {
332        // From packet capture: "Ohea Lock" = 4F 68 65 61 20 4C 6F 63 6B
333        assert_eq!(DEVICE_NAME, "Ohea Lock");
334        assert_eq!(DEVICE_NAME.as_bytes(), &[0x4F, 0x68, 0x65, 0x61, 0x20, 0x4C, 0x6F, 0x63, 0x6B]);
335    }
336
337    #[test]
338    fn manufacturer_id_matches_packet_capture() {
339        assert_eq!(MANUFACTURER_ID, 0x0A0F);
340    }
341
342    // =========================================================================
343    // Command Builder Tests
344    // =========================================================================
345
346    #[test]
347    fn init_command_matches_packet_capture() {
348        // From packet capture: 1A 01 04 00 31
349        assert_eq!(build_init_command(), [0x1A, 0x01, 0x04, 0x00, 0x31]);
350    }
351
352    #[test]
353    fn init_command_length_is_five_bytes() {
354        assert_eq!(build_init_command().len(), 5);
355    }
356
357    // =========================================================================
358    // ATT Error Codes Tests
359    // =========================================================================
360
361    #[test]
362    fn att_error_codes_match_bluetooth_spec() {
363        assert_eq!(ATT_ERR_INSUFFICIENT_AUTH, 0x05);
364        assert_eq!(ATT_ERR_ATTR_NOT_FOUND, 0x0A);
365    }
366}