1use uuid::Uuid;
7
8const BLUETOOTH_BASE_UUID: u128 = 0x0000_0000_0000_1000_8000_0080_5F9B_34FB;
15
16#[must_use]
18const fn uuid_from_u16(short: u16) -> Uuid {
19 Uuid::from_u128(BLUETOOTH_BASE_UUID | ((short as u128) << 96))
20}
21
22pub const LOCK_SERVICE_UUID: Uuid = Uuid::from_u128(0x0A0F0001_0000_1000_8000_00805F9B34FB);
29
30pub const BATTERY_SERVICE_UUID: Uuid = uuid_from_u16(0x180F);
32
33pub const DEVICE_INFO_SERVICE_UUID: Uuid = uuid_from_u16(0x180A);
35
36pub const GENERIC_ACCESS_SERVICE_UUID: Uuid = uuid_from_u16(0x1800);
38
39pub const DIALOG_OTA_SERVICE_UUID: Uuid = uuid_from_u16(0xFEF5);
41
42pub const LOCK_STATE_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0001_0000_1000_8000_00805F9B34FB);
50
51pub const STATUS_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0011_0000_1000_8000_00805F9B34FB);
54
55pub const LOCK_POSITION_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0002_0000_1000_8000_00805F9B34FB);
58
59pub const COMMAND_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0003_0000_1000_8000_00805F9B34FB);
62
63pub const DEVICE_INFO_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0004_0000_1000_8000_00805F9B34FB);
66
67pub const EXTENDED_INFO_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F1004_0000_1000_8000_00805F9B34FB);
70
71pub const CONFIG_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0005_0000_1000_8000_00805F9B34FB);
74
75pub const CONTROL_CHAR_UUID: Uuid = Uuid::from_u128(0x0A0F0006_0000_1000_8000_00805F9B34FB);
78
79pub const BATTERY_LEVEL_CHAR_UUID: Uuid = uuid_from_u16(0x2A19);
85
86pub const FIRMWARE_REVISION_CHAR_UUID: Uuid = uuid_from_u16(0x2A26);
88
89pub const DEVICE_NAME_CHAR_UUID: Uuid = uuid_from_u16(0x2A00);
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98#[repr(u8)]
99pub enum LockState {
100 Unlocked = 0x00,
102 Locked = 0x01,
104}
105
106impl LockState {
107 #[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 #[must_use]
119 pub const fn as_byte(self) -> u8 {
120 self as u8
121 }
122
123 #[must_use]
125 pub const fn is_locked(self) -> bool {
126 matches!(self, Self::Locked)
127 }
128
129 #[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#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DeviceInfo {
154 pub name: String,
156 pub firmware_version: String,
158 pub battery_level: u8,
160}
161
162pub const MANUFACTURER_ID: u16 = 0x0A0F;
164
165pub const DEVICE_NAME: &str = "Ohea Lock";
167
168#[must_use]
177pub fn build_init_command() -> [u8; 5] {
178 [0x1A, 0x01, 0x04, 0x00, 0x31]
179}
180
181pub const ATT_ERR_INSUFFICIENT_AUTH: u8 = 0x05;
187
188pub const ATT_ERR_ATTR_NOT_FOUND: u8 = 0x0A;
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
200 fn uuid_from_u16_constructs_correct_bluetooth_uuid() {
201 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 #[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 #[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 #[test]
331 fn device_name_matches_packet_capture() {
332 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 #[test]
347 fn init_command_matches_packet_capture() {
348 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 #[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}