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

112 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +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 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 remove_suffix(name: str, suffix: str) -> str: 

60 """Remove suffix from name if present. 

61 

62 Args: 

63 name: Original name 

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

65 

66 Returns: 

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

68 

69 """ 

70 if name.endswith(suffix): 

71 return name[: -len(suffix)] 

72 return name 

73 

74 @staticmethod 

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

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

77 

78 Args: 

79 words: List of words from name split 

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

81 

82 Returns: 

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

84 

85 """ 

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

87 

88 @staticmethod 

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

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

91 acronyms = { 

92 "cgm", 

93 "co2", 

94 "ieee", 

95 "pm1", 

96 "pm10", 

97 "pm25", 

98 "voc", 

99 "rsc", 

100 "cct", 

101 "cccd", 

102 "ccc", 

103 "2d", 

104 "3d", 

105 "pm", 

106 "no2", 

107 "so2", 

108 "o3", 

109 "nh3", 

110 "ch4", 

111 "co", 

112 "o2", 

113 "h2", 

114 "n2", 

115 "csc", 

116 "uv", 

117 "ln", 

118 "plx", 

119 } 

120 parts = s.split("_") 

121 result = [] 

122 for part in parts: 

123 if part.lower() in acronyms: 

124 result.append(part.upper()) 

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

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

127 else: 

128 result.append(part.capitalize()) 

129 return "".join(result) 

130 

131 

132class NameVariantGenerator: 

133 """Generates name variants for registry lookups. 

134 

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

136 ordered by likelihood of success. 

137 """ 

138 

139 @staticmethod 

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

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

142 

143 Args: 

144 class_name: The __name__ of the characteristic class 

145 explicit_name: Optional explicit name override 

146 

147 Returns: 

148 List of name variants ordered by likelihood of success 

149 

150 """ 

151 variants: list[str] = [] 

152 

153 # If explicit name provided, try it first 

154 if explicit_name: 

155 variants.append(explicit_name) 

156 

157 # Remove 'Characteristic' suffix if present 

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

159 

160 # Convert to space-separated display name 

161 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

162 

163 # Generate org format 

164 words = display_name.split() 

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

166 

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

168 # 1. Space-separated display name 

169 # 2. Base name without suffix 

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

171 # 4. Full class name (fallback) 

172 variants.extend( 

173 [ 

174 display_name, 

175 base_name, 

176 org_name, 

177 class_name, 

178 ] 

179 ) 

180 

181 # Remove duplicates while preserving order 

182 seen: set[str] = set() 

183 result: list[str] = [] 

184 for v in variants: 

185 if v not in seen: 

186 seen.add(v) 

187 result.append(v) 

188 return result 

189 

190 @staticmethod 

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

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

193 

194 Args: 

195 class_name: The __name__ of the service class 

196 explicit_name: Optional explicit name override 

197 

198 Returns: 

199 List of name variants ordered by likelihood of success 

200 

201 """ 

202 variants: list[str] = [] 

203 

204 # If explicit name provided, try it first 

205 if explicit_name: 

206 variants.append(explicit_name) 

207 

208 # Remove 'Service' suffix if present 

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

210 

211 # Split on camelCase and convert to space-separated 

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

213 display_name = " ".join(words) 

214 

215 # Generate org format 

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

217 

218 # Order by likelihood: 

219 # 1. Space-separated display name 

220 # 2. Base name without suffix 

221 # 3. Display name with " Service" suffix 

222 # 4. Full class name 

223 # 5. Org ID format 

224 variants.extend( 

225 [ 

226 display_name, 

227 base_name, 

228 display_name + " Service", 

229 class_name, 

230 org_name, 

231 ] 

232 ) 

233 

234 # Remove duplicates while preserving order 

235 seen: set[str] = set() 

236 result: list[str] = [] 

237 for v in variants: 

238 if v not in seen: 

239 seen.add(v) 

240 result.append(v) 

241 return result 

242 

243 @staticmethod 

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

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

246 

247 Args: 

248 class_name: The __name__ of the descriptor class 

249 explicit_name: Optional explicit name override 

250 

251 Returns: 

252 List of name variants ordered by likelihood of success 

253 

254 """ 

255 variants: list[str] = [] 

256 

257 # If explicit name provided, try it first 

258 if explicit_name: 

259 variants.append(explicit_name) 

260 

261 # Remove 'Descriptor' suffix if present 

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

263 

264 # Convert to space-separated display name 

265 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

266 

267 # Generate org format 

268 words = display_name.split() 

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

270 

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

272 # 1. Space-separated display name 

273 # 2. Base name without suffix 

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

275 # 4. Full class name (fallback) 

276 variants.extend( 

277 [ 

278 display_name, 

279 base_name, 

280 org_name, 

281 class_name, 

282 ] 

283 ) 

284 

285 # Remove duplicates while preserving order 

286 seen: set[str] = set() 

287 result: list[str] = [] 

288 for v in variants: 

289 if v not in seen: 

290 seen.add(v) 

291 result.append(v) 

292 return result 

293 

294 

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

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

297 

298 This class implements the Template Method pattern, allowing subclasses 

299 to customize the search behaviour for different entity types. 

300 """ 

301 

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

303 """Search registry using name variants. 

304 

305 Args: 

306 class_obj: The class to resolve info for 

307 explicit_name: Optional explicit name override 

308 

309 Returns: 

310 Resolved info object or None if not found 

311 

312 """ 

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

314 

315 for variant in variants: 

316 info = self._lookup_in_registry(variant) 

317 if info: 

318 return info 

319 

320 return None 

321 

322 @abstractmethod 

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

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

325 

326 @abstractmethod 

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

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

329 

330 

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

332 """Registry search strategy for characteristics.""" 

333 

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

335 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name) 

336 

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

338 return uuid_registry.get_characteristic_info(name) 

339 

340 

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

342 """Registry search strategy for services.""" 

343 

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

345 return NameVariantGenerator.generate_service_variants(class_name, explicit_name) 

346 

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

348 return uuid_registry.get_service_info(name) 

349 

350 

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

352 """Registry search strategy for descriptors.""" 

353 

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

355 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name) 

356 

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

358 return uuid_registry.get_descriptor_info(name)