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
« 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.
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 abc import abstractmethod
11from typing import Generic, TypeVar
13from ..types import CharacteristicInfo, DescriptorInfo, ServiceInfo
14from .uuid_registry import get_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 regex to find word boundaries at capital letters and numbers.
33 Args:
34 name: CamelCase name (e.g., "VOCConcentration", "BatteryLevel", "ApparentEnergy32")
36 Returns:
37 Space-separated display name (e.g., "VOC Concentration", "Battery Level", "Apparent Energy 32")
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"
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)
58 @staticmethod
59 def sanitize_display_markup(name: str) -> str:
60 """Convert SIG LaTeX-style display markup into plain-text output.
62 Args:
63 name: Raw SIG/YAML name or description fragment.
65 Returns:
66 Plain-text display string with supported LaTeX markup removed.
68 """
69 return re.sub(r"\\textsubscript\{([^}]+)\}", r"\1", name)
71 @staticmethod
72 def remove_suffix(name: str, suffix: str) -> str:
73 """Remove suffix from name if present.
75 Args:
76 name: Original name
77 suffix: Suffix to remove (e.g., "Characteristic", "Service")
79 Returns:
80 Name without suffix, or original name if suffix not present
82 """
83 if name.endswith(suffix):
84 return name[: -len(suffix)]
85 return name
87 @staticmethod
88 def to_org_format(words: list[str], entity_type: str) -> str:
89 """Convert words to org.bluetooth format.
91 Args:
92 words: List of words from name split
93 entity_type: Type of entity ("characteristic" or "service")
95 Returns:
96 Org format string (e.g., "org.bluetooth.characteristic.battery_level")
98 """
99 return f"org.bluetooth.{entity_type}." + "_".join(word.lower() for word in words)
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)
145class NameVariantGenerator:
146 """Generates name variants for registry lookups.
148 Produces all possible name formats that might match registry entries,
149 ordered by likelihood of success.
150 """
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.
156 Args:
157 class_name: The __name__ of the characteristic class
158 explicit_name: Optional explicit name override
160 Returns:
161 List of name variants ordered by likelihood of success
163 """
164 variants: list[str] = []
166 # If explicit name provided, try it first
167 if explicit_name:
168 variants.append(explicit_name)
170 # Remove 'Characteristic' suffix if present
171 base_name = NameNormalizer.remove_suffix(class_name, "Characteristic")
173 # Convert to space-separated display name
174 display_name = NameNormalizer.camel_case_to_display_name(base_name)
176 # Generate org format
177 words = display_name.split()
178 org_name = NameNormalizer.to_org_format(words, "characteristic")
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 )
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
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.
207 Args:
208 class_name: The __name__ of the service class
209 explicit_name: Optional explicit name override
211 Returns:
212 List of name variants ordered by likelihood of success
214 """
215 variants: list[str] = []
217 # If explicit name provided, try it first
218 if explicit_name:
219 variants.append(explicit_name)
221 # Remove 'Service' suffix if present
222 base_name = NameNormalizer.remove_suffix(class_name, "Service")
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)
228 # Generate org format
229 org_name = NameNormalizer.to_org_format(words, "service")
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 )
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
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.
260 Args:
261 class_name: The __name__ of the descriptor class
262 explicit_name: Optional explicit name override
264 Returns:
265 List of name variants ordered by likelihood of success
267 """
268 variants: list[str] = []
270 # If explicit name provided, try it first
271 if explicit_name:
272 variants.append(explicit_name)
274 # Remove 'Descriptor' suffix if present
275 base_name = NameNormalizer.remove_suffix(class_name, "Descriptor")
277 # Convert to space-separated display name
278 display_name = NameNormalizer.camel_case_to_display_name(base_name)
280 # Generate org format
281 words = display_name.split()
282 org_name = NameNormalizer.to_org_format(words, "descriptor")
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 )
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
308class RegistrySearchStrategy(Generic[TInfo]): # pylint: disable=too-few-public-methods
309 """Base strategy for searching registry with name variants.
311 This class implements the Template Method pattern, allowing subclasses
312 to customize the search behaviour for different entity types.
313 """
315 def search(self, class_obj: type, explicit_name: str | None = None) -> TInfo | None:
316 """Search registry using name variants.
318 Args:
319 class_obj: The class to resolve info for
320 explicit_name: Optional explicit name override
322 Returns:
323 Resolved info object or None if not found
325 """
326 variants = self._generate_variants(class_obj.__name__, explicit_name)
328 for variant in variants:
329 info = self._lookup_in_registry(variant)
330 if info:
331 return info
333 return None
335 @abstractmethod
336 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
337 """Generate name variants for this entity type."""
339 @abstractmethod
340 def _lookup_in_registry(self, name: str) -> TInfo | None:
341 """Lookup name in registry and return domain type."""
344class CharacteristicRegistrySearch(RegistrySearchStrategy[CharacteristicInfo]): # pylint: disable=too-few-public-methods
345 """Registry search strategy for characteristics."""
347 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
348 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name)
350 def _lookup_in_registry(self, name: str) -> CharacteristicInfo | None:
351 return get_uuid_registry().get_characteristic_info(name)
354class ServiceRegistrySearch(RegistrySearchStrategy[ServiceInfo]): # pylint: disable=too-few-public-methods
355 """Registry search strategy for services."""
357 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
358 return NameVariantGenerator.generate_service_variants(class_name, explicit_name)
360 def _lookup_in_registry(self, name: str) -> ServiceInfo | None:
361 return get_uuid_registry().get_service_info(name)
364class DescriptorRegistrySearch(RegistrySearchStrategy[DescriptorInfo]): # pylint: disable=too-few-public-methods
365 """Registry search strategy for descriptors."""
367 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
368 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name)
370 def _lookup_in_registry(self, name: str) -> DescriptorInfo | None:
371 return get_uuid_registry().get_descriptor_info(name)