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

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

2 

3from __future__ import annotations 

4 

5import builtins 

6import re 

7 

8 

9class BluetoothUUID: 

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

11 

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) 

17 

18 Provides automatic conversion between formats and consistent comparison. 

19 """ 

20 

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

22 SIG_BASE_SUFFIX = "00001000800000805F9B34FB" 

23 

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

25 BASE_UUID = f"0000XXXX{SIG_BASE_SUFFIX}" 

26 

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

33 

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

35 SIG_CHARACTERISTIC_MIN = 0x2A00 # 10752 

36 SIG_CHARACTERISTIC_MAX = 0x2C24 # 11300 

37 

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

39 SIG_SERVICE_MIN = 0x1800 # 6144 

40 SIG_SERVICE_MAX = 0x185C # 6236 

41 

42 UUID_SHORT_LEN = 4 

43 UUID_FULL_LEN = 32 

44 

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

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

47 

48 Args: 

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

50 

51 Raises: 

52 ValueError: If UUID format is invalid 

53 

54 """ 

55 if isinstance(uuid, int): 

56 self._normalized = self._normalize_uuid_from_int(uuid) 

57 else: 

58 self._normalized = self._normalize_uuid(uuid) 

59 

60 # Validate the normalized form 

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

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

63 

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 

70 

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

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

73 cleaned = cleaned[2:] 

74 

75 # Validate it's hex 

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

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

78 

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

87 

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

93 

94 # Convert to hex and remove 0x prefix 

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

96 

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

106 

107 return hex_str 

108 

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 ) 

115 

116 @property 

117 def normalized(self) -> str: 

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

119 return self._normalized 

120 

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 

125 

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 

130 

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

139 

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

148 

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

154 

155 @property 

156 def int_value(self) -> int: 

157 """Get UUID as integer value.""" 

158 return int(self._normalized, 16) 

159 

160 @property 

161 def bytes(self) -> builtins.bytes: 

162 """Get UUID as 16-byte binary representation (big-endian). 

163 

164 Useful for BLE wire protocol operations where UUIDs need to be 

165 transmitted in binary format. 

166 

167 Returns: 

168 16 bytes representing the full 128-bit UUID in big-endian byte order 

169 

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

174 

175 @property 

176 def bytes_le(self) -> builtins.bytes: 

177 """Get UUID as 16-byte binary representation (little-endian). 

178 

179 Some BLE operations require little-endian byte order. 

180 

181 Returns: 

182 16 bytes representing the full 128-bit UUID in little-endian byte order 

183 

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

188 

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) 

193 

194 # Convert both to full form for comparison 

195 return self.full_form == other.full_form 

196 

197 def __str__(self) -> str: 

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

199 return self.dashed_form 

200 

201 def __repr__(self) -> str: 

202 """Representation showing the normalized form.""" 

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

204 

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) 

210 

211 def __hash__(self) -> int: 

212 """Hash based on normalized form.""" 

213 return hash(self._normalized) 

214 

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 

220 

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

222 """Less than or equal comparison.""" 

223 return self < other or self == other 

224 

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 

230 

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

232 """Greater than or equal comparison.""" 

233 return self > other or self == other 

234 

235 def __len__(self) -> int: 

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

237 return len(self._normalized) 

238 

239 def is_valid_for_custom_characteristic(self) -> bool: 

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

241 

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 

248 

249 """ 

250 return self.normalized not in ( 

251 self.INVALID_BASE_UUID_NORMALIZED, 

252 self.INVALID_NULL_UUID, 

253 self.INVALID_PLACEHOLDER_UUID, 

254 ) 

255 

256 def is_sig_characteristic(self) -> bool: 

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

258 

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

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

261 

262 Returns: 

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

264 

265 """ 

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

267 if not self.is_full: 

268 return False 

269 

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 

273 

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

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

276 return False 

277 

278 try: 

279 # Use existing short_form property instead of manual string slicing 

280 uuid_int = int(self.short_form, 16) 

281 

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 

286 

287 def is_sig_service(self) -> bool: 

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

289 

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

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

292 

293 Returns: 

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

295 

296 """ 

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

298 if not self.is_full: 

299 return False 

300 

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 

304 

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

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

307 return False 

308 

309 try: 

310 # Use existing short_form property instead of manual string slicing 

311 uuid_int = int(self.short_form, 16) 

312 

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