Coverage for src / bluetooth_sig / types / uuid.py: 79%
150 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Bluetooth UUID utilities for handling 16-bit and 128-bit UUIDs."""
3from __future__ import annotations
5import builtins
6import re
7from typing import Literal
10class BluetoothUUID:
11 """Bluetooth UUID class that handles both 16-bit and 128-bit UUIDs with automatic normalization and conversion.
13 Supports various input formats:
14 - Short form: "180F", "0x180F", "180f"
15 - Full form: "0000180F-0000-1000-8000-00805F9B34FB"
16 - Normalized: "0000180F00001000800000805F9B34FB"
17 - Integer: 6159 (for 16-bit) or large integer (for 128-bit)
19 Provides automatic conversion between formats and consistent comparison.
20 """
22 # SIG base UUID suffix (everything after XXXX placeholder)
23 SIG_BASE_SUFFIX = "00001000800000805F9B34FB"
25 # Bluetooth SIG base UUID for 16-bit to 128-bit conversion
26 BASE_UUID = f"0000XXXX{SIG_BASE_SUFFIX}"
28 # UUID validation constants
29 INVALID_SHORT_UUID = "0000"
30 INVALID_BASE_UUID_DASHED = "00000000-0000-1000-8000-00805f9b34fb"
31 INVALID_BASE_UUID_NORMALIZED = f"00000000{SIG_BASE_SUFFIX}"
32 INVALID_NULL_UUID = "0000000000000000000000000000"
33 INVALID_PLACEHOLDER_UUID = f"00001234{SIG_BASE_SUFFIX}"
35 # SIG characteristic UUID ranges (from actual YAML data)
36 SIG_CHARACTERISTIC_MIN = 0x2A00 # 10752
37 SIG_CHARACTERISTIC_MAX = 0x2C24 # 11300
39 # SIG service UUID ranges (from actual YAML data)
40 SIG_SERVICE_MIN = 0x1800 # 6144
41 SIG_SERVICE_MAX = 0x185C # 6236
43 UUID_SHORT_LEN = 4
44 UUID_32BIT_LEN = 8
45 UUID_FULL_LEN = 32
47 _normalized: str
49 def __init__(self, uuid: str | int | BluetoothUUID) -> None:
50 """Initialize BluetoothUUID from a UUID string or integer.
52 Args:
53 uuid: UUID string in any valid format (short, full, dashed, hex-prefixed) or integer
55 Raises:
56 ValueError: If UUID format is invalid
58 """
59 if isinstance(uuid, BluetoothUUID):
60 self._normalized = uuid.normalized
61 return
63 if isinstance(uuid, int):
64 self._normalized = self._normalize_uuid_from_int(uuid)
65 else:
66 self._normalized = self._normalize_uuid(uuid)
68 # Validate the normalized form
69 if not self._is_valid_normalized_uuid(self._normalized):
70 raise ValueError(f"Invalid UUID format: {uuid}")
72 @staticmethod
73 def _normalize_uuid(uuid: str | BluetoothUUID) -> str:
74 """Normalize UUID to uppercase hex without dashes or 0x prefix."""
75 if isinstance(uuid, BluetoothUUID):
76 # Access to protected attribute is intentional for self-reference normalization
77 return uuid._normalized # pylint: disable=protected-access
79 cleaned = uuid.replace("-", "").replace(" ", "").upper()
80 if cleaned.startswith("0X"):
81 cleaned = cleaned[2:]
83 # Validate it's hex
84 if not re.match(r"^[0-9A-F]+$", cleaned):
85 raise ValueError(f"Invalid UUID format: {uuid}")
87 # Determine if it's 16-bit, 32-bit, or 128-bit
88 if len(cleaned) == BluetoothUUID.UUID_SHORT_LEN:
89 # 16-bit UUID - expand to 128-bit using SIG base
90 return f"0000{cleaned}{BluetoothUUID.SIG_BASE_SUFFIX}"
91 if len(cleaned) == BluetoothUUID.UUID_32BIT_LEN:
92 # 32-bit UUID - expand to 128-bit using SIG base
93 return f"{cleaned}{BluetoothUUID.SIG_BASE_SUFFIX}"
94 if len(cleaned) == BluetoothUUID.UUID_FULL_LEN:
95 # Already 128-bit
96 return cleaned
97 raise ValueError(f"Invalid UUID length: {len(cleaned)} (expected 4, 8, or 32 characters)")
99 @staticmethod
100 def _normalize_uuid_from_int(uuid_int: int) -> str:
101 """Normalize UUID from integer to uppercase hex string.
103 16-bit and 32-bit UUIDs are expanded using the Bluetooth SIG base UUID:
104 - 16-bit 0x180F becomes 0000180F-0000-1000-8000-00805F9B34FB
105 - 32-bit 0x12345678 becomes 12345678-0000-1000-8000-00805F9B34FB
107 """
108 if uuid_int < 0:
109 raise ValueError(f"UUID integer cannot be negative: {uuid_int}")
111 # Convert to hex and remove 0x prefix
112 hex_str = hex(uuid_int)[2:].upper()
114 # Determine UUID type and expand appropriately
115 if len(hex_str) <= BluetoothUUID.UUID_SHORT_LEN:
116 # 16-bit UUID - expand to full 128-bit using SIG base
117 hex_str = hex_str.zfill(BluetoothUUID.UUID_SHORT_LEN)
118 return f"0000{hex_str}{BluetoothUUID.SIG_BASE_SUFFIX}"
119 if len(hex_str) <= BluetoothUUID.UUID_32BIT_LEN:
120 # 32-bit UUID - expand to full 128-bit using SIG base
121 hex_str = hex_str.zfill(BluetoothUUID.UUID_32BIT_LEN)
122 return f"{hex_str}{BluetoothUUID.SIG_BASE_SUFFIX}"
123 if len(hex_str) <= BluetoothUUID.UUID_FULL_LEN:
124 # 128-bit UUID
125 return hex_str.zfill(BluetoothUUID.UUID_FULL_LEN)
127 raise ValueError(f"UUID integer too large: {uuid_int}")
129 @staticmethod
130 def _is_valid_normalized_uuid(normalized: str) -> bool:
131 """Check if normalized UUID string is valid."""
132 valid_lengths = (
133 BluetoothUUID.UUID_SHORT_LEN,
134 BluetoothUUID.UUID_32BIT_LEN,
135 BluetoothUUID.UUID_FULL_LEN,
136 )
137 return len(normalized) in valid_lengths and bool(re.match(r"^[0-9A-F]+$", normalized))
139 @property
140 def normalized(self) -> str:
141 """Get normalized UUID (uppercase hex, no dashes, no 0x prefix)."""
142 return self._normalized
144 @property
145 def is_short(self) -> bool:
146 """Check if this is a 16-bit (short) UUID."""
147 return len(self._normalized) == self.UUID_SHORT_LEN
149 @property
150 def is_full(self) -> bool:
151 """Check if this is a 128-bit (full) UUID."""
152 return len(self._normalized) == self.UUID_FULL_LEN
154 @property
155 def short_form(self) -> str:
156 """Get 16-bit short form (e.g., '180F')."""
157 if self.is_short:
158 return self._normalized
159 if self.is_full:
160 return self._normalized[4:8]
161 raise ValueError(f"Invalid UUID length: {len(self._normalized)}")
163 @property
164 def full_form(self) -> str:
165 """Get 128-bit full form with Bluetooth base UUID."""
166 if self.is_full:
167 return self._normalized
168 if self.is_short:
169 return f"0000{self._normalized}{self.SIG_BASE_SUFFIX}"
170 raise ValueError(f"Invalid UUID length: {len(self._normalized)}")
172 @property
173 def dashed_form(self) -> str:
174 """Get UUID in standard dashed format (e.g., '0000180F-0000-1000-8000-00805F9B34FB')."""
175 full = self.full_form
176 return f"{full[:8]}-{full[8:12]}-{full[12:16]}-{full[16:20]}-{full[20:]}"
178 @property
179 def int_value(self) -> int:
180 """Get UUID as integer value."""
181 return int(self._normalized, 16)
183 def to_bytes(self, byteorder: Literal["little", "big"] = "little") -> builtins.bytes:
184 """Get UUID as 16-byte binary representation.
186 BLE advertising uses little-endian by default.
188 Args:
189 byteorder: Byte order - "little" (default, for BLE) or "big".
191 Returns:
192 16 bytes representing the full 128-bit UUID.
194 """
195 full_int = int(self.full_form, 16)
196 return full_int.to_bytes(16, byteorder=byteorder)
198 def matches(self, other: str | BluetoothUUID) -> bool:
199 """Check if this UUID matches another UUID (handles format conversion automatically)."""
200 if not isinstance(other, BluetoothUUID):
201 other = BluetoothUUID(other)
203 # Convert both to full form for comparison
204 return self.full_form == other.full_form
206 def __str__(self) -> str:
207 """String representation - uses dashed form for readability."""
208 return self.dashed_form
210 def __repr__(self) -> str:
211 """Representation showing the normalized form."""
212 return f"BluetoothUUID('{self._normalized}')"
214 def __eq__(self, other: object) -> bool:
215 """Check equality with another UUID."""
216 if not isinstance(other, (str, BluetoothUUID)):
217 return NotImplemented
218 return self.matches(other)
220 def __hash__(self) -> int:
221 """Hash based on full form for consistency with __eq__."""
222 return hash(self.full_form)
224 def __lt__(self, other: str | BluetoothUUID) -> bool:
225 """Less than comparison."""
226 if not isinstance(other, BluetoothUUID):
227 other = BluetoothUUID(other)
228 return self._normalized < other._normalized
230 def __le__(self, other: str | BluetoothUUID) -> bool:
231 """Less than or equal comparison."""
232 return self < other or self == other
234 def __gt__(self, other: str | BluetoothUUID) -> bool:
235 """Greater than comparison."""
236 if not isinstance(other, BluetoothUUID):
237 other = BluetoothUUID(other)
238 return self._normalized > other._normalized
240 def __ge__(self, other: str | BluetoothUUID) -> bool:
241 """Greater than or equal comparison."""
242 return self > other or self == other
244 def __len__(self) -> int:
245 """Return the length of the normalized UUID string."""
246 return len(self._normalized)
248 def is_valid_for_custom_characteristic(self) -> bool:
249 """Check if this UUID is valid for custom characteristics.
251 Returns:
252 False if the UUID is any of the invalid/reserved UUIDs:
253 - Base UUID (00000000-0000-1000-8000-00805f9b34fb)
254 - Null UUID (all zeros)
255 - Placeholder UUID (used internally)
256 True otherwise
258 """
259 return self.normalized not in (
260 self.INVALID_BASE_UUID_NORMALIZED,
261 self.INVALID_NULL_UUID,
262 self.INVALID_PLACEHOLDER_UUID,
263 )
265 def is_sig_characteristic(self) -> bool:
266 """Check if this UUID is a Bluetooth SIG assigned characteristic UUID.
268 Based on actual SIG assigned numbers from characteristic_uuids.yaml.
269 Range verified: 0x2A00 to 0x2C24 (and potentially expanding).
271 Returns:
272 True if this is a SIG characteristic UUID, False otherwise
274 """
275 # Must be a full 128-bit UUID using SIG base UUID pattern
276 if not self.is_full:
277 return False
279 # Check if it uses the SIG base UUID pattern by comparing with our constant
280 if not self.normalized.endswith(self.SIG_BASE_SUFFIX):
281 return False
283 # Must start with "0000" to be a proper SIG UUID
284 if not self.normalized.startswith("0000"):
285 return False
287 try:
288 # Use existing short_form property instead of manual string slicing
289 uuid_int = int(self.short_form, 16)
290 except ValueError:
291 return False
293 # Check if it's in the SIG characteristic range using constants
294 return self.SIG_CHARACTERISTIC_MIN <= uuid_int <= self.SIG_CHARACTERISTIC_MAX
296 def is_sig_service(self) -> bool:
297 """Check if this UUID is a Bluetooth SIG assigned service UUID.
299 Based on actual SIG assigned numbers from service_uuids.yaml.
300 Range verified: 0x1800 to 0x185C (and potentially expanding).
302 Returns:
303 True if this is a SIG service UUID, False otherwise
305 """
306 # Must be a full 128-bit UUID using SIG base UUID pattern
307 if not self.is_full:
308 return False
310 # Check if it uses the SIG base UUID pattern by comparing with our constant
311 if not self.normalized.endswith(self.SIG_BASE_SUFFIX):
312 return False
314 # Must start with "0000" to be a proper SIG UUID
315 if not self.normalized.startswith("0000"):
316 return False
318 try:
319 # Use existing short_form property instead of manual string slicing
320 uuid_int = int(self.short_form, 16)
321 except ValueError:
322 return False
324 # Check if it's in the SIG service range using constants
325 return self.SIG_SERVICE_MIN <= uuid_int <= self.SIG_SERVICE_MAX