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

1"""Bluetooth UUID utilities for handling 16-bit and 128-bit UUIDs.""" 

2 

3from __future__ import annotations 

4 

5import builtins 

6import re 

7from typing import Literal 

8 

9 

10class BluetoothUUID: 

11 """Bluetooth UUID class that handles both 16-bit and 128-bit UUIDs with automatic normalization and conversion. 

12 

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) 

18 

19 Provides automatic conversion between formats and consistent comparison. 

20 """ 

21 

22 # SIG base UUID suffix (everything after XXXX placeholder) 

23 SIG_BASE_SUFFIX = "00001000800000805F9B34FB" 

24 

25 # Bluetooth SIG base UUID for 16-bit to 128-bit conversion 

26 BASE_UUID = f"0000XXXX{SIG_BASE_SUFFIX}" 

27 

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}" 

34 

35 # SIG characteristic UUID ranges (from actual YAML data) 

36 SIG_CHARACTERISTIC_MIN = 0x2A00 # 10752 

37 SIG_CHARACTERISTIC_MAX = 0x2C24 # 11300 

38 

39 # SIG service UUID ranges (from actual YAML data) 

40 SIG_SERVICE_MIN = 0x1800 # 6144 

41 SIG_SERVICE_MAX = 0x185C # 6236 

42 

43 UUID_SHORT_LEN = 4 

44 UUID_32BIT_LEN = 8 

45 UUID_FULL_LEN = 32 

46 

47 _normalized: str 

48 

49 def __init__(self, uuid: str | int | BluetoothUUID) -> None: 

50 """Initialize BluetoothUUID from a UUID string or integer. 

51 

52 Args: 

53 uuid: UUID string in any valid format (short, full, dashed, hex-prefixed) or integer 

54 

55 Raises: 

56 ValueError: If UUID format is invalid 

57 

58 """ 

59 if isinstance(uuid, BluetoothUUID): 

60 self._normalized = uuid.normalized 

61 return 

62 

63 if isinstance(uuid, int): 

64 self._normalized = self._normalize_uuid_from_int(uuid) 

65 else: 

66 self._normalized = self._normalize_uuid(uuid) 

67 

68 # Validate the normalized form 

69 if not self._is_valid_normalized_uuid(self._normalized): 

70 raise ValueError(f"Invalid UUID format: {uuid}") 

71 

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 

78 

79 cleaned = uuid.replace("-", "").replace(" ", "").upper() 

80 if cleaned.startswith("0X"): 

81 cleaned = cleaned[2:] 

82 

83 # Validate it's hex 

84 if not re.match(r"^[0-9A-F]+$", cleaned): 

85 raise ValueError(f"Invalid UUID format: {uuid}") 

86 

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)") 

98 

99 @staticmethod 

100 def _normalize_uuid_from_int(uuid_int: int) -> str: 

101 """Normalize UUID from integer to uppercase hex string. 

102 

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 

106 

107 """ 

108 if uuid_int < 0: 

109 raise ValueError(f"UUID integer cannot be negative: {uuid_int}") 

110 

111 # Convert to hex and remove 0x prefix 

112 hex_str = hex(uuid_int)[2:].upper() 

113 

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) 

126 

127 raise ValueError(f"UUID integer too large: {uuid_int}") 

128 

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)) 

138 

139 @property 

140 def normalized(self) -> str: 

141 """Get normalized UUID (uppercase hex, no dashes, no 0x prefix).""" 

142 return self._normalized 

143 

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 

148 

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 

153 

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)}") 

162 

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)}") 

171 

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:]}" 

177 

178 @property 

179 def int_value(self) -> int: 

180 """Get UUID as integer value.""" 

181 return int(self._normalized, 16) 

182 

183 def to_bytes(self, byteorder: Literal["little", "big"] = "little") -> builtins.bytes: 

184 """Get UUID as 16-byte binary representation. 

185 

186 BLE advertising uses little-endian by default. 

187 

188 Args: 

189 byteorder: Byte order - "little" (default, for BLE) or "big". 

190 

191 Returns: 

192 16 bytes representing the full 128-bit UUID. 

193 

194 """ 

195 full_int = int(self.full_form, 16) 

196 return full_int.to_bytes(16, byteorder=byteorder) 

197 

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) 

202 

203 # Convert both to full form for comparison 

204 return self.full_form == other.full_form 

205 

206 def __str__(self) -> str: 

207 """String representation - uses dashed form for readability.""" 

208 return self.dashed_form 

209 

210 def __repr__(self) -> str: 

211 """Representation showing the normalized form.""" 

212 return f"BluetoothUUID('{self._normalized}')" 

213 

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) 

219 

220 def __hash__(self) -> int: 

221 """Hash based on full form for consistency with __eq__.""" 

222 return hash(self.full_form) 

223 

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 

229 

230 def __le__(self, other: str | BluetoothUUID) -> bool: 

231 """Less than or equal comparison.""" 

232 return self < other or self == other 

233 

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 

239 

240 def __ge__(self, other: str | BluetoothUUID) -> bool: 

241 """Greater than or equal comparison.""" 

242 return self > other or self == other 

243 

244 def __len__(self) -> int: 

245 """Return the length of the normalized UUID string.""" 

246 return len(self._normalized) 

247 

248 def is_valid_for_custom_characteristic(self) -> bool: 

249 """Check if this UUID is valid for custom characteristics. 

250 

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 

257 

258 """ 

259 return self.normalized not in ( 

260 self.INVALID_BASE_UUID_NORMALIZED, 

261 self.INVALID_NULL_UUID, 

262 self.INVALID_PLACEHOLDER_UUID, 

263 ) 

264 

265 def is_sig_characteristic(self) -> bool: 

266 """Check if this UUID is a Bluetooth SIG assigned characteristic UUID. 

267 

268 Based on actual SIG assigned numbers from characteristic_uuids.yaml. 

269 Range verified: 0x2A00 to 0x2C24 (and potentially expanding). 

270 

271 Returns: 

272 True if this is a SIG characteristic UUID, False otherwise 

273 

274 """ 

275 # Must be a full 128-bit UUID using SIG base UUID pattern 

276 if not self.is_full: 

277 return False 

278 

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 

282 

283 # Must start with "0000" to be a proper SIG UUID 

284 if not self.normalized.startswith("0000"): 

285 return False 

286 

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 

292 

293 # Check if it's in the SIG characteristic range using constants 

294 return self.SIG_CHARACTERISTIC_MIN <= uuid_int <= self.SIG_CHARACTERISTIC_MAX 

295 

296 def is_sig_service(self) -> bool: 

297 """Check if this UUID is a Bluetooth SIG assigned service UUID. 

298 

299 Based on actual SIG assigned numbers from service_uuids.yaml. 

300 Range verified: 0x1800 to 0x185C (and potentially expanding). 

301 

302 Returns: 

303 True if this is a SIG service UUID, False otherwise 

304 

305 """ 

306 # Must be a full 128-bit UUID using SIG base UUID pattern 

307 if not self.is_full: 

308 return False 

309 

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 

313 

314 # Must start with "0000" to be a proper SIG UUID 

315 if not self.normalized.startswith("0000"): 

316 return False 

317 

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 

323 

324 # Check if it's in the SIG service range using constants 

325 return self.SIG_SERVICE_MIN <= uuid_int <= self.SIG_SERVICE_MAX