Coverage for src / bluetooth_sig / gatt / characteristics / registry.py: 81%
135 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"""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 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 = 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 = 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]] = {"__main__", "__init__", "base", "registry", "templates"}
99 _NON_ALPHANUMERIC_RE: ClassVar[re.Pattern[str]] = re.compile(r"[^a-z0-9]+")
101 def _get_base_class(self) -> type[BaseCharacteristic[Any]]:
102 """Return the base class for characteristic validation."""
103 return BaseCharacteristic
105 def _discover_sig_classes(self) -> list[type[BaseCharacteristic[Any]]]:
106 """Discover all SIG-defined characteristic classes in the package."""
107 package_name = __package__ or "bluetooth_sig.gatt.characteristics"
108 module_names = ModuleDiscovery.iter_module_names(package_name, self._MODULE_EXCLUSIONS)
110 return ModuleDiscovery.discover_classes(
111 module_names,
112 BaseCharacteristic,
113 _is_characteristic_subclass,
114 )
116 def _build_uuid_to_enum_map(self) -> dict[str, CharacteristicName]:
117 """Create a mapping from normalized UUID string to CharacteristicName."""
118 uuid_to_enum: dict[str, CharacteristicName] = {}
120 for enum_member in CharacteristicName:
121 for candidate in _RegistryKeyBuilder.generate_candidate_keys(enum_member):
122 info = uuid_registry.get_characteristic_info(candidate)
123 if info is None:
124 continue
125 uuid_to_enum[info.uuid.normalized] = enum_member
126 break
128 # Handle special cases (CO2, etc.) - access via static method to avoid protected access
129 special_cases = {
130 "CO\\textsubscript{2} Concentration": CharacteristicName.CO2_CONCENTRATION,
131 }
132 for info_name, enum_member in special_cases.items():
133 info = uuid_registry.get_characteristic_info(info_name)
134 if info is None:
135 continue
136 uuid_to_enum.setdefault(info.uuid.normalized, enum_member)
138 return uuid_to_enum
140 def _build_enum_map(self) -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
141 """Build the enum → class mapping using runtime discovery."""
142 mapping: dict[CharacteristicName, type[BaseCharacteristic[Any]]] = {}
143 uuid_to_enum = self._build_uuid_to_enum_map()
145 for char_cls in self._discover_sig_classes():
146 try:
147 uuid_obj = char_cls.get_class_uuid()
148 except (AttributeError, ValueError):
149 continue
151 if uuid_obj is None:
152 continue
154 enum_member = uuid_to_enum.get(uuid_obj.normalized)
155 if enum_member is None:
156 continue
158 existing = mapping.get(enum_member)
159 if existing is not None and existing is not char_cls:
160 raise RuntimeError(
161 f"Multiple characteristic classes resolved for {enum_member.name}: "
162 f"{existing.__name__} and {char_cls.__name__}"
163 )
165 mapping[enum_member] = char_cls
167 return mapping
169 def _load(self) -> None:
170 """Perform the actual loading of registry data."""
171 # Trigger cache building
172 _ = self._get_enum_map()
173 _ = self._get_sig_classes_map()
174 self._loaded = True
176 # Backward compatibility aliases for existing API
178 @classmethod
179 def register_characteristic_class(
180 cls, uuid: str | BluetoothUUID | int, char_cls: type[BaseCharacteristic[Any]], override: bool = False
181 ) -> None:
182 """Register a custom characteristic class at runtime.
184 Backward compatibility wrapper for register_class().
185 """
186 instance = cls.get_instance()
187 instance.register_class(uuid, char_cls, override)
189 @classmethod
190 def unregister_characteristic_class(cls, uuid: str | BluetoothUUID | int) -> None:
191 """Unregister a custom characteristic class.
193 Backward compatibility wrapper for unregister_class().
194 """
195 instance = cls.get_instance()
196 instance.unregister_class(uuid)
198 @classmethod
199 def get_characteristic_class(cls, name: CharacteristicName) -> type[BaseCharacteristic[Any]] | None:
200 """Get the characteristic class for a given CharacteristicName enum.
202 Backward compatibility wrapper for get_class_by_enum().
203 """
204 instance = cls.get_instance()
205 return instance.get_class_by_enum(name)
207 @classmethod
208 def get_characteristic_class_by_uuid(cls, uuid: str | BluetoothUUID | int) -> type[BaseCharacteristic[Any]] | None:
209 """Get the characteristic class for a given UUID.
211 Backward compatibility wrapper for get_class_by_uuid().
212 """
213 instance = cls.get_instance()
214 return instance.get_class_by_uuid(uuid)
216 @classmethod
217 def get_characteristic(cls, uuid: str | BluetoothUUID | int) -> BaseCharacteristic[Any] | None:
218 """Get a characteristic instance from a UUID.
220 Args:
221 uuid: The characteristic UUID (string, BluetoothUUID, or int)
223 Returns:
224 Characteristic instance if found, None if UUID not registered
226 Raises:
227 ValueError: If uuid format is invalid
228 """
229 # Normalize to BluetoothUUID (let ValueError propagate for invalid format)
230 uuid_obj = uuid if isinstance(uuid, BluetoothUUID) else BluetoothUUID(uuid)
232 instance = cls.get_instance()
233 char_cls = instance.get_class_by_uuid(uuid_obj)
234 if char_cls is None:
235 return None
237 return char_cls()
239 @staticmethod
240 def list_all_characteristic_names() -> list[str]:
241 """List all supported characteristic names as strings."""
242 return [e.value for e in CharacteristicName]
244 @staticmethod
245 def list_all_characteristic_enums() -> list[CharacteristicName]:
246 """List all supported characteristic names as enum values."""
247 return list(CharacteristicName)
249 @classmethod
250 def get_all_characteristics(cls) -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
251 """Get all registered characteristic classes."""
252 instance = cls.get_instance()
253 return instance._get_enum_map().copy() # pylint: disable=protected-access
255 @classmethod
256 def clear_custom_registrations(cls) -> None:
257 """Clear all custom characteristic registrations (for testing)."""
258 instance = cls.get_instance()
259 for uuid in list(instance.list_custom_uuids()):
260 instance.unregister_class(uuid)
262 @classmethod
263 def clear_cache(cls) -> None:
264 """Clear the characteristic class map cache (for testing)."""
265 instance = cls.get_instance()
266 instance.clear_enum_map_cache()
267 instance._load() # Reload to repopulate # pylint: disable=protected-access