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
« 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.
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 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 remove_suffix(name: str, suffix: str) -> str:
60 """Remove suffix from name if present.
62 Args:
63 name: Original name
64 suffix: Suffix to remove (e.g., "Characteristic", "Service")
66 Returns:
67 Name without suffix, or original name if suffix not present
69 """
70 if name.endswith(suffix):
71 return name[: -len(suffix)]
72 return name
74 @staticmethod
75 def to_org_format(words: list[str], entity_type: str) -> str:
76 """Convert words to org.bluetooth format.
78 Args:
79 words: List of words from name split
80 entity_type: Type of entity ("characteristic" or "service")
82 Returns:
83 Org format string (e.g., "org.bluetooth.characteristic.battery_level")
85 """
86 return f"org.bluetooth.{entity_type}." + "_".join(word.lower() for word in words)
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)
132class NameVariantGenerator:
133 """Generates name variants for registry lookups.
135 Produces all possible name formats that might match registry entries,
136 ordered by likelihood of success.
137 """
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.
143 Args:
144 class_name: The __name__ of the characteristic class
145 explicit_name: Optional explicit name override
147 Returns:
148 List of name variants ordered by likelihood of success
150 """
151 variants: list[str] = []
153 # If explicit name provided, try it first
154 if explicit_name:
155 variants.append(explicit_name)
157 # Remove 'Characteristic' suffix if present
158 base_name = NameNormalizer.remove_suffix(class_name, "Characteristic")
160 # Convert to space-separated display name
161 display_name = NameNormalizer.camel_case_to_display_name(base_name)
163 # Generate org format
164 words = display_name.split()
165 org_name = NameNormalizer.to_org_format(words, "characteristic")
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 )
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
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.
194 Args:
195 class_name: The __name__ of the service class
196 explicit_name: Optional explicit name override
198 Returns:
199 List of name variants ordered by likelihood of success
201 """
202 variants: list[str] = []
204 # If explicit name provided, try it first
205 if explicit_name:
206 variants.append(explicit_name)
208 # Remove 'Service' suffix if present
209 base_name = NameNormalizer.remove_suffix(class_name, "Service")
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)
215 # Generate org format
216 org_name = NameNormalizer.to_org_format(words, "service")
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 )
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
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.
247 Args:
248 class_name: The __name__ of the descriptor class
249 explicit_name: Optional explicit name override
251 Returns:
252 List of name variants ordered by likelihood of success
254 """
255 variants: list[str] = []
257 # If explicit name provided, try it first
258 if explicit_name:
259 variants.append(explicit_name)
261 # Remove 'Descriptor' suffix if present
262 base_name = NameNormalizer.remove_suffix(class_name, "Descriptor")
264 # Convert to space-separated display name
265 display_name = NameNormalizer.camel_case_to_display_name(base_name)
267 # Generate org format
268 words = display_name.split()
269 org_name = NameNormalizer.to_org_format(words, "descriptor")
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 )
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
295class RegistrySearchStrategy(Generic[TInfo]): # pylint: disable=too-few-public-methods
296 """Base strategy for searching registry with name variants.
298 This class implements the Template Method pattern, allowing subclasses
299 to customize the search behaviour for different entity types.
300 """
302 def search(self, class_obj: type, explicit_name: str | None = None) -> TInfo | None:
303 """Search registry using name variants.
305 Args:
306 class_obj: The class to resolve info for
307 explicit_name: Optional explicit name override
309 Returns:
310 Resolved info object or None if not found
312 """
313 variants = self._generate_variants(class_obj.__name__, explicit_name)
315 for variant in variants:
316 info = self._lookup_in_registry(variant)
317 if info:
318 return info
320 return None
322 @abstractmethod
323 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
324 """Generate name variants for this entity type."""
326 @abstractmethod
327 def _lookup_in_registry(self, name: str) -> TInfo | None:
328 """Lookup name in registry and return domain type."""
331class CharacteristicRegistrySearch(RegistrySearchStrategy[CharacteristicInfo]): # pylint: disable=too-few-public-methods
332 """Registry search strategy for characteristics."""
334 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
335 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name)
337 def _lookup_in_registry(self, name: str) -> CharacteristicInfo | None:
338 return uuid_registry.get_characteristic_info(name)
341class ServiceRegistrySearch(RegistrySearchStrategy[ServiceInfo]): # pylint: disable=too-few-public-methods
342 """Registry search strategy for services."""
344 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
345 return NameVariantGenerator.generate_service_variants(class_name, explicit_name)
347 def _lookup_in_registry(self, name: str) -> ServiceInfo | None:
348 return uuid_registry.get_service_info(name)
351class DescriptorRegistrySearch(RegistrySearchStrategy[DescriptorInfo]): # pylint: disable=too-few-public-methods
352 """Registry search strategy for descriptors."""
354 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
355 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name)
357 def _lookup_in_registry(self, name: str) -> DescriptorInfo | None:
358 return uuid_registry.get_descriptor_info(name)