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

1"""Bluetooth SIG GATT characteristic registry. 

2 

3This module contains the characteristic registry implementation and 

4class mappings. CharacteristicName enum is now centralized in 

5types.gatt_enums to avoid circular imports. 

6""" 

7 

8from __future__ import annotations 

9 

10import re 

11from typing import Any, ClassVar, TypeGuard 

12 

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 

20 

21# Export for other modules to import 

22__all__ = ["CharacteristicName", "CharacteristicRegistry", "get_characteristic_class_map"] 

23 

24 

25def _is_characteristic_subclass(candidate: object) -> TypeGuard[type[BaseCharacteristic[Any]]]: 

26 """Type guard to check if candidate is a BaseCharacteristic subclass. 

27 

28 Args: 

29 candidate: Object to check 

30 

31 Returns: 

32 True if candidate is a subclass of BaseCharacteristic 

33 """ 

34 return TypeValidator.is_subclass_of(candidate, BaseCharacteristic) 

35 

36 

37class _RegistryKeyBuilder: 

38 """Builds registry lookup keys for characteristics.""" 

39 

40 _NON_ALPHANUMERIC_RE = re.compile(r"[^a-z0-9]+") 

41 

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 } 

47 

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("_") 

52 

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] 

61 

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] = {} 

66 

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 

74 

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) 

80 

81 return uuid_to_enum 

82 

83 

84def get_characteristic_class_map() -> dict[CharacteristicName, type[BaseCharacteristic[Any]]]: 

85 """Get the current characteristic class map. 

86 

87 Backward compatibility function that returns the current registry state. 

88 

89 Returns: 

90 Dictionary mapping CharacteristicName enum to characteristic classes 

91 """ 

92 return CharacteristicRegistry.get_instance()._get_enum_map() # pylint: disable=protected-access 

93 

94 

95class CharacteristicRegistry(BaseUUIDClassRegistry[CharacteristicName, BaseCharacteristic[Any]]): 

96 """Encapsulates all GATT characteristic registry operations.""" 

97 

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]+") 

100 

101 def _get_base_class(self) -> type[BaseCharacteristic[Any]]: 

102 """Return the base class for characteristic validation.""" 

103 return BaseCharacteristic 

104 

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) 

109 

110 return ModuleDiscovery.discover_classes( 

111 module_names, 

112 BaseCharacteristic, 

113 _is_characteristic_subclass, 

114 ) 

115 

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] = {} 

119 

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 

127 

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) 

137 

138 return uuid_to_enum 

139 

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() 

144 

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 

150 

151 if uuid_obj is None: 

152 continue 

153 

154 enum_member = uuid_to_enum.get(uuid_obj.normalized) 

155 if enum_member is None: 

156 continue 

157 

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 ) 

164 

165 mapping[enum_member] = char_cls 

166 

167 return mapping 

168 

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 

175 

176 # Backward compatibility aliases for existing API 

177 

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. 

183 

184 Backward compatibility wrapper for register_class(). 

185 """ 

186 instance = cls.get_instance() 

187 instance.register_class(uuid, char_cls, override) 

188 

189 @classmethod 

190 def unregister_characteristic_class(cls, uuid: str | BluetoothUUID | int) -> None: 

191 """Unregister a custom characteristic class. 

192 

193 Backward compatibility wrapper for unregister_class(). 

194 """ 

195 instance = cls.get_instance() 

196 instance.unregister_class(uuid) 

197 

198 @classmethod 

199 def get_characteristic_class(cls, name: CharacteristicName) -> type[BaseCharacteristic[Any]] | None: 

200 """Get the characteristic class for a given CharacteristicName enum. 

201 

202 Backward compatibility wrapper for get_class_by_enum(). 

203 """ 

204 instance = cls.get_instance() 

205 return instance.get_class_by_enum(name) 

206 

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. 

210 

211 Backward compatibility wrapper for get_class_by_uuid(). 

212 """ 

213 instance = cls.get_instance() 

214 return instance.get_class_by_uuid(uuid) 

215 

216 @classmethod 

217 def get_characteristic(cls, uuid: str | BluetoothUUID | int) -> BaseCharacteristic[Any] | None: 

218 """Get a characteristic instance from a UUID. 

219 

220 Args: 

221 uuid: The characteristic UUID (string, BluetoothUUID, or int) 

222 

223 Returns: 

224 Characteristic instance if found, None if UUID not registered 

225 

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) 

231 

232 instance = cls.get_instance() 

233 char_cls = instance.get_class_by_uuid(uuid_obj) 

234 if char_cls is None: 

235 return None 

236 

237 return char_cls() 

238 

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] 

243 

244 @staticmethod 

245 def list_all_characteristic_enums() -> list[CharacteristicName]: 

246 """List all supported characteristic names as enum values.""" 

247 return list(CharacteristicName) 

248 

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 

254 

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) 

261 

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