Coverage for src / bluetooth_sig / gatt / descriptors / base.py: 88%

66 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Base class for GATT descriptors.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from abc import ABC, abstractmethod 

7from typing import Any, Protocol 

8 

9from ...types import DescriptorData, DescriptorInfo 

10from ...types.uuid import BluetoothUUID 

11from ..exceptions import UUIDResolutionError 

12from ..resolver import NameVariantGenerator 

13from ..uuid_registry import uuid_registry 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class BaseDescriptor(ABC): 

19 """Base class for all GATT descriptors. 

20 

21 Automatically resolves UUID and name from Bluetooth SIG registry. 

22 Provides parsing capabilities for descriptor values. 

23 

24 Attributes: 

25 _descriptor_name: Optional explicit descriptor name for registry lookup. 

26 _writable: Whether this descriptor type supports write operations. 

27 Override to True in writable descriptor subclasses (CCCD, SCCD). 

28 

29 Note: 

30 Most descriptors are read-only per Bluetooth SIG specification. 

31 Some like CCCD (0x2902) and SCCD (0x2903) support writes. 

32 """ 

33 

34 # Class attributes for explicit overrides 

35 _descriptor_name: str | None = None 

36 _writable: bool = False # Override to True in writable descriptor subclasses 

37 _info: DescriptorInfo # Populated in __post_init__ 

38 

39 def __init__(self) -> None: 

40 """Initialize descriptor with resolved information.""" 

41 self.__post_init__() 

42 

43 def __post_init__(self) -> None: 

44 """Initialize descriptor with resolved information.""" 

45 self._info = self._resolve_info() 

46 

47 def _resolve_info(self) -> DescriptorInfo: 

48 """Resolve descriptor information from registry using sophisticated name resolution.""" 

49 # Generate name variants using the same logic as characteristics 

50 descriptor_name = getattr(self.__class__, "_descriptor_name", None) 

51 variants = NameVariantGenerator.generate_descriptor_variants(self.__class__.__name__, descriptor_name) 

52 

53 # Try each variant 

54 for variant in variants: 

55 info = uuid_registry.get_descriptor_info(variant) 

56 if info: 

57 return info 

58 

59 # No resolution found 

60 raise UUIDResolutionError(self.__class__.__name__, [self.__class__.__name__]) 

61 

62 def _has_structured_data(self) -> bool: 

63 """Check if this descriptor contains structured data.""" 

64 return False 

65 

66 def _get_data_format(self) -> str: 

67 """Get the data format for this descriptor.""" 

68 return "bytes" 

69 

70 @property 

71 def uuid(self) -> BluetoothUUID: 

72 """Get the descriptor UUID.""" 

73 return self._info.uuid 

74 

75 @property 

76 def name(self) -> str: 

77 """Get the descriptor name.""" 

78 return self._info.name 

79 

80 @property 

81 def info(self) -> DescriptorInfo: 

82 """Get the descriptor information.""" 

83 return self._info 

84 

85 def parse_value(self, data: bytes) -> DescriptorData: 

86 """Parse raw descriptor data into structured format. 

87 

88 Args: 

89 data: Raw bytes from the descriptor read 

90 

91 Returns: 

92 DescriptorData object with parsed value and metadata 

93 """ 

94 try: 

95 parsed_value = self._parse_descriptor_value(data) 

96 return DescriptorData( 

97 info=self._info, 

98 value=parsed_value, 

99 raw_data=data, 

100 parse_success=True, 

101 error_message="", 

102 ) 

103 except Exception as e: # pylint: disable=broad-exception-caught 

104 logger.warning("Failed to parse descriptor %s: %s", self.name, e) 

105 return DescriptorData( 

106 info=self._info, 

107 value=None, 

108 raw_data=data, 

109 parse_success=False, 

110 error_message=str(e), 

111 ) 

112 

113 def is_writable(self) -> bool: 

114 """Check if descriptor type supports write operations. 

115 

116 Returns: 

117 True if descriptor type supports writes, False otherwise. 

118 

119 Note: 

120 Only checks descriptor type, not runtime permissions or security. 

121 Example writable descriptors (CCCD, SCCD) override `_writable = True`. 

122 """ 

123 return self._writable 

124 

125 @abstractmethod 

126 def _parse_descriptor_value(self, data: bytes) -> Any: # noqa: ANN401 # Descriptors can return various types 

127 """Parse the specific descriptor value format. 

128 

129 Args: 

130 data: Raw bytes from the descriptor 

131 

132 Returns: 

133 Parsed value in appropriate format 

134 

135 Raises: 

136 NotImplementedError: If subclass doesn't implement parsing 

137 """ 

138 raise NotImplementedError(f"{self.__class__.__name__} must implement _parse_descriptor_value()") 

139 

140 

141class _RangeValue(Protocol): 

142 """Protocol for parsed descriptor values with min/max fields.""" 

143 

144 @property 

145 def min_value(self) -> int | float: ... 

146 

147 @property 

148 def max_value(self) -> int | float: ... 

149 

150 

151class RangeDescriptorMixin(ABC): 

152 """Mixin for descriptors that provide min/max value validation. 

153 

154 Concrete subclasses must also inherit from BaseDescriptor (which provides 

155 ``_parse_descriptor_value``). The abstract stub below declares the 

156 dependency so that mypy recognises it without ``type: ignore[attr-defined]``. 

157 """ 

158 

159 @abstractmethod 

160 def _parse_descriptor_value(self, data: bytes) -> _RangeValue: 

161 """Parse the descriptor value — implemented by BaseDescriptor.""" 

162 

163 def get_min_value(self, data: bytes) -> int | float: 

164 """Get the minimum valid value. 

165 

166 Args: 

167 data: Raw descriptor data 

168 

169 Returns: 

170 Minimum valid value for the characteristic 

171 """ 

172 parsed = self._parse_descriptor_value(data) 

173 return parsed.min_value 

174 

175 def get_max_value(self, data: bytes) -> int | float: 

176 """Get the maximum valid value. 

177 

178 Args: 

179 data: Raw descriptor data 

180 

181 Returns: 

182 Maximum valid value for the characteristic 

183 """ 

184 parsed = self._parse_descriptor_value(data) 

185 return parsed.max_value 

186 

187 def is_value_in_range(self, data: bytes, value: float) -> bool: 

188 """Check if a value is within the valid range. 

189 

190 Args: 

191 data: Raw descriptor data 

192 value: Value to check 

193 

194 Returns: 

195 True if value is within [min_value, max_value] range 

196 """ 

197 parsed = self._parse_descriptor_value(data) 

198 min_val = parsed.min_value 

199 max_val = parsed.max_value 

200 return bool(min_val <= value <= max_val)