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

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 get_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 = 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 

74 

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) 

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

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

107 

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

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

110 return BaseCharacteristic 

111 

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) 

116 

117 return ModuleDiscovery.discover_classes( 

118 module_names, 

119 BaseCharacteristic, 

120 _is_characteristic_subclass, 

121 ) 

122 

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

126 

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 

134 

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) 

144 

145 return uuid_to_enum 

146 

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

151 

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 

157 

158 if uuid_obj is None: 

159 continue 

160 

161 enum_member = uuid_to_enum.get(uuid_obj.normalized) 

162 if enum_member is None: 

163 continue 

164 

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 ) 

171 

172 mapping[enum_member] = char_cls 

173 

174 return mapping 

175 

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 

182 

183 # Backward compatibility aliases for existing API 

184 

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. 

190 

191 Backward compatibility wrapper for register_class(). 

192 """ 

193 instance = cls.get_instance() 

194 instance.register_class(uuid, char_cls, override) 

195 

196 @classmethod 

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

198 """Unregister a custom characteristic class. 

199 

200 Backward compatibility wrapper for unregister_class(). 

201 """ 

202 instance = cls.get_instance() 

203 instance.unregister_class(uuid) 

204 

205 @classmethod 

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

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

208 

209 Backward compatibility wrapper for get_class_by_enum(). 

210 """ 

211 instance = cls.get_instance() 

212 return instance.get_class_by_enum(name) 

213 

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. 

217 

218 Backward compatibility wrapper for get_class_by_uuid(). 

219 """ 

220 instance = cls.get_instance() 

221 return instance.get_class_by_uuid(uuid) 

222 

223 @classmethod 

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

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

226 

227 Args: 

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

229 

230 Returns: 

231 Characteristic instance if found, None if UUID not registered 

232 

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) 

238 

239 instance = cls.get_instance() 

240 char_cls = instance.get_class_by_uuid(uuid_obj) 

241 if char_cls is None: 

242 return None 

243 

244 return char_cls() 

245 

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] 

250 

251 @staticmethod 

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

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

254 return list(CharacteristicName) 

255 

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 

261 

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) 

268 

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