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
« 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.
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 result = re.sub(r"([a-z])(\d+)", r"\1 \2", result)
57 return result
59 @staticmethod
60 def remove_suffix(name: str, suffix: str) -> str:
61 """Remove suffix from name if present.
63 Args:
64 name: Original name
65 suffix: Suffix to remove (e.g., "Characteristic", "Service")
67 Returns:
68 Name without suffix, or original name if suffix not present
70 """
71 if name.endswith(suffix):
72 return name[: -len(suffix)]
73 return name
75 @staticmethod
76 def to_org_format(words: list[str], entity_type: str) -> str:
77 """Convert words to org.bluetooth format.
79 Args:
80 words: List of words from name split
81 entity_type: Type of entity ("characteristic" or "service")
83 Returns:
84 Org format string (e.g., "org.bluetooth.characteristic.battery_level")
86 """
87 return f"org.bluetooth.{entity_type}." + "_".join(word.lower() for word in words)
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)
130class NameVariantGenerator:
131 """Generates name variants for registry lookups.
133 Produces all possible name formats that might match registry entries,
134 ordered by likelihood of success.
135 """
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.
141 Args:
142 class_name: The __name__ of the characteristic class
143 explicit_name: Optional explicit name override
145 Returns:
146 List of name variants ordered by likelihood of success
148 """
149 variants: list[str] = []
151 # If explicit name provided, try it first
152 if explicit_name:
153 variants.append(explicit_name)
155 # Remove 'Characteristic' suffix if present
156 base_name = NameNormalizer.remove_suffix(class_name, "Characteristic")
158 # Convert to space-separated display name
159 display_name = NameNormalizer.camel_case_to_display_name(base_name)
161 # Generate org format
162 words = display_name.split()
163 org_name = NameNormalizer.to_org_format(words, "characteristic")
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 )
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
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.
192 Args:
193 class_name: The __name__ of the service class
194 explicit_name: Optional explicit name override
196 Returns:
197 List of name variants ordered by likelihood of success
199 """
200 variants: list[str] = []
202 # If explicit name provided, try it first
203 if explicit_name:
204 variants.append(explicit_name)
206 # Remove 'Service' suffix if present
207 base_name = NameNormalizer.remove_suffix(class_name, "Service")
209 # Split on camelCase and convert to space-separated
210 words = re.findall("[A-Z][^A-Z]*", base_name)
211 display_name = " ".join(words)
213 # Generate org format
214 org_name = NameNormalizer.to_org_format(words, "service")
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 )
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
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.
245 Args:
246 class_name: The __name__ of the descriptor class
247 explicit_name: Optional explicit name override
249 Returns:
250 List of name variants ordered by likelihood of success
252 """
253 variants: list[str] = []
255 # If explicit name provided, try it first
256 if explicit_name:
257 variants.append(explicit_name)
259 # Remove 'Descriptor' suffix if present
260 base_name = NameNormalizer.remove_suffix(class_name, "Descriptor")
262 # Convert to space-separated display name
263 display_name = NameNormalizer.camel_case_to_display_name(base_name)
265 # Generate org format
266 words = display_name.split()
267 org_name = NameNormalizer.to_org_format(words, "descriptor")
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 )
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
293class RegistrySearchStrategy(Generic[TInfo]): # pylint: disable=too-few-public-methods
294 """Base strategy for searching registry with name variants.
296 This class implements the Template Method pattern, allowing subclasses
297 to customize the search behaviour for different entity types.
298 """
300 def search(self, class_obj: type, explicit_name: str | None = None) -> TInfo | None:
301 """Search registry using name variants.
303 Args:
304 class_obj: The class to resolve info for
305 explicit_name: Optional explicit name override
307 Returns:
308 Resolved info object or None if not found
310 """
311 variants = self._generate_variants(class_obj.__name__, explicit_name)
313 for variant in variants:
314 info = self._lookup_in_registry(variant)
315 if info:
316 return info
318 return None
320 @abstractmethod
321 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
322 """Generate name variants for this entity type."""
324 @abstractmethod
325 def _lookup_in_registry(self, name: str) -> TInfo | None:
326 """Lookup name in registry and return domain type."""
329class CharacteristicRegistrySearch(RegistrySearchStrategy[CharacteristicInfo]): # pylint: disable=too-few-public-methods
330 """Registry search strategy for characteristics."""
332 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
333 return NameVariantGenerator.generate_characteristic_variants(class_name, explicit_name)
335 def _lookup_in_registry(self, name: str) -> CharacteristicInfo | None:
336 return uuid_registry.get_characteristic_info(name)
339class ServiceRegistrySearch(RegistrySearchStrategy[ServiceInfo]): # pylint: disable=too-few-public-methods
340 """Registry search strategy for services."""
342 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
343 return NameVariantGenerator.generate_service_variants(class_name, explicit_name)
345 def _lookup_in_registry(self, name: str) -> ServiceInfo | None:
346 return uuid_registry.get_service_info(name)
349class DescriptorRegistrySearch(RegistrySearchStrategy[DescriptorInfo]): # pylint: disable=too-few-public-methods
350 """Registry search strategy for descriptors."""
352 def _generate_variants(self, class_name: str, explicit_name: str | None) -> list[str]:
353 return NameVariantGenerator.generate_descriptor_variants(class_name, explicit_name)
355 def _lookup_in_registry(self, name: str) -> DescriptorInfo | None:
356 return uuid_registry.get_descriptor_info(name)