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
« 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.
3This module provides common name resolution and normalization logic to avoid
4duplication between characteristic and service resolvers.
5"""
7from __future__ import annotations
9import re
10from typing import Generic, TypeVar
12from ..types import CharacteristicInfo, DescriptorInfo, ServiceInfo
13from ..types.gatt_enums import ValueType
14from .uuid_registry import UuidInfo, uuid_registry
16# Generic type variables for resolver return types
17TInfo = TypeVar("TInfo", CharacteristicInfo, ServiceInfo, DescriptorInfo)
20class NameNormalizer:
21 """Utilities for normalizing class names to various Bluetooth SIG formats.
23 This class provides name transformation functions that are common to both
24 characteristic and service resolution.
25 """
27 @staticmethod
28 def camel_case_to_display_name(name: str) -> str:
29 """Convert camelCase class name to space-separated display name.
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)
35 Args:
36 name: CamelCase name (e.g., "VOCConcentration", "BatteryLevel")
38 Returns:
39 Space-separated display name (e.g., "VOC Concentration", "Battery Level")
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"
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)
57 @staticmethod
58 def remove_suffix(name: str, suffix: str) -> str:
59 """Remove suffix from name if present.
61 Args:
62 name: Original name
63 suffix: Suffix to remove (e.g., "Characteristic", "Service")
65 Returns:
66 Name without suffix, or original name if suffix not present
68 """
69 if name.endswith(suffix):
70 return name[: -len(suffix)]
71 return name
73 @staticmethod
74 def to_org_format(words: list[str], entity_type: str) -> str:
75 """Convert words to org.bluetooth format.
77 Args:
78 words: List of words from name split
79 entity_type: Type of entity ("characteristic" or "service")
81 Returns:
82 Org format string (e.g., "org.bluetooth.characteristic.battery_level")
84 """
85 return f"org.bluetooth.{entity_type}." + "_".join(word.lower() for word in words)
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)
127class NameVariantGenerator:
128 """Generates name variants for registry lookups.
130 Produces all possible name formats that might match registry entries,
131 ordered by likelihood of success.
132 """
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.
138 Args:
139 class_name: The __name__ of the characteristic class
140 explicit_name: Optional explicit name override
142 Returns:
143 List of name variants ordered by likelihood of success
145 """
146 variants: list[str] = []
148 # If explicit name provided, try it first
149 if explicit_name:
150 variants.append(explicit_name)
152 # Remove 'Characteristic' suffix if present
153 base_name = NameNormalizer.remove_suffix(class_name, "Characteristic")
155 # Convert to space-separated display name
156 display_name = NameNormalizer.camel_case_to_display_name(base_name)
158 # Generate org format
159 words = display_name.split()
160 org_name = NameNormalizer.to_org_format(words, "characteristic")
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 )
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
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.
189 Args:
190 class_name: The __name__ of the service class
191 explicit_name: Optional explicit name override
193 Returns:
194 List of name variants ordered by likelihood of success
196 """
197 variants: list[str] = []
199 # If explicit name provided, try it first
200 if explicit_name:
201 variants.append(explicit_name)
203 # Remove 'Service' suffix if present
204 base_name = NameNormalizer.remove_suffix(class_name, "Service")
206 # Split on camelCase and convert to space-separated
207 words = re.findall("[A-Z][^A-Z]*", base_name)
208 display_name = " ".join(words)
210 # Generate org format
211 org_name = NameNormalizer.to_org_format(words, "service")
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 )
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
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.
242 Args:
243 class_name: The __name__ of the descriptor class
244 explicit_name: Optional explicit name override
246 Returns:
247 List of name variants ordered by likelihood of success
249 """
250 variants: list[str] = []
252 # If explicit name provided, try it first
253 if explicit_name:
254 variants.append(explicit_name)
256 # Remove 'Descriptor' suffix if present
257 base_name = NameNormalizer.remove_suffix(class_name, "Descriptor")
259 # Convert to space-separated display name
260 display_name = NameNormalizer.camel_case_to_display_name(base_name)
262 # Generate org format
263 words = display_name.split()
264 org_name = NameNormalizer.to_org_format(words, "descriptor")
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 )
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
290class RegistrySearchStrategy(Generic[TInfo]): # pylint: disable=too-few-public-methods
291 """Base strategy for searching registry with name variants.
293 This class implements the Template Method pattern, allowing subclasses
294 to customize the search behavior for different entity types.
295 """
297 def search(self, class_obj: type, explicit_name: str | None = None) -> TInfo | None:
298 """Search registry using name variants.
300 Args:
301 class_obj: The class to resolve info for
302 explicit_name: Optional explicit name override
304 Returns:
305 Resolved info object or None if not found
307 """
308 variants = self._generate_variants(class_obj.__name__, explicit_name)
310 for variant in variants:
311 info = self._lookup_in_registry(variant)
312 if info:
313 return self._create_info(info, class_obj)
315 return None
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
321 def _lookup_in_registry(self, name: str) -> UuidInfo | None:
322 """Lookup name in registry."""
323 raise NotImplementedError
325 def _create_info(self, uuid_info: UuidInfo, class_obj: type) -> TInfo:
326 """Create Info object from registry UuidInfo."""
327 raise NotImplementedError
330class CharacteristicRegistrySearch(RegistrySearchStrategy[CharacteristicInfo]): # pylint: disable=too-few-public-methods
331 """Registry search strategy for characteristics."""
333 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
334 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name)
336 def _lookup_in_registry(self, name: str) -> UuidInfo | None:
337 return uuid_registry.get_characteristic_info(name)
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 )
349class ServiceRegistrySearch(RegistrySearchStrategy[ServiceInfo]): # pylint: disable=too-few-public-methods
350 """Registry search strategy for services."""
352 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
353 return NameVariantGenerator.generate_service_variants(class_name, explicit_name)
355 def _lookup_in_registry(self, name: str) -> UuidInfo | None:
356 return uuid_registry.get_service_info(name)
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 )
366class DescriptorRegistrySearch(RegistrySearchStrategy[DescriptorInfo]): # pylint: disable=too-few-public-methods
367 """Registry search strategy for descriptors."""
369 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
370 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name)
372 def _lookup_in_registry(self, name: str) -> UuidInfo | None:
373 return uuid_registry.get_descriptor_info(name)
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: "")()
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 )