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

63 statements  

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

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

2 

3from __future__ import annotations 

4 

5import logging 

6from abc import ABC, abstractmethod 

7from typing import Any 

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 RangeDescriptorMixin: 

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

143 

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

145 """Get the minimum valid value. 

146 

147 Args: 

148 data: Raw descriptor data 

149 

150 Returns: 

151 Minimum valid value for the characteristic 

152 """ 

153 parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] 

154 return parsed.min_value # type: ignore[no-any-return] 

155 

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

157 """Get the maximum valid value. 

158 

159 Args: 

160 data: Raw descriptor data 

161 

162 Returns: 

163 Maximum valid value for the characteristic 

164 """ 

165 parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] 

166 return parsed.max_value # type: ignore[no-any-return] 

167 

168 def is_value_in_range(self, data: bytes, value: int | float) -> bool: 

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

170 

171 Args: 

172 data: Raw descriptor data 

173 value: Value to check 

174 

175 Returns: 

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

177 """ 

178 parsed = self._parse_descriptor_value(data) # type: ignore[attr-defined] 

179 min_val = parsed.min_value 

180 max_val = parsed.max_value 

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