Coverage for src / bluetooth_sig / gatt / characteristics / custom.py: 89%

47 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +0000

1"""Custom characteristic base class with auto-registration support.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from ...types import CharacteristicInfo 

8from .base import BaseCharacteristic, ValidationConfig 

9 

10 

11class CustomBaseCharacteristic(BaseCharacteristic[Any]): 

12 r"""Helper base class for custom characteristic implementations. 

13 

14 This class provides a wrapper around physical BLE characteristics that are not 

15 defined in the Bluetooth SIG specification. It supports both manual info passing 

16 and automatic class-level _info binding via __init_subclass__. 

17 

18 Auto-Registration: 

19 Custom characteristics automatically register themselves with the global 

20 BluetoothSIGTranslator singleton when first instantiated. No manual 

21 registration needed! 

22 

23 Examples: 

24 >>> from bluetooth_sig.types.data_types import CharacteristicInfo 

25 >>> from bluetooth_sig.types.uuid import BluetoothUUID 

26 >>> class MyCharacteristic(CustomBaseCharacteristic): 

27 ... _info = CharacteristicInfo(uuid=BluetoothUUID("AAAA"), name="My Char") 

28 >>> # Auto-registers with singleton on first instantiation 

29 >>> char = MyCharacteristic() # Auto-registered! 

30 >>> # Now accessible via the global translator 

31 >>> from bluetooth_sig import BluetoothSIGTranslator 

32 >>> translator = BluetoothSIGTranslator.get_instance() 

33 >>> result = translator.parse_characteristic("AAAA", b"\x42") 

34 """ 

35 

36 _is_custom = True 

37 _is_base_class = True # Exclude from registry validation tests 

38 _configured_info: CharacteristicInfo | None = None # Stores class-level _info 

39 _allows_sig_override = False # Default: no SIG override permission 

40 _registry_tracker: set[str] = set() # Track registered UUIDs to avoid duplicates 

41 

42 @classmethod 

43 def get_configured_info(cls) -> CharacteristicInfo | None: 

44 """Get the class-level configured CharacteristicInfo. 

45 

46 Returns: 

47 CharacteristicInfo if configured, None otherwise 

48 

49 """ 

50 return cls._configured_info 

51 

52 # pylint: disable=duplicate-code 

53 # NOTE: __init_subclass__ and __init__ patterns are intentionally similar to CustomBaseService. 

54 # This is by design - both custom characteristic and service classes need identical validation 

55 # and info management patterns. Consolidation not possible due to different base types and info types. 

56 def __init_subclass__(cls, allow_sig_override: bool = False, **kwargs: Any) -> None: # noqa: ANN401 # Receives subclass kwargs 

57 """Automatically set up _info if provided as class attribute. 

58 

59 Args: 

60 allow_sig_override: Set to True when intentionally overriding SIG UUIDs. 

61 **kwargs: Additional subclass keyword arguments passed by callers or 

62 metaclasses; these are accepted for compatibility and ignored 

63 unless explicitly handled. 

64 

65 Raises: 

66 ValueError: If class uses SIG UUID without override permission. 

67 

68 """ 

69 super().__init_subclass__(**kwargs) 

70 

71 # Store override permission for registry validation 

72 cls._allows_sig_override = allow_sig_override 

73 

74 # If class has _info attribute, validate and store it 

75 if hasattr(cls, "_info"): 

76 info = getattr(cls, "_info", None) 

77 if info is not None: 

78 # Check for SIG UUID override (unless explicitly allowed) 

79 if not allow_sig_override and info.uuid.is_sig_characteristic(): 

80 raise ValueError( 

81 f"{cls.__name__} uses SIG UUID {info.uuid} without override flag. " 

82 "Use custom UUID or add allow_sig_override=True parameter." 

83 ) 

84 

85 cls._configured_info = info 

86 

87 def __init__( 

88 self, 

89 info: CharacteristicInfo | None = None, 

90 auto_register: bool = True, 

91 validation: ValidationConfig | None = None, 

92 ) -> None: 

93 """Initialize a custom characteristic with automatic _info resolution and registration. 

94 

95 Args: 

96 info: Optional override for class-configured _info 

97 auto_register: If True (default), automatically register with global translator singleton 

98 validation: Validation constraints configuration (optional) 

99 

100 Raises: 

101 ValueError: If no valid info available from class or parameter 

102 

103 Examples: 

104 >>> # Simple usage - auto-registers with global translator 

105 >>> char = MyCharacteristic() # Auto-registered! 

106 >>> # Opt-out of auto-registration if needed 

107 >>> char = MyCharacteristic(auto_register=False) 

108 

109 """ 

110 # Use provided info, or fall back to class-configured _info 

111 final_info = info or self.__class__.get_configured_info() 

112 

113 if not final_info: 

114 raise ValueError(f"{self.__class__.__name__} requires either 'info' parameter or '_info' class attribute") 

115 

116 if not final_info.uuid or str(final_info.uuid) == "0000": 

117 raise ValueError("Valid UUID is required for custom characteristics") 

118 

119 # Auto-register if requested and not already registered 

120 if auto_register: 

121 # TODO 

122 # NOTE: Import here to avoid circular import (translator imports characteristics) 

123 from ...core.translator import BluetoothSIGTranslator # pylint: disable=import-outside-toplevel 

124 

125 # Get the singleton translator instance 

126 translator = BluetoothSIGTranslator.get_instance() 

127 

128 # Track registration to avoid duplicate registrations 

129 uuid_str = str(final_info.uuid) 

130 registry_key = f"{id(translator)}:{uuid_str}" 

131 

132 if registry_key not in CustomBaseCharacteristic._registry_tracker: 

133 # Register this characteristic class with the translator 

134 # Use override=True to allow re-registration (idempotent behaviour) 

135 translator.register_custom_characteristic_class( 

136 uuid_str, 

137 self.__class__, 

138 override=True, # Allow override for idempotent registration 

139 ) 

140 CustomBaseCharacteristic._registry_tracker.add(registry_key) 

141 

142 # Call parent constructor with our info to maintain consistency 

143 super().__init__(info=final_info, validation=validation) 

144 

145 def __post_init__(self) -> None: 

146 """Override BaseCharacteristic.__post_init__ to use custom info management. 

147 

148 CustomBaseCharacteristic manages _info manually from provided or configured info, 

149 bypassing SIG resolution that would fail for custom characteristics. 

150 Then delegates to parent for remaining initialization. 

151 """ 

152 # Use provided info if available (from manual override), otherwise use configured info 

153 final_info = None 

154 if hasattr(self, "_provided_info") and self._provided_info: 

155 final_info = self._provided_info 

156 else: 

157 configured_info = self.__class__.get_configured_info() 

158 if configured_info: 

159 final_info = configured_info 

160 else: 

161 # This shouldn't happen if class setup is correct 

162 raise ValueError(f"CustomBaseCharacteristic {self.__class__.__name__} has no valid info source") 

163 

164 # Set _provided_info so parent __post_init__ will use it instead of trying SIG resolution 

165 self._provided_info = final_info 

166 

167 # Call parent to complete initialization (sets up _special_resolver, applies overrides, etc.) 

168 # This is critical - without it, _special_resolver won't exist 

169 super().__post_init__()