Coverage for src/bluetooth_sig/gatt/resolver.py: 93%

121 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""Shared SIG resolver utilities for characteristics and services. 

2 

3This module provides common name resolution and normalization logic to avoid 

4duplication between characteristic and service resolvers. 

5""" 

6 

7from __future__ import annotations 

8 

9import re 

10from typing import Generic, TypeVar 

11 

12from ..types import CharacteristicInfo, DescriptorInfo, ServiceInfo 

13from ..types.gatt_enums import ValueType 

14from .uuid_registry import UuidInfo, uuid_registry 

15 

16# Generic type variables for resolver return types 

17TInfo = TypeVar("TInfo", CharacteristicInfo, ServiceInfo, DescriptorInfo) 

18 

19 

20class NameNormalizer: 

21 """Utilities for normalizing class names to various Bluetooth SIG formats. 

22 

23 This class provides name transformation functions that are common to both 

24 characteristic and service resolution. 

25 """ 

26 

27 @staticmethod 

28 def camel_case_to_display_name(name: str) -> str: 

29 """Convert camelCase class name to space-separated display name. 

30 

31 Uses industry-standard two-step regex pattern to handle acronyms correctly: 

32 - Step 1: Handle consecutive capitals followed by lowercase (VOCConcentration -> VOC Concentration) 

33 - Step 2: Handle lowercase/digit followed by capital (camelCase -> camel Case) 

34 

35 Args: 

36 name: CamelCase name (e.g., "VOCConcentration", "BatteryLevel") 

37 

38 Returns: 

39 Space-separated display name (e.g., "VOC Concentration", "Battery Level") 

40 

41 Examples: 

42 >>> NameNormalizer.camel_case_to_display_name("VOCConcentration") 

43 "VOC Concentration" 

44 >>> NameNormalizer.camel_case_to_display_name("CO2Concentration") 

45 "CO2 Concentration" 

46 >>> NameNormalizer.camel_case_to_display_name("BatteryLevel") 

47 "Battery Level" 

48 

49 """ 

50 # Step 1: Handle consecutive capitals followed by lowercase 

51 # e.g., "VOCConcentration" -> "VOC Concentration" 

52 s1 = re.sub("([A-Z]+)([A-Z][a-z])", r"\1 \2", name) 

53 # Step 2: Handle lowercase/digit followed by capital 

54 # e.g., "camelCase" -> "camel Case", "PM25Concentration" -> "PM25 Concentration" 

55 return re.sub("([a-z0-9])([A-Z])", r"\1 \2", s1) 

56 

57 @staticmethod 

58 def remove_suffix(name: str, suffix: str) -> str: 

59 """Remove suffix from name if present. 

60 

61 Args: 

62 name: Original name 

63 suffix: Suffix to remove (e.g., "Characteristic", "Service") 

64 

65 Returns: 

66 Name without suffix, or original name if suffix not present 

67 

68 """ 

69 if name.endswith(suffix): 

70 return name[: -len(suffix)] 

71 return name 

72 

73 @staticmethod 

74 def to_org_format(words: list[str], entity_type: str) -> str: 

75 """Convert words to org.bluetooth format. 

76 

77 Args: 

78 words: List of words from name split 

79 entity_type: Type of entity ("characteristic" or "service") 

80 

81 Returns: 

82 Org format string (e.g., "org.bluetooth.characteristic.battery_level") 

83 

84 """ 

85 return f"org.bluetooth.{entity_type}." + "_".join(word.lower() for word in words) 

86 

87 @staticmethod 

88 def snake_case_to_camel_case(s: str) -> str: 

89 """Convert snake_case to CamelCase with acronym handling (for test file mapping).""" 

90 acronyms = { 

91 "co2", 

92 "pm1", 

93 "pm10", 

94 "pm25", 

95 "voc", 

96 "rsc", 

97 "cccd", 

98 "ccc", 

99 "2d", 

100 "3d", 

101 "pm", 

102 "no2", 

103 "so2", 

104 "o3", 

105 "nh3", 

106 "ch4", 

107 "co", 

108 "o2", 

109 "h2", 

110 "n2", 

111 "csc", 

112 "uv", 

113 "ln", 

114 } 

115 parts = s.split("_") 

116 result = [] 

117 for part in parts: 

118 if part.lower() in acronyms: 

119 result.append(part.upper()) 

120 elif any(c.isdigit() for c in part): 

121 result.append("".join(c.upper() if c.isalpha() else c for c in part)) 

122 else: 

123 result.append(part.capitalize()) 

124 return "".join(result) 

125 

126 

127class NameVariantGenerator: 

128 """Generates name variants for registry lookups. 

