Coverage for src/bluetooth_sig/types/uuid.py: 80%
143 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +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) -> 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, int):
56 self._normalized = self._normalize_uuid_from_int(uuid)
57 else:
58 self._normalized = self._normalize_uuid(uuid)
60 # Validate the normalized form
61 if not self._is_valid_normalized_uuid(self._normalized):
62 raise ValueError(f"Invalid UUID format: {uuid}")
64 @staticmethod
65 def _normalize_uuid(uuid: str | BluetoothUUID) -> str:
66 """Normalize UUID to uppercase hex without dashes or 0x prefix."""
67 if isinstance(uuid, BluetoothUUID):
68 # Access to protected attribute is intentional for self-reference normalization
69 return uuid._normalized # pylint: disable=protected-access
71 cleaned = uuid.replace("-", "").replace(" ", "").upper()
72 if cleaned.startswith("0X"):
73 cleaned = cleaned[2:]
75 # Validate it's hex
76 if not re.match(r"^[0-9A-F]+$", cleaned):
77 raise ValueError(f"Invalid UUID format: {uuid}")
79 # Determine if it's 16-bit or 128-bit
80 if len(cleaned) == BluetoothUUID.UUID_SHORT_LEN:
81 # 16-bit UUID - expand to 128-bit
82 return f"0000{cleaned}{BluetoothUUID.SIG_BASE_SUFFIX}"
83 if len(cleaned) == BluetoothUUID.UUID_FULL_LEN:
84 # Already 128-bit
85 return cleaned
86 raise ValueError(f"Invalid UUID length: {len(cleaned)} (expected 4 or 32 characters)")
88 @staticmethod
89 def _normalize_uuid_from_int(uuid_int: int) -> str:
90 """Normalize UUID from integer to uppercase hex string."""
91 if uuid_int < 0:
92 raise ValueError(f"UUID integer cannot be negative: {uuid_int}")
94 # Convert to hex and remove 0x prefix
95 hex_str = hex(uuid_int)[2:].upper()
97 # Pad to appropriate length
98 if len(hex_str) <= BluetoothUUID.UUID_SHORT_LEN:
99 # 16-bit UUID
100 hex_str = hex_str.zfill(BluetoothUUID.UUID_SHORT_LEN)
101 elif len(hex_str) <= BluetoothUUID.UUID_FULL_LEN:
102 # 128-bit UUID
103 hex_str = hex_str.zfill(BluetoothUUID.UUID_FULL_LEN)
104 else:
105 raise ValueError(f"UUID integer too large: {uuid_int}")
107 return hex_str
109 @staticmethod
110 def _is_valid_normalized_uuid(normalized: str) -> bool:
111 """Check if normalized UUID string is valid."""
112 return len(normalized) in (BluetoothUUID.UUID_SHORT_LEN, BluetoothUUID.UUID_FULL_LEN) and bool(
113 re.match(r"^[0-9A-F]+$", normalized)
114 )
116 @property
117 def normalized(self) -> str:
118 """Get normalized UUID (uppercase hex, no dashes, no 0x prefix)."""
119 return self._normalized
121 @property
122 def is_short(self) -> bool:
123 """Check if this is a 16-bit (short) UUID."""
124 return len(self._normalized) == self.UUID_SHORT_LEN
126 @property
127 def is_full(self) -> bool:
128 """Check if this is a 128-bit (full) UUID."""
129 return len(self._normalized) == self.UUID_FULL_LEN
131 @property
132 def short_form(self) -> str:
133 """Get 16-bit short form (e.g., '180F')."""
134 if self.is_short:
135 return self._normalized
136 if self.is_full:
137 return self._normalized[4:8]
138 raise ValueError(f"Invalid UUID length: {len(self._normalized)}")
140 @property
141 def full_form(self) -> str:
142 """Get 128-bit full form with Bluetooth base UUID."""
143 if self.is_full:
144 return self._normalized
145 if self.is_short:
146 return f"0000{self._normalized}{self.SIG_BASE_SUFFIX}"
147 raise ValueError(f"Invalid UUID length: {len(self._normalized)}")
149 @property
150 def dashed_form(self) -> str:
151 """Get UUID in standard dashed format (e.g., '0000180F-0000-1000-8000-00805F9B34FB')."""
152 full = self.full_form
153 return f"{full[:8]}-{full[8:12]}-{full[12:16]}-{full[16:20]}-{full[20:]}"
155 @property
156 def int_value(self) -> int:
157 """Get UUID as integer value."""
158 return int(self._normalized, 16)
160 @property
161 def bytes(self) -> builtins.bytes:
162 """Get UUID as 16-byte binary representation (big-endian).
164 Useful for BLE wire protocol operations where UUIDs need to be
165 transmitted in binary format.
167 Returns:
168 16 bytes representing the full 128-bit UUID in big-endian byte order
170 """
171 # Always use full form (128-bit) for bytes representation
172 full_int = int(self.full_form, 16)
173 return full_int.to_bytes(16, byteorder="big")
175 @property
176 def bytes_le(self) -> builtins.bytes:
177 """Get UUID as 16-byte binary representation (little-endian).
179 Some BLE operations require little-endian byte order.
181 Returns:
182 16 bytes representing the full 128-bit UUID in little-endian byte order
184 """
185 # Always use full form (128-bit) for bytes representation
186 full_int = int(self.full_form, 16)
187 return full_int.to_bytes(16, byteorder="little")
189 def matches(self, other: str | BluetoothUUID) -> bool:
190 """Check if this UUID matches another UUID (handles format conversion automatically)."""
191 if not isinstance(other, BluetoothUUID):
192 other = BluetoothUUID(other)
194 # Convert both to full form for comparison
195 return self.full_form == other.full_form
197 def __str__(self) -> str:
198 """String representation - uses dashed form for readability."""
199 return self.dashed_form
201 def __repr__(self) -> str:
202 """Representation showing the normalized form."""
203 return f"BluetoothUUID('{self._normalized}')"
205 def __eq__(self, other: object) -> bool:
206 """Check equality with another UUID."""
207 if not isinstance(other, (str, BluetoothUUID)):
208 return NotImplemented
209 return self.matches(other)
211 def __hash__(self) -> int:
212 """Hash based on normalized form."""
213 return hash(self._normalized)
215 def __lt__(self, other: str | BluetoothUUID) -> bool:
216 """Less than comparison."""
217 if not isinstance(other, BluetoothUUID):
218 other = BluetoothUUID(other)
219 return self._normalized < other._normalized
221 def __le__(self, other: str | BluetoothUUID) -> bool:
222 """Less than or equal comparison."""
223 return self < other or self == other
225 def __gt__(self, other: str | BluetoothUUID) -> bool:
226 """Greater than comparison."""
227 if not isinstance(other, BluetoothUUID):
228 other = BluetoothUUID(other)
229 return self._normalized > other._normalized
231 def __ge__(self, other: str | BluetoothUUID) -> bool:
232 """Greater than or equal comparison."""
233 return self > other or self == other
235 def __len__(self) -> int:
236 """Return the length of the normalized UUID string."""
237 return len(self._normalized)
239 def is_valid_for_custom_characteristic(self) -> bool:
240 """Check if this UUID is valid for custom characteristics.
242 Returns:
243 False if the UUID is any of the invalid/reserved UUIDs:
244 - Base UUID (00000000-0000-1000-8000-00805f9b34fb)
245 - Null UUID (all zeros)
246 - Placeholder UUID (used internally)
247 True otherwise
249 """
250 return self.normalized not in (
251 self.INVALID_BASE_UUID_NORMALIZED,
252 self.INVALID_NULL_UUID,
253 self.INVALID_PLACEHOLDER_UUID,
254 )
256 def is_sig_characteristic(self) -> bool:
257 """Check if this UUID is a Bluetooth SIG assigned characteristic UUID.
259 Based on actual SIG assigned numbers from characteristic_uuids.yaml.
260 Range verified: 0x2A00 to 0x2C24 (and potentially expanding).
262 Returns:
263 True if this is a SIG characteristic UUID, False otherwise
265 """
266 # Must be a full 128-bit UUID using SIG base UUID pattern
267 if not self.is_full:
268 return False
270 # Check if it uses the SIG base UUID pattern by comparing with our constant
271 if not self.normalized.endswith(self.SIG_BASE_SUFFIX):
272 return False
274 # Must start with "0000" to be a proper SIG UUID
275 if not self.normalized.startswith("0000"):
276 return False
278 try:
279 # Use existing short_form property instead of manual string slicing
280 uuid_int = int(self.short_form, 16)
282 # Check if it's in the SIG characteristic range using constants
283 return self.SIG_CHARACTERISTIC_MIN <= uuid_int <= self.SIG_CHARACTERISTIC_MAX
284 except ValueError:
285 return False
287 def is_sig_service(self) -> bool:
288 """Check if this UUID is a Bluetooth SIG assigned service UUID.
290 Based on actual SIG assigned numbers from service_uuids.yaml.
291 Range verified: 0x1800 to 0x185C (and potentially expanding).
293 Returns:
294 True if this is a SIG service UUID, False otherwise
296 """
297 # Must be a full 128-bit UUID using SIG base UUID pattern
298 if not self.is_full:
299 return False
301 # Check if it uses the SIG base UUID pattern by comparing with our constant
302 if not self.normalized.endswith(self.SIG_BASE_SUFFIX):
303 return False
305 # Must start with "0000" to be a proper SIG UUID
306 if not self.normalized.startswith("0000"):
307 return False
309 try:
310 # Use existing short_form property instead of manual string slicing
311 uuid_int = int(self.short_form, 16)
313 # Check if it's in the SIG service range using constants
314 return self.SIG_SERVICE_MIN <= uuid_int <= self.SIG_SERVICE_MAX
315 except ValueError:
316 return False