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

115 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 01:26 +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 abc import abstractmethod 

11from typing import Generic, TypeVar 

12 

13from ..types import CharacteristicInfo, DescriptorInfo, ServiceInfo 

14from .uuid_registry import get_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 regex to find word boundaries at capital letters and numbers. 

32 

33 Args: 

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

35 

36 Returns: 

37 Space-separated display name (e.g., "VOC Concentration", "Battery Level", "Apparent Energy 32") 

38 

39 Examples: 

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

41 "VOC Concentration" 

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

43 "CO2 Concentration" 

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

45 "Battery Level" 

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

47 "Apparent Energy 32" 

48 

49 """ 

50 # Standard camelCase splitting pattern 

51 # Insert space before uppercase letters preceded by any character followed by lowercase 

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

53 # Insert space before uppercase that follows lowercase or digit 

54 result = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", result) 

55 # Insert space before trailing numbers 

56 return re.sub(r"([a-z])(\d+)", r"\1 \2", result) 

57 

58 @staticmethod 

59 def sanitize_display_markup(name: str) -> str: 

60 """Convert SIG LaTeX-style display markup into plain-text output. 

61 

62 Args: 

63 name: Raw SIG/YAML name or description fragment. 

64 

65 Returns: 

66 Plain-text display string with supported LaTeX markup removed. 

67 

68 """ 

69 return re.sub(r"\\textsubscript\{([^}]+)\}", r"\1", name) 

70 

71 @staticmethod 

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

73 """Remove suffix from name if present. 

74 

75 Args: 

76 name: Original name 

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

78 

79 Returns: 

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

81 

82 """ 

83 if name.endswith(suffix): 

84 return name[: -len(suffix)] 

85 return name 

86 

87 @staticmethod 

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

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

90 

91 Args: 

92 words: List of words from name split 

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

94 

95 Returns: 

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

97 

98 """ 

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

100 

101 @staticmethod 

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

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

104 acronyms = { 

105 "cgm", 

106 "co2", 

107 "ieee", 

108 "pm1", 

109 "pm10", 

110 "pm25", 

111 "voc", 

112 "rsc", 

113 "cct", 

114 "cccd", 

115 "ccc", 

116 "2d", 

117 "3d", 

118 "pm", 

119 "no2", 

120 "so2", 

121 "o3", 

122 "nh3", 

123 "ch4", 

124 "co", 

125 "o2", 

126 "h2", 

127 "n2", 

128 "csc", 

129 "uv", 

130 "ln", 

131 "plx", 

132 } 

133 parts = s.split("_") 

134 result = [] 

135 for part in parts: 

136 if part.lower() in acronyms: 

137 result.append(part.upper()) 

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

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

140 else: 

141 result.append(part.capitalize()) 

142 return "".join(result) 

143 

144 

145class NameVariantGenerator: 

146 """Generates name variants for registry lookups. 

147 

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

149 ordered by likelihood of success. 

150 """ 

151 

152 @staticmethod 

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

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

155 

156 Args: 

157 class_name: The __name__ of the characteristic class 

158 explicit_name: Optional explicit name override 

159 

160 Returns: 

161 List of name variants ordered by likelihood of success 

162 

163 """ 

164 variants: list[str] = [] 

165 

166 # If explicit name provided, try it first 

167 if explicit_name: 

168 variants.append(explicit_name) 

169 

170 # Remove 'Characteristic' suffix if present 

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

172 

173 # Convert to space-separated display name 

174 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

175 

176 # Generate org format 

177 words = display_name.split() 

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

179 

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

181 # 1. Space-separated display name 

182 # 2. Base name without suffix 

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

184 # 4. Full class name (fallback) 

185 variants.extend( 

186 [ 

187 display_name, 

188 base_name, 

189 org_name, 

190 class_name, 

191 ] 

192 ) 

193 

194 # Remove duplicates while preserving order 

195 seen: set[str] = set() 

196 result: list[str] = [] 

197 for v in variants: 

198 if v not in seen: 

199 seen.add(v) 

200 result.append(v) 

201 return result 

202 

203 @staticmethod 

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

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

206 

207 Args: 

208 class_name: The __name__ of the service class 

209 explicit_name: Optional explicit name override 

210 

211 Returns: 

212 List of name variants ordered by likelihood of success 

213 

214 """ 

