Coverage for src / bluetooth_sig / gatt / characteristics / registry.py: 80%
138 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"""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
13from typing_extensions import TypeGuard
15from ...registry.base import BaseUUIDClassRegistry
16from ...types.gatt_enums import CharacteristicName
17from ...types.uuid import BluetoothUUID
18from ..registry_utils import ModuleDiscovery, TypeValidator
19from ..resolver import NameVariantGenerator
20from ..uuid_registry import uuid_registry
21from .base import BaseCharacteristic
23# Export for other modules to import
24__all__ = ["CharacteristicName", "get_characteristic_class_map", "CharacteristicRegistry"]
27def _is_characteristic_subclass(candidate: object) -> TypeGuard[type[BaseCharacteristic[Any]]]:
28 """Type guard to check if candidate is a BaseCharacteristic subclass.
30 Args:
31 candidate: Object to check
33 Returns:
34 True if candidate is a subclass of BaseCharacteristic
35 """
36 return TypeValidator.is_subclass_of(candidate, BaseCharacteristic)
39class _RegistryKeyBuilder:
40 """Builds registry lookup keys for characteristics."""
42 _NON_ALPHANUMERIC_RE = re.compile(r"[^a-z0-9]+")
44 # Special cases for characteristics whose YAML names don't match enum display names
45 # NOTE: CO2 uses LaTeX formatting in official Bluetooth SIG spec: "CO\textsubscript{2} Concentration"
46 _SPECIAL_INFO_NAME_TO_ENUM = {
47 "CO\\textsubscript{2} Concentration": CharacteristicName.CO2_CONCENTRATION,
48 }
50 @classmethod
51 def slugify_characteristic_identifier(cls, value: str) -> str:
52 """Convert a characteristic display name into an org.bluetooth identifier slug."""
53 return cls._NON_ALPHANUMERIC_RE.sub("_", value.lower()).strip("_")
55 @classmethod
56 def generate_candidate_keys(cls, enum_member: CharacteristicName) -> list[str]:
57 """Generate registry lookup keys for a characteristic enum value."""
58 class_name = enum_member.value.replace(" ", "") + "Characteristic"
59 variants = NameVariantGenerator.generate_characteristic_variants(class_name, enum_member.value)
60 slug = cls.slugify_characteristic_identifier(enum_member.value)
61 org_identifier = f"org.bluetooth.characteristic.{slug}"
62 return [*variants, enum_member.name.replace("_", " "), org_identifier]
64 @classmethod
65 def build_uuid_to_enum_map(cls) -> dict[str, CharacteristicName]:
66 """Create a mapping from normalized UUID string to CharacteristicName."""
67 uuid_to_enum: dict[str, CharacteristicName] = {}
69 for enum_member in CharacteristicName:
70 for candidate in cls.generate_candidate_keys(enum_member):
71 info = uuid_registry.get_characteristic_info(candidate)
72 if info is None:
73 continue
74 uuid_to_enum[info.uuid.normalized] = enum_member
75 break
77 for info_name, enum_member in cls._SPECIAL_INFO_NAME_TO_ENUM.items():
78 info = uuid_registry.get_characteristic_info(info_name)
79 if info is None:
80 continue
81 uuid_to_enum.setdefault(info.uuid.normalized, enum_member)
83 return uuid_to_enum
86def get_characteristic_class_map() -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
87 """Get the current characteristic class map.
89 Backward compatibility function that returns the current registry state.
91 Returns:
92 Dictionary mapping CharacteristicName enum to characteristic classes
93 """
94 return CharacteristicRegistry.get_instance()._get_enum_map() # pylint: disable=protected-access
97class CharacteristicRegistry(BaseUUIDClassRegistry[CharacteristicName, BaseCharacteristic[Any]]):
98 """Encapsulates all GATT characteristic registry operations."""
100 _MODULE_EXCLUSIONS = {"__main__", "__init__", "base", "registry", "templates"}
101 _NON_ALPHANUMERIC_RE = re.compile(r"[^a-z0-9]+")
103 def _get_base_class(self) -> type[BaseCharacteristic[Any]]:
104 """Return the base class for characteristic validation."""
105 return BaseCharacteristic
107 def _discover_sig_classes(self) -> list[type[BaseCharacteristic[Any]]]:
108 """Discover all SIG-defined characteristic classes in the package."""
109 package_name = __package__ or "bluetooth_sig.gatt.characteristics"
110 module_names = ModuleDiscovery.iter_module_names(package_name, self._MODULE_EXCLUSIONS)
112 return ModuleDiscovery.discover_classes(
113 module_names,
114 BaseCharacteristic,
115 _is_characteristic_subclass,
116 )
118 def _build_uuid_to_enum_map(self) -> dict[str, CharacteristicName]:
119 """Create a mapping from normalized UUID string to CharacteristicName."""
120 uuid_to_enum: dict[str, CharacteristicName] = {}
122 for enum_member in CharacteristicName:
123 for candidate in _RegistryKeyBuilder.generate_candidate_keys(enum_member):
124 info = uuid_registry.get_characteristic_info(candidate)
125 if info is None:
126 continue
127 uuid_to_enum[info.uuid.normalized] = enum_member
128 break
130 # Handle special cases (CO2, etc.) - access via static method to avoid protected access
131 special_cases = {
132 "CO\\textsubscript{2} Concentration": CharacteristicName.CO2_CONCENTRATION,
133 }
134 for info_name, enum_member in special_cases.items():
135 info = uuid_registry.get_characteristic_info(info_name)
136 if info is None:
137 continue
138 uuid_to_enum.setdefault(info.uuid.normalized, enum_member)
140 return uuid_to_enum
142 def _build_enum_map(self) -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
143 """Build the enum → class mapping using runtime discovery."""
144 mapping: dict[CharacteristicName, type[BaseCharacteristic[Any]]] = {}
145 uuid_to_enum = self._build_uuid_to_enum_map()
147 for char_cls in self._discover_sig_classes():
148 try:
149 uuid_obj = char_cls.get_class_uuid()
150 except (AttributeError, ValueError):
151 continue
153 if uuid_obj is None:
154 continue
156 enum_member = uuid_to_enum.get(uuid_obj.normalized)
157 if enum_member is None:
158 continue
160 existing = mapping.get(enum_member)
161 if existing is not None and existing is not char_cls:
162 raise RuntimeError(
163 f"Multiple characteristic classes resolved for {enum_member.name}: "
164 f"{existing.__name__} and {char_cls.__name__}"
165 )
167 mapping[enum_member] = char_cls
169 return mapping
171 def _load(self) -> None:
172 """Perform the actual loading of registry data."""
173 # Trigger cache building
174 _ = self._get_enum_map()
175 _ = self._get_sig_classes_map()
176 self._loaded = True
178 # Backward compatibility aliases for existing API
180 @classmethod
181 def register_characteristic_class(
182 cls, uuid: str | BluetoothUUID | int, char_cls: type[BaseCharacteristic[Any]], override: bool = False
183 ) -> None:
184 """Register a custom characteristic class at runtime.
186 Backward compatibility wrapper for register_class().
187 """
188 instance = cls.get_instance()
189 instance.register_class(uuid, char_cls, override)
191 @classmethod
192 def unregister_characteristic_class(cls, uuid: str | BluetoothUUID | int) -> None:
193 """Unregister a custom characteristic class.
195 Backward compatibility wrapper for unregister_class().
196 """
197 instance = cls.get_instance()
198 instance.unregister_class(uuid)
200 @classmethod
201 def get_characteristic_class(cls, name: CharacteristicName) -> type[BaseCharacteristic[Any]] | None:
202 """Get the characteristic class for a given CharacteristicName enum.
204 Backward compatibility wrapper for get_class_by_enum().
205 """
206 instance = cls.get_instance()
207 return instance.get_class_by_enum(name)
209 @classmethod
210 def get_characteristic_class_by_uuid(cls, uuid: str | BluetoothUUID | int) -> type[BaseCharacteristic[Any]] | None:
211 """Get the characteristic class for a given UUID.
213 Backward compatibility wrapper for get_class_by_uuid().
214 """
215 instance = cls.get_instance()
216 return instance.get_class_by_uuid(uuid)
218 @classmethod
219 def create_characteristic(cls, uuid: str | BluetoothUUID | int) -> BaseCharacteristic[Any] | None:
220 """Create a characteristic instance from a UUID.
222 Args:
223 uuid: The characteristic UUID (string, BluetoothUUID, or int)
225 Returns:
226 Characteristic instance if found, None if UUID not registered
228 Raises:
229 ValueError: If uuid format is invalid
230 """
231 # Normalize to BluetoothUUID (let ValueError propagate for invalid format)
232 if isinstance(uuid, BluetoothUUID):
233 uuid_obj = uuid
234 else:
235 uuid_obj = BluetoothUUID(uuid)
237 instance = cls.get_instance()
238 char_cls = instance.get_class_by_uuid(uuid_obj)
239 if char_cls is None:
240 return None
242 return char_cls()
244 @staticmethod
245 def list_all_characteristic_names() -> list[str]:
246 """List all supported characteristic names as strings."""
247 return [e.value for e in CharacteristicName]
249 @staticmethod
250 def list_all_characteristic_enums() -> list[CharacteristicName]:
251 """List all supported characteristic names as enum values."""
252 return list(CharacteristicName)
254 @classmethod
255 def get_all_characteristics(cls) -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]:
256 """Get all registered characteristic classes."""
257 instance = cls.get_instance()
258 return instance._get_enum_map().copy() # pylint: disable=protected-access
260 @classmethod
261 def clear_custom_registrations(cls) -> None:
262 """Clear all custom characteristic registrations (for testing)."""
263 instance = cls.get_instance()
264 for uuid in list(instance.list_custom_uuids()):
265 instance.unregister_class(uuid)
267 @classmethod
268 def clear_cache(cls) -> None:
269 """Clear the characteristic class map cache (for testing)."""
270 instance = cls.get_instance()
271 instance.clear_enum_map_cache()
272 instance._load() # Reload to repopulate # pylint: disable=protected-access