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

113 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +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 result = re.sub(r"([a-z])(\d+)", r"\1 \2", result) 

57 return result 

58 

59 @staticmethod 

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

61 """Remove suffix from name if present. 

62 

63 Args: 

64 name: Original name 

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

66 

67 Returns: 

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

69 

70 """ 

71 if name.endswith(suffix): 

72 return name[: -len(suffix)] 

73 return name 

74 

75 @staticmethod 

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

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

78 

79 Args: 

80 words: List of words from name split 

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

82 

83 Returns: 

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

85 

86 """ 

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

88 

89 @staticmethod 

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

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

92 acronyms = { 

93 "co2", 

94 "pm1", 

95 "pm10", 

96 "pm25", 

97 "voc", 

98 "rsc", 

99 "cccd", 

100 "ccc", 

101 "2d", 

102 "3d", 

103 "pm", 

104 "no2", 

105 "so2", 

106 "o3", 

107 "nh3", 

108 "ch4", 

109 "co", 

110 "o2", 

111 "h2", 

112 "n2", 

113 "csc", 

114 "uv", 

115 "ln", 

116 "plx", 

117 } 

118 parts = s.split("_") 

119 result = [] 

120 for part in parts: 

121 if part.lower() in acronyms: 

122 result.append(part.upper()) 

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

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

125 else: 

126 result.append(part.capitalize()) 

127 return "".join(result) 

128 

129 

130class NameVariantGenerator: 

131 """Generates name variants for registry lookups. 

132 

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

134 ordered by likelihood of success. 

135 """ 

136 

137 @staticmethod 

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

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

140 

141 Args: 

142 class_name: The __name__ of the characteristic class 

143 explicit_name: Optional explicit name override 

144 

145 Returns: 

146 List of name variants ordered by likelihood of success 

147 

148 """ 

149 variants: list[str] = [] 

150 

151 # If explicit name provided, try it first 

152 if explicit_name: 

153 variants.append(explicit_name) 

154 

155 # Remove 'Characteristic' suffix if present 

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

157 

158 # Convert to space-separated display name 

159 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

160 

161 # Generate org format 

162 words = display_name.split() 

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

164 

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

166 # 1. Space-separated display name 

167 # 2. Base name without suffix 

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

169 # 4. Full class name (fallback) 

170 variants.extend( 

171 [ 

172 display_name, 

173 base_name, 

174 org_name, 

175 class_name, 

176 ] 

177 ) 

178 

179 # Remove duplicates while preserving order 

180 seen: set[str] = set() 

181 result: list[str] = [] 

182 for v in variants: 

183 if v not in seen: 

184 seen.add(v) 

185 result.append(v) 

186 return result 

187 

188 @staticmethod 

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

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

191 

192 Args: 

193 class_name: The __name__ of the service class 

194 explicit_name: Optional explicit name override 

195 

196 Returns: 

197 List of name variants ordered by likelihood of success 

198 

199 """ 

200 variants: list[str] = [] 

201 

202 # If explicit name provided, try it first 

203 if explicit_name: 

204 variants.append(explicit_name) 

205 

206 # Remove 'Service' suffix if present 

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

208 

209 # Split on camelCase and convert to space-separated 

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

211 display_name = " ".join(words) 

212 

213 # Generate org format 

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

215 

216 # Order by likelihood: 

217 # 1. Space-separated display name 

218 # 2. Base name without suffix 

219 # 3. Display name with " Service" suffix 

220 # 4. Full class name 

221 # 5. Org ID format 

222 variants.extend( 

223 [ 

224 display_name, 

225 base_name, 

226 display_name + " Service", 

227 class_name, 

228 org_name, 

229 ] 

230 ) 

231 

232 # Remove duplicates while preserving order 

233 seen: set[str] = set() 

234 result: list[str] = [] 

235 for v in variants: 

236 if v not in seen: 

237 seen.add(v) 

238 result.append(v) 

239 return result 

240 

241 @staticmethod 

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

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

244 

245 Args: 

246 class_name: The __name__ of the descriptor class 

247 explicit_name: Optional explicit name override 

248 

249 Returns: 

250 List of name variants ordered by likelihood of success 

251 

252 """ 

253 variants: list[str] = [] 

254 

255 # If explicit name provided, try it first 

256 if explicit_name: 

257 variants.append(explicit_name) 

258 

259 # Remove 'Descriptor' suffix if present 

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

261 

262 # Convert to space-separated display name 

263 display_name = NameNormalizer.camel_case_to_display_name(base_name) 

264 

265 # Generate org format 

266 words = display_name.split() 

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

268 

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

270 # 1. Space-separated display name 

271 # 2. Base name without suffix 

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

273 # 4. Full class name (fallback) 

274 variants.extend( 

275 [ 

276 display_name, 

277 base_name, 

278 org_name, 

279 class_name, 

280 ] 

281 ) 

282 

283 # Remove duplicates while preserving order 

284 seen: set[str] = set() 

285 result: list[str] = [] 

286 for v in variants: 

287 if v not in seen: 

288 seen.add(v) 

289 result.append(v) 

290 return result 

291 

292 

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

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

295 

296 This class implements the Template Method pattern, allowing subclasses 

297 to customize the search behaviour for different entity types. 

298 """ 

299 

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

301 """Search registry using name variants. 

302 

303 Args: 

304 class_obj: The class to resolve info for 

305 explicit_name: Optional explicit name override 

306 

307 Returns: 

308 Resolved info object or None if not found 

309 

310 """ 

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

312 

313 for variant in variants: 

314 info = self._lookup_in_registry(variant) 

315 if info: 

316 return info 

317 

318 return None 

319 

320 @abstractmethod 

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

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

323 

324 @abstractmethod 

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

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

327 

328 

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

330 """Registry search strategy for characteristics.""" 

331 

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

333 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name) 

334 

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

336 return uuid_registry.get_characteristic_info(name) 

337 

338 

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

340 """Registry search strategy for services.""" 

341 

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

343 return NameVariantGenerator.generate_service_variants(class_name, explicit_name) 

344 

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

346 return uuid_registry.get_service_info(name) 

347 

348 

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

350 """Registry search strategy for descriptors.""" 

351 

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

353 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name) 

354 

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

356 return uuid_registry.get_descriptor_info(name)