Coverage for src / bluetooth_sig / types / uuid.py: 83%
145 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Bluetooth UUID utilities for handling 16-bit and 128-bit UUIDs."""
3from __future__ import annotations
5import builtins
6import re
9class BluetoothUUID:
10 """Bluetooth UUID class that handles both 16-bit and 128-bit UUIDs with automatic normalization and conversion.
12 Supports various input formats:
13 - Short form: "180F", "0x180F", "180f"
14 - Full form: "0000180F-0000-1000-8000-00805F9B34FB"
15 - Normalized: "0000180F00001000800000805F9B34FB"
16 - Integer: 6159 (for 16-bit) or large integer (for 128-bit)
18 Provides automatic conversion between formats and consistent comparison.
19 """
21 # SIG base UUID suffix (everything after XXXX placeholder)
22 SIG_BASE_SUFFIX = "00001000800000805F9B34FB"
24 # Bluetooth SIG base UUID for 16-bit to 128-bit conversion
25 BASE_UUID = f"0000XXXX{SIG_BASE_SUFFIX}"
27 # UUID validation constants
28 INVALID_SHORT_UUID = "0000"
29 INVALID_BASE_UUID_DASHED = "00000000-0000-1000-8000-00805f9b34fb"
30 INVALID_BASE_UUID_NORMALIZED = f"00000000{SIG_BASE_SUFFIX}"
31 INVALID_NULL_UUID = "0000000000000000000000000000"
32 INVALID_PLACEHOLDER_UUID = f"00001234{SIG_BASE_SUFFIX}"
34 # SIG characteristic UUID ranges (from actual YAML data)
35 SIG_CHARACTERISTIC_MIN = 0x2A00 # 10752
36 SIG_CHARACTERISTIC_MAX = 0x2C24 # 11300
38 # SIG service UUID ranges (from actual YAML data)
39 SIG_SERVICE_MIN = 0x1800 # 6144
40 SIG_SERVICE_MAX = 0x185C # 6236
42 UUID_SHORT_LEN = 4
43 UUID_FULL_LEN = 32
45 def __init__(self, uuid: str | int | BluetoothUUID) -> None:
46 """Initialize BluetoothUUID from a UUID string or integer.
48 Args:
49 uuid: UUID string in any valid format (short, full, dashed, hex-prefixed) or integer
51 Raises:
52 ValueError: If UUID format is invalid
54 """
55 if isinstance(uuid, BluetoothUUID):
56 return
58 if isinstance(uuid, int):
59 self._normalized = self._normalize_uuid_from_int(uuid)
60 else:
61 self._normalized = self._normalize_uuid(uuid)
63 # Validate the normalized form
64 if not self._is_valid_normalized_uuid(self._normalized):
65 raise ValueError(f"Invalid UUID format: {uuid}")
67 @staticmethod
68 def _normalize_uuid(uuid: str | BluetoothUUID) -> str:
69 """Normalize UUID to uppercase hex without dashes or 0x prefix."""
70 if isinstance(uuid, BluetoothUUID):
71 # Access to protected attribute is intentional for self-reference normalization
72 return uuid._normalized # pylint: disable=protected-access
74 cleaned = uuid.replace("-", "").replace(" ", "").upper()
75 if cleaned.startswith("0X"):
76 cleaned = cleaned[2:]
78 # Validate it's hex
79 if not re.match(r"^[0-9A-F]+$", cleaned):
80 raise ValueError(f"Invalid UUID format: {uuid}")
82 # Determine if it's 16-bit or 128-bit
83 if len(cleaned) == BluetoothUUID.UUID_SHORT_LEN:
84 # 16-bit UUID - expand to 128-bit
85 return f"0000{cleaned}{BluetoothUUID.SIG_BASE_SUFFIX}"
86 if len(cleaned) == BluetoothUUID.UUID_FULL_LEN:
87 # Already 128-bit
88 return cleaned
89 raise ValueError(f"Invalid UUID length: {len(cleaned)} (expected 4 or 32 characters)")
91 @staticmethod
92 def _normalize_uuid_from_int(uuid_int: int) -> str:
93 """Normalize UUID from integer to uppercase hex string."""
94 if uuid_int < 0:
95 raise ValueError(f"UUID integer cannot be negative: {uuid_int}")
97 # Convert to hex and remove 0x prefix
98 hex_str = hex(uuid_int)[2:].upper()
100 # Pad to appropriate length
101 if len(hex_str) <= BluetoothUUID.UUID_SHORT_LEN:
102 # 16-bit UUID
103 hex_str = hex_str.zfill(BluetoothUUID.UUID_SHORT_LEN)
104 elif len(hex_str) <= BluetoothUUID.UUID_FULL_LEN:
105 # 128-bit UUID
106 hex_str = hex_str.zfill(BluetoothUUID.UUID_FULL_LEN)
107 else:
108 raise ValueError(f"UUID integer too large: {uuid_int}")
110 return hex_str
112 @staticmethod
113 def _is_valid_normalized_uuid(normalized: str) -> bool:
114 """Check if normalized UUID string is valid."""
115 return len(normalized) in (BluetoothUUID.UUID_SHORT_LEN, BluetoothUUID.UUID_FULL_LEN) and bool(
116 re.match(r"^[0-9A-F]+$", normalized)
117 )
119 @property
120 def normalized(self) -> str:
121 """Get normalized UUID (uppercase hex, no dashes, no 0x prefix)."""
122 return self._normalized
124 @property
125 def is_short(self) -> bool:
126 """Check if this is a 16-bit (short) UUID."""
127 return len(self._normalized) == self.UUID_SHORT_LEN
129 @property
130 def is_full(self) -> bool:
131 """Check if this is a 128-bit (full) UUID."""
132 return len(self._normalized) == self.UUID_FULL_LEN
134 @property
135 def short_form(self) -> str:
136 """Get 16-bit short form (e.g., '180F')."""
137 if self.is_short:
138 return self._normalized
139 if self.is_full:
140 return self._normalized[4:8]
141 raise ValueError(f"Invalid UUID length: {len(self._normalized)}")
143 @property
144 def full_form(self) -> str:
145 """Get 128-bit full form with Bluetooth base UUID."""
146 if self.is_full:
147 return self._normalized
148 if self.is_short:
149 return f"0000{self._normalized}{self.SIG_BASE_SUFFIX}"
150 raise ValueError(f"Invalid UUID length: {len(self._normalized)}")
152 @property
153 def dashed_form(self) -> str:
154 """Get UUID in standard dashed format (e.g., '0000180F-0000-1000-8000-00805F9B34FB')."""
155 full = self.full_form
156 return f"{full[:8]}-{full[8:12]}-{full[12:16]}-{full[16:20]}-{full[20:]}"
158 @property
159 def int_value(self) -> int:
160 """Get UUID as integer value."""
161 return int(self._normalized, 16)
163 @property
164 def bytes(self) -> builtins.bytes:
165 """Get UUID as 16-byte binary representation (big-endian).
167 Useful for BLE wire protocol operations where UUIDs need to be
168 transmitted in binary format.
170 Returns:
171 16 bytes representing the full 128-bit UUID in big-endian byte order
173 """
174 # Always use full form (128-bit) for bytes representation
175 full_int = int(self.full_form, 16)
176 return full_int.to_bytes(16, byteorder="big")
178 @property
179 def bytes_le(self) -> builtins.bytes:
180 """Get UUID as 16-byte binary representation (little-endian).
182 Some BLE operations require little-endian byte order.
184 Returns:
185 16 bytes representing the full 128-bit UUID in little-endian byte order
187 """
188 # Always use full form (128-bit) for bytes representation
189 full_int = int(self.full_form, 16)
190 return full_int.to_bytes(16, byteorder="little")
192 def matches(self, other: str | BluetoothUUID) -> bool:
193 """Check if this UUID matches another UUID (handles format conversion automatically)."""
194 if not isinstance(other, BluetoothUUID):
195 other = BluetoothUUID(other)
197 # Convert both to full form for comparison
198 return self.full_form == other.full_form
200 def __str__(self) -> str:
201 """String representation - uses dashed form for readability."""
202 return self.dashed_form
204 def __repr__(self) -> str:
205 """Representation showing the normalized form."""
206 return f"BluetoothUUID('{self._normalized}')"
208 def __eq__(self, other: object) -> bool:
209 """Check equality with another UUID."""
210 if not isinstance(other, (str, BluetoothUUID)):
211 return NotImplemented
212 return self.matches(other)
214 def __hash__(self) -> int:
215 """Hash based on full form for consistency with __eq__."""
216 return hash(self.full_form)
218 def __lt__(self, other: str | BluetoothUUID) -> bool:
219 """Less than comparison."""
220 if not isinstance(other, BluetoothUUID):
221 other = BluetoothUUID(other)
222 return self._normalized < other._normalized
224 def __le__(self, other: str | BluetoothUUID) -> bool:
225 """Less than or equal comparison."""
226 return self < other or self == other
228 def __gt__(self, other: str | BluetoothUUID) -> bool:
229 """Greater than comparison."""
230 if not isinstance(other, BluetoothUUID):
231 other = BluetoothUUID(other)
232 return self._normalized > other._normalized
234 def __ge__(self, other: str | BluetoothUUID) -> bool:
235 """Greater than or equal comparison."""
236 return self > other or self == other
238 def __len__(self) -> int:
239 """Return the length of the normalized UUID string."""
240 return len(self._normalized)
242 def is_valid_for_custom_characteristic(self) -> bool:
243 """Check if this UUID is valid for custom characteristics.
245 Returns:
246 False if the UUID is any of the invalid/reserved UUIDs:
247 - Base UUID (00000000-0000-1000-8000-00805f9b34fb)
248 - Null UUID (all zeros)
249 - Placeholder UUID (used internally)
250 True otherwise
252 """
253 return self.normalized not in (
254 self.INVALID_BASE_UUID_NORMALIZED,
255 self.INVALID_NULL_UUID,
256 self.INVALID_PLACEHOLDER_UUID,
257 )
259 def is_sig_characteristic(self) -> bool:
260 """Check if this UUID is a Bluetooth SIG assigned characteristic UUID.
262 Based on actual SIG assigned numbers from characteristic_uuids.yaml.
263 Range verified: 0x2A00 to 0x2C24 (and potentially expanding).
265 Returns:
266 True if this is a SIG characteristic UUID, False otherwise
268 """
269 # Must be a full 128-bit UUID using SIG base UUID pattern
270 if not self.is_full:
271 return False
273 # Check if it uses the SIG base UUID pattern by comparing with our constant
274 if not self.normalized.endswith(self.SIG_BASE_SUFFIX):
275 return False
277 # Must start with "0000" to be a proper SIG UUID
278 if not self.normalized.startswith("0000"):
279 return False
281 try:
282 # Use existing short_form property instead of manual string slicing
283 uuid_int = int(self.short_form, 16)
285 # Check if it's in the SIG characteristic range using constants
286 return self.SIG_CHARACTERISTIC_MIN <= uuid_int <= self.SIG_CHARACTERISTIC_MAX
287 except ValueError:
288 return False
290 def is_sig_service(self) -> bool:
291 """Check if this UUID is a Bluetooth SIG assigned service UUID.
293 Based on actual SIG assigned numbers from service_uuids.yaml.
294 Range verified: 0x1800 to 0x185C (and potentially expanding).
296 Returns:
297 True if this is a SIG service UUID, False otherwise
299 """
300 # Must be a full 128-bit UUID using SIG base UUID pattern
301 if not self.is_full:
302 return False
304 # Check if it uses the SIG base UUID pattern by comparing with our constant
305 if not self.normalized.endswith(self.SIG_BASE_SUFFIX):
306 return False
308 # Must start with "0000" to be a proper SIG UUID
309 if not self.normalized.startswith("0000"):
310 return False
312 try:
313 # Use existing short_form property instead of manual string slicing
314 uuid_int = int(self.short_form, 16)
316 # Check if it's in the SIG service range using constants
317 return self.SIG_SERVICE_MIN <= uuid_int <= self.SIG_SERVICE_MAX
318 except ValueError:
319 return False