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

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 

12 

13from typing_extensions import TypeGuard 

14 

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 

22 

23# Export for other modules to import 

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

25 

26 

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

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

29 

30 Args: 

31 candidate: Object to check 

32 

33 Returns: 

34 True if candidate is a subclass of BaseCharacteristic 

35 """ 

36 return TypeValidator.is_subclass_of(candidate, BaseCharacteristic) 

37 

38 

39class _RegistryKeyBuilder: 

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

41 

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

43 

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 } 

49 

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

54 

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] 

63 

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

68 

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 

76 

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) 

82 

83 return uuid_to_enum 

84 

85 

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

87 """Get the current characteristic class map. 

88 

89 Backward compatibility function that returns the current registry state. 

90 

91 Returns: 

92 Dictionary mapping CharacteristicName enum to characteristic classes 

93 """ 

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

95 

96 

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

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

99 

100 _MODULE_EXCLUSIONS = {"__main__", "__init__", "base", "registry", "templates"} 

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

102 

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

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

105 return BaseCharacteristic 

106 

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) 

111 

112 return ModuleDiscovery.discover_classes( 

113 module_names, 

114 BaseCharacteristic, 

115 _is_characteristic_subclass, 

116 ) 

117 

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

121 

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 

129 

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) 

139 

140 return uuid_to_enum 

141 

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

146 

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 

152 

153 if uuid_obj is None: 

154 continue 

155 

156 enum_member = uuid_to_enum.get(uuid_obj.normalized) 

157 if enum_member is None: 

158 continue 

159 

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 ) 

166 

167 mapping[enum_member] = char_cls 

168 

169 return mapping 

170 

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 

177 

178 # Backward compatibility aliases for existing API 

179 

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. 

185 

186 Backward compatibility wrapper for register_class(). 

187 """ 

188 instance = cls.get_instance() 

189 instance.register_class(uuid, char_cls, override) 

190 

191 @classmethod 

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

193 """Unregister a custom characteristic class. 

194 

195 Backward compatibility wrapper for unregister_class(). 

196 """ 

197 instance = cls.get_instance() 

198 instance.unregister_class(uuid) 

199 

200 @classmethod 

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

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

203 

204 Backward compatibility wrapper for get_class_by_enum(). 

205 """ 

206 instance = cls.get_instance() 

207 return instance.get_class_by_enum(name) 

208 

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. 

212 

213 Backward compatibility wrapper for get_class_by_uuid(). 

214 """ 

215 instance = cls.get_instance() 

216 return instance.get_class_by_uuid(uuid) 

217 

218 @classmethod 

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

220 """Create a characteristic instance from a UUID. 

221 

222 Args: 

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

224 

225 Returns: 

226 Characteristic instance if found, None if UUID not registered 

227 

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) 

236 

237 instance = cls.get_instance() 

238 char_cls = instance.get_class_by_uuid(uuid_obj) 

239 if char_cls is None: 

240 return None 

241 

242 return char_cls() 

243 

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] 

248 

249 @staticmethod 

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

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

252 return list(CharacteristicName) 

253 

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 

259 

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) 

266 

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