215 variants: list[str] = [] 

216 

217 # If explicit name provided, try it first 

218 if explicit_name: 

219 variants.append(explicit_name) 

220 

221 # Remove 'Service' suffix if present 

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

223 

224 # Split on camelCase and convert to space-separated 

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

226 display_name = " ".join(words) 

227 

228 # Generate org format 

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

230 

231 # Order by likelihood: 

232 # 1. Space-separated display name 

233 # 2. Base name without suffix 

234 # 3. Display name with " Service" suffix 

235 # 4. Full class name 

236 # 5. Org ID format 

237 variants.extend( 

238 [ 

239 display_name, 

240 base_name, 

241 display_name + " Service", 

242 class_name, 

243 org_name, 

244 ] 

245 ) 

246 

247 # Remove duplicates while preserving order 

248 seen: set[str] = set() 

249 result: list[str] = [] 

250 for v in variants: 

251 if v not in seen: 

252 seen.add(v) 

253 result.append(v) 

254 return result 

255 

256 @staticmethod 

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

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

259 

260 Args: 

261 class_name: The __name__ of the descriptor class 

262 explicit_name: Optional explicit name override 

263 

264 Returns: 

265 List of name variants ordered by likelihood of success 

266 

267 """ 

268 variants: list[str] = [] 

269 

270 # If explicit name provided, try it first 

271 if explicit_name: 

272 variants.append(explicit_name) 

273 

274 # Remove 'Descriptor' suffix if present 

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

276 

277 # Convert to space-separated display name 

278 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

279 

280 # Generate org format 

281 words = display_name.split() 

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

283 

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

285 # 1. Space-separated display name 

286 # 2. Base name without suffix 

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

288 # 4. Full class name (fallback) 

289 variants.extend( 

290 [ 

291 display_name, 

292 base_name, 

293 org_name, 

294 class_name, 

295 ] 

296 ) 

297 

298 # Remove duplicates while preserving order 

299 seen: set[str] = set() 

300 result: list[str] = [] 

301 for v in variants: 

302 if v not in seen: 

303 seen.add(v) 

304 result.append(v) 

305 return result 

306 

307 

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

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

310 

311 This class implements the Template Method pattern, allowing subclasses 

312 to customize the search behaviour for different entity types. 

313 """ 

314 

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

316 """Search registry using name variants. 

317 

318 Args: 

319 class_obj: The class to resolve info for 

320 explicit_name: Optional explicit name override 

321 

322 Returns: 

323 Resolved info object or None if not found 

324 

325 """ 

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

327 

328 for variant in variants: 

329 info = self._lookup_in_registry(variant) 

330 if info: 

331 return info 

332 

333 return None 

334 

335 @abstractmethod 

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

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

338 

339 @abstractmethod 

340 def _lookup_in_registry(self, name: str) -> TInfo | None: 

341 """Lookup name in registry and return domain type.""" 

342 

343 

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

345 """Registry search strategy for characteristics.""" 

346 

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

348 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name) 

349 

350 def _lookup_in_registry(self, name: str) -> CharacteristicInfo | None: 

351 return get_uuid_registry().get_characteristic_info(name) 

352 

353 

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

355 """Registry search strategy for services.""" 

356 

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

358 return NameVariantGenerator.generate_service_variants(class_name, explicit_name) 

359 

360 def _lookup_in_registry(self, name: str) -> ServiceInfo | None: 

361 return get_uuid_registry().get_service_info(name) 

362 

363 

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

365 """Registry search strategy for descriptors.""" 

366 

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

368 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name) 

369 

370 def _lookup_in_registry(self, name: str) -> DescriptorInfo | None: 

371 return get_uuid_registry().get_descriptor_info(name)