Coverage for src/bluetooth_sig/gatt/characteristics/registry.py: 81%
135 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"""Bluetooth SIG GATT characteristic registry.
3This module contains the characteristic registry implementation and
4class mappings. CharacteristicName enum is now centralized in
5types.gatt_enums to avoid circular imports.
6"""
8from __future__ import annotations
10import re
11from typing import Any, ClassVar, TypeGuard
13from ...registry.base import BaseUUIDClassRegistry
14from ...types.gatt_enums import CharacteristicName
15from ...types.uuid import BluetoothUUID
16from ..registry_utils import ModuleDiscovery, TypeValidator
17from ..resolver import NameVariantGenerator
18from ..uuid_registry import get_uuid_registry
19from .base import BaseCharacteristic
21# Export for other modules to import
22__all__ = ["CharacteristicName", "CharacteristicRegistry", "get_characteristic_class_map"]
25def _is_characteristic_subclass(candidate: object) -> TypeGuard[type[BaseCharacteristic[Any]]]:
26 """Type guard to check if candidate is a BaseCharacteristic subclass.
28 Args:
29 candidate: Object to check
31 Returns:
32 True if candidate is a subclass of BaseCharacteristic
33 """
34 return TypeValidator.is_subclass_of(candidate, BaseCharacteristic)
37class _RegistryKeyBuilder:
38 """Builds registry lookup keys for characteristics."""
40 _NON_ALPHANUMERIC_RE = re.compile(r"[^a-z0-9]+")
42 # Special cases for characteristics whose YAML names don't match enum display names
43 # NOTE: CO2 uses LaTeX formatting in official Bluetooth SIG spec: "CO\textsubscript{2} Concentration"
44 _SPECIAL_INFO_NAME_TO_ENUM: ClassVar[dict[str, CharacteristicName]] = {
45 "CO\\textsubscript{2} Concentration": CharacteristicName.CO2_CONCENTRATION,
46 }
48 @classmethod
49 def slugify_characteristic_identifier(cls, value: str) -> str:
50 """Convert a characteristic display name into an org.bluetooth identifier slug."""
51 return cls._NON_ALPHANUMERIC_RE.sub("_", value.lower()).strip("_")
53 @classmethod
54 def generate_candidate_keys(cls, enum_member: CharacteristicName) -> list[str]:
55 """Generate registry lookup keys for a characteristic enum value."""
56 class_name = enum_member.value.replace(" ", "") + "Characteristic"
57 variants = NameVariantGenerator.generate_characteristic_variants(class_name, enum_member.value)
58 slug = cls.slugify_characteristic_identifier(enum_member.value)
59 org_identifier = f"org.bluetooth.characteristic.{slug}"
60 return [*variants, enum_member.name.replace("_", " "), org_identifier]
62 @classmethod
63 def build_uuid_to_enum_map(cls) -> dict[str, CharacteristicName]:
64 """Create a mapping from normalized UUID string to CharacteristicName."""
65 uuid_to_enum: dict[str, CharacteristicName] = {}
67 for enum_member in CharacteristicName:
68 for candidate in cls.generate_candidate_keys(enum_member):
69 info = get_uuid_registry().get_characteristic_info(candidate)
70 if info is None:
71 continue
72 uuid_to_enum[info.uuid.normalized] = enum_member
73 break
75 for info_name, enum_member in cls._SPECIAL_INFO_NAME_TO_ENUM.items():
76 info = get_uuid_registry().get_characteristic_info(info_name)
77 if info is None:
78 continue
79 uuid_to_enum.setdefault(info.uuid.normalized, enum_member)
81 return uuid_to_enum
84def get_characteristic_class_map() -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
85 """Get the current characteristic class map.
87 Backward compatibility function that returns the current registry state.
89 Returns:
90 Dictionary mapping CharacteristicName enum to characteristic classes
91 """
92 return CharacteristicRegistry.get_instance()._get_enum_map() # pylint: disable=protected-access
95class CharacteristicRegistry(BaseUUIDClassRegistry[CharacteristicName, BaseCharacteristic[Any]]):
96 """Encapsulates all GATT characteristic registry operations."""
98 _MODULE_EXCLUSIONS: ClassVar[set[str]] = {
99 "__main__",
100 "__init__",
101 "_export_map",
102 "base",
103 "registry",
104 "templates",
105 }
106 _NON_ALPHANUMERIC_RE: ClassVar[re.Pattern[str]] = re.compile(r"[^a-z0-9]+")
108 def _get_base_class(self) -> type[BaseCharacteristic[Any]]:
109 """Return the base class for characteristic validation."""
110 return BaseCharacteristic
112 def _discover_sig_classes(self) -> list[type[BaseCharacteristic[Any]]]:
113 """Discover all SIG-defined characteristic classes in the package."""
114 package_name = __package__ or "bluetooth_sig.gatt.characteristics"
115 module_names = ModuleDiscovery.iter_module_names(package_name, self._MODULE_EXCLUSIONS)
117 return ModuleDiscovery.discover_classes(
118 module_names,
119 BaseCharacteristic,
120 _is_characteristic_subclass,
121 )
123 def _build_uuid_to_enum_map(self) -> dict[str, CharacteristicName]:
124 """Create a mapping from normalized UUID string to CharacteristicName."""
125 uuid_to_enum: dict[str, CharacteristicName] = {}
127 for enum_member in CharacteristicName:
128 for candidate in _RegistryKeyBuilder.generate_candidate_keys(enum_member):
129 info = get_uuid_registry().get_characteristic_info(candidate)
130 if info is None:
131 continue
132 uuid_to_enum[info.uuid.normalized] = enum_member
133 break
135 # Handle special cases (CO2, etc.) - access via static method to avoid protected access
136 special_cases = {
137 "CO\\textsubscript{2} Concentration": CharacteristicName.CO2_CONCENTRATION,
138 }
139 for info_name, enum_member in special_cases.items():
140 info = get_uuid_registry().get_characteristic_info(info_name)
141 if info is None:
142 continue
143 uuid_to_enum.setdefault(info.uuid.normalized, enum_member)
145 return uuid_to_enum
147 def _build_enum_map(self) -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
148 """Build the enum → class mapping using runtime discovery."""
149 mapping: dict[CharacteristicName, type[BaseCharacteristic[Any]]] = {}
150 uuid_to_enum = self._build_uuid_to_enum_map()
152 for char_cls in self._discover_sig_classes():
153 try:
154 uuid_obj = char_cls.get_class_uuid()
155 except (AttributeError, ValueError):
156 continue
158 if uuid_obj is None:
159 continue
161 enum_member = uuid_to_enum.get(uuid_obj.normalized)
162 if enum_member is None:
163 continue
165 existing = mapping.get(enum_member)
166 if existing is not None and existing is not char_cls:
167 raise RuntimeError(
168 f"Multiple characteristic classes resolved for {enum_member.name}: "
169 f"{existing.__name__} and {char_cls.__name__}"
170 )
172 mapping[enum_member] = char_cls
174 return mapping
176 def _load(self) -> None:
177 """Perform the actual loading of registry data."""
178 # Trigger cache building
179 _ = self._get_enum_map()
180 _ = self._get_sig_classes_map()
181 self._loaded = True
183 # Backward compatibility aliases for existing API
185 @classmethod
186 def register_characteristic_class(
187 cls, uuid: str | BluetoothUUID | int, char_cls: type[BaseCharacteristic[Any]], override: bool = False
188 ) -> None:
189 """Register a custom characteristic class at runtime.
191 Backward compatibility wrapper for register_class().
192 """
193 instance = cls.get_instance()
194 instance.register_class(uuid, char_cls, override)
196 @classmethod
197 def unregister_characteristic_class(cls, uuid: str | BluetoothUUID | int) -> None:
198 """Unregister a custom characteristic class.
200 Backward compatibility wrapper for unregister_class().
201 """
202 instance = cls.get_instance()
203 instance.unregister_class(uuid)
205 @classmethod
206 def get_characteristic_class(cls, name: CharacteristicName) -> type[BaseCharacteristic[Any]] | None:
207 """Get the characteristic class for a given CharacteristicName enum.
209 Backward compatibility wrapper for get_class_by_enum().
210 """
211 instance = cls.get_instance()
212 return instance.get_class_by_enum(name)
214 @classmethod
215 def get_characteristic_class_by_uuid(cls, uuid: str | BluetoothUUID | int) -> type[BaseCharacteristic[Any]] | None:
216 """Get the characteristic class for a given UUID.
218 Backward compatibility wrapper for get_class_by_uuid().
219 """
220 instance = cls.get_instance()
221 return instance.get_class_by_uuid(uuid)
223 @classmethod
224 def get_characteristic(cls, uuid: str | BluetoothUUID | int) -> BaseCharacteristic[Any] | None:
225 """Get a characteristic instance from a UUID.
227 Args:
228 uuid: The characteristic UUID (string, BluetoothUUID, or int)
230 Returns:
231 Characteristic instance if found, None if UUID not registered
233 Raises:
234 ValueError: If uuid format is invalid
235 """
236 # Normalize to BluetoothUUID (let ValueError propagate for invalid format)
237 uuid_obj = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid)
239 instance = cls.get_instance()
240 char_cls = instance.get_class_by_uuid(uuid_obj)
241 if char_cls is None:
242 return None
244 return char_cls()
246 @staticmethod
247 def list_all_characteristic_names() -> list[str]:
248 """List all supported characteristic names as strings."""
249 return [e.value for e in CharacteristicName]
251 @staticmethod
252 def list_all_characteristic_enums() -> list[CharacteristicName]:
253 """List all supported characteristic names as enum values."""
254 return list(CharacteristicName)
256 @classmethod
257 def get_all_characteristics(cls) -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
258 """Get all registered characteristic classes."""
259 instance = cls.get_instance()
260 return instance._get_enum_map().copy() # pylint: disable=protected-access
262 @classmethod
263 def clear_custom_registrations(cls) -> None:
264 """Clear all custom characteristic registrations (for testing)."""
265 instance = cls.get_instance()
266 for uuid in list(instance.list_custom_uuids()):
267 instance.unregister_class(uuid)
269 @classmethod
270 def clear_cache(cls) -> None:
271 """Clear the characteristic class map cache (for testing)."""
272 instance = cls.get_instance()
273 instance.clear_enum_map_cache()
274 instance._load() # Reload to repopulate # pylint: disable=protected-access