129 

130 Produces all possible name formats that might match registry entries, 

131 ordered by likelihood of success. 

132 """ 

133 

134 @staticmethod 

135 def generate_characteristic_variants(class_name: str, explicit_name: str | None = None) -> list[str]: 

136 """Generate all name variants to try for characteristic resolution. 

137 

138 Args: 

139 class_name: The __name__ of the characteristic class 

140 explicit_name: Optional explicit name override 

141 

142 Returns: 

143 List of name variants ordered by likelihood of success 

144 

145 """ 

146 variants: list[str] = [] 

147 

148 # If explicit name provided, try it first 

149 if explicit_name: 

150 variants.append(explicit_name) 

151 

152 # Remove 'Characteristic' suffix if present 

153 base_name = NameNormalizer.remove_suffix(class_name, "Characteristic") 

154 

155 # Convert to space-separated display name 

156 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

157 

158 # Generate org format 

159 words = display_name.split() 

160 org_name = NameNormalizer.to_org_format(words, "characteristic") 

161 

162 # Order by hit rate (based on testing): 

163 # 1. Space-separated display name 

164 # 2. Base name without suffix 

165 # 3. Org ID format (~0% hit rate but spec-compliant) 

166 # 4. Full class name (fallback) 

167 variants.extend( 

168 [ 

169 display_name, 

170 base_name, 

171 org_name, 

172 class_name, 

173 ] 

174 ) 

175 

176 # Remove duplicates while preserving order 

177 seen: set[str] = set() 

178 result: list[str] = [] 

179 for v in variants: 

180 if v not in seen: 

181 seen.add(v) 

182 result.append(v) 

183 return result 

184 

185 @staticmethod 

186 def generate_service_variants(class_name: str, explicit_name: str | None = None) -> list[str]: 

187 """Generate all name variants to try for service resolution. 

188 

189 Args: 

190 class_name: The __name__ of the service class 

191 explicit_name: Optional explicit name override 

192 

193 Returns: 

194 List of name variants ordered by likelihood of success 

195 

196 """ 

197 variants: list[str] = [] 

198 

199 # If explicit name provided, try it first 

200 if explicit_name: 

201 variants.append(explicit_name) 

202 

203 # Remove 'Service' suffix if present 

204 base_name = NameNormalizer.remove_suffix(class_name, "Service") 

205 

206 # Split on camelCase and convert to space-separated 

207 words = re.findall("[A-Z][^A-Z]*", base_name) 

208 display_name = " ".join(words) 

209 

210 # Generate org format 

211 org_name = NameNormalizer.to_org_format(words, "service") 

212 

213 # Order by likelihood: 

214 # 1. Space-separated display name 

215 # 2. Base name without suffix 

216 # 3. Display name with " Service" suffix 

217 # 4. Full class name 

218 # 5. Org ID format 

219 variants.extend( 

220 [ 

221 display_name, 

222 base_name, 

223 display_name + " Service", 

224 class_name, 

225 org_name, 

226 ] 

227 ) 

228 

229 # Remove duplicates while preserving order 

230 seen: set[str] = set() 

231 result: list[str] = [] 

232 for v in variants: 

233 if v not in seen: 

234 seen.add(v) 

235 result.append(v) 

236 return result 

237 

238 @staticmethod 

239 def generate_descriptor_variants(class_name: str, explicit_name: str | None = None) -> list[str]: 

240 """Generate all name variants to try for descriptor resolution. 

241 

242 Args: 

243 class_name: The __name__ of the descriptor class 

244 explicit_name: Optional explicit name override 

245 

246 Returns: 

247 List of name variants ordered by likelihood of success 

248 

249 """ 

250 variants: list[str] = [] 

251 

252 # If explicit name provided, try it first 

253 if explicit_name: 

254 variants.append(explicit_name) 

255 

256 # Remove 'Descriptor' suffix if present 

257 base_name = NameNormalizer.remove_suffix(class_name, "Descriptor") 

258 

259 # Convert to space-separated display name 

260 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

261 

262 # Generate org format 

263 words = display_name.split() 

264 org_name = NameNormalizer.to_org_format(words, "descriptor") 

265 

266 # Order by hit rate (based on testing): 

267 # 1. Space-separated display name 

268 # 2. Base name without suffix 

269 # 3. Org ID format (~0% hit rate but spec-compliant) 

270 # 4. Full class name (fallback) 

271 variants.extend( 

272 [ 

273 display_name, 

274 base_name, 

275 org_name, 

276 class_name, 

277 ] 

278 ) 

279 

280 # Remove duplicates while preserving order 

281 seen: set[str] = set() 

282 result: list[str] = [] 

283 for v in variants: 

284 if v not in seen: 

285 seen.add(v) 

286 result.append(v) 

287 return result 

288 

289 

290class RegistrySearchStrategy(Generic[TInfo]): # pylint: disable=too-few-public-methods 

291 """Base strategy for searching registry with name variants. 

292 

293 This class implements the Template Method pattern, allowing subclasses 

294 to customize the search behavior for different entity types. 

295 """ 

296 

297 def search(self, class_obj: type, explicit_name: str | None = None) -> TInfo | None: 

298 """Search registry using name variants. 

299 

300 Args: 

301 class_obj: The class to resolve info for 

302 explicit_name: Optional explicit name override 

303 

304 Returns: 

305 Resolved info object or None if not found 

306 

307 """ 

308 variants = self._generate_variants(class_obj.__name__, explicit_name) 

309 

310 for variant in variants: 

311 info = self._lookup_in_registry(variant) 

312 if info: 

313 return self._create_info(info, class_obj) 

314 

315 return None 

316 

317 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]: 

318 """Generate name variants for this entity type.""" 

319 raise NotImplementedError 

320 

321 def _lookup_in_registry(self, name: str) -> UuidInfo | None: 

322 """Lookup name in registry.""" 

323 raise NotImplementedError 

324 

325 def _create_info(self, uuid_info: UuidInfo, class_obj: type) -> TInfo: 

326 """Create Info object from registry UuidInfo.""" 

327 raise NotImplementedError 

328 

329 

330class CharacteristicRegistrySearch(RegistrySearchStrategy[CharacteristicInfo]): # pylint: disable=too-few-public-methods 

331 """Registry search strategy for characteristics.""" 

332 

333 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]: 

334 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name) 

335 

336 def _lookup_in_registry(self, name: str) -> UuidInfo | None: 

337 return uuid_registry.get_characteristic_info(name) 

338 

339 def _create_info(self, uuid_info: UuidInfo, class_obj: type) -> CharacteristicInfo: 

340 return CharacteristicInfo( 

341 uuid=uuid_info.uuid, 

342 name=uuid_info.name, 

343 unit=uuid_info.unit or "", 

344 value_type=ValueType(uuid_info.value_type) if uuid_info.value_type else ValueType.UNKNOWN, 

345 properties=[], 

346 ) 

347 

348 

349class ServiceRegistrySearch(RegistrySearchStrategy[ServiceInfo]): # pylint: disable=too-few-public-methods 

350 """Registry search strategy for services.""" 

351 

352 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]: 

353 return NameVariantGenerator.generate_service_variants(class_name, explicit_name) 

354 

355 def _lookup_in_registry(self, name: str) -> UuidInfo | None: 

356 return uuid_registry.get_service_info(name) 

357 

358 def _create_info(self, uuid_info: UuidInfo, class_obj: type) -> ServiceInfo: 

359 return ServiceInfo( 

360 uuid=uuid_info.uuid, 

361 name=uuid_info.name, 

362 description=uuid_info.summary or "", 

363 ) 

364 

365 

366class DescriptorRegistrySearch(RegistrySearchStrategy[DescriptorInfo]): # pylint: disable=too-few-public-methods 

367 """Registry search strategy for descriptors.""" 

368 

369 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]: 

370 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name) 

371 

372 def _lookup_in_registry(self, name: str) -> UuidInfo | None: 

373 return uuid_registry.get_descriptor_info(name) 

374 

375 def _create_info(self, uuid_info: UuidInfo, class_obj: type) -> DescriptorInfo: 

376 # Get structured data info from the class if available 

377 has_structured_data = getattr(class_obj, "_has_structured_data", lambda: False)() 

378 data_format = getattr(class_obj, "_get_data_format", lambda: "")() 

379 

380 return DescriptorInfo( 

381 uuid=uuid_info.uuid, 

382 name=uuid_info.name, 

383 description=uuid_info.summary or "", 

384 has_structured_data=has_structured_data, 

385 data_format=data_format, 

386 )