Coverage for src / bluetooth_sig / gatt / characteristics / weight_scale_feature.py: 88%

78 statements  

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

1"""Weight Scale Feature characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum, IntFlag 

6 

7import msgspec 

8 

9from ..constants import UINT32_MAX 

10from ..context import CharacteristicContext 

11from .base import BaseCharacteristic 

12from .utils import BitFieldUtils, DataParser 

13 

14 

15class WeightScaleBits: 

16 """Weight scale bit field constants.""" 

17 

18 # pylint: disable=missing-class-docstring,too-few-public-methods 

19 

20 # Weight Scale Feature bit field constants 

21 WEIGHT_RESOLUTION_START_BIT = 3 # Weight measurement resolution starts at bit 3 

22 WEIGHT_RESOLUTION_BIT_WIDTH = 4 # Weight measurement resolution uses 4 bits 

23 HEIGHT_RESOLUTION_START_BIT = 7 # Height measurement resolution starts at bit 7 

24 HEIGHT_RESOLUTION_BIT_WIDTH = 3 # Height measurement resolution uses 3 bits 

25 

26 

27class WeightScaleFeatures(IntFlag): 

28 """Weight Scale Feature flags as per Bluetooth SIG specification.""" 

29 

30 TIMESTAMP_SUPPORTED = 0x01 

31 MULTIPLE_USERS_SUPPORTED = 0x02 

32 BMI_SUPPORTED = 0x04 

33 

34 

35class WeightMeasurementResolution(IntEnum): 

36 """Weight measurement resolution enumeration.""" 

37 

38 NOT_SPECIFIED = 0 

39 HALF_KG_OR_1_LB = 1 

40 POINT_2_KG_OR_HALF_LB = 2 

41 POINT_1_KG_OR_POINT_2_LB = 3 

42 POINT_05_KG_OR_POINT_1_LB = 4 

43 POINT_02_KG_OR_POINT_05_LB = 5 

44 POINT_01_KG_OR_POINT_02_LB = 6 

45 POINT_005_KG_OR_POINT_01_LB = 7 

46 

47 def __str__(self) -> str: 

48 """Return a human-readable description of the weight measurement resolution.""" 

49 descriptions = { 

50 0: "not_specified", 

51 1: "0.5_kg_or_1_lb", 

52 2: "0.2_kg_or_0.5_lb", 

53 3: "0.1_kg_or_0.2_lb", 

54 4: "0.05_kg_or_0.1_lb", 

55 5: "0.02_kg_or_0.05_lb", 

56 6: "0.01_kg_or_0.02_lb", 

57 7: "0.005_kg_or_0.01_lb", 

58 } 

59 return descriptions.get(self.value, "Reserved for Future Use") 

60 

61 

62class HeightMeasurementResolution(IntEnum): 

63 """Height measurement resolution enumeration.""" 

64 

65 NOT_SPECIFIED = 0 

66 POINT_01_M_OR_1_INCH = 1 

67 POINT_005_M_OR_HALF_INCH = 2 

68 POINT_001_M_OR_POINT_1_INCH = 3 

69 

70 def __str__(self) -> str: 

71 """Return a human-readable description of the height measurement resolution.""" 

72 descriptions = { 

73 0: "not_specified", 

74 1: "0.01_m_or_1_inch", 

75 2: "0.005_m_or_0.5_inch", 

76 3: "0.001_m_or_0.1_inch", 

77 } 

78 return descriptions.get(self.value, "Reserved for Future Use") 

79 

80 

81class WeightScaleFeatureData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

82 """Parsed data from Weight Scale Feature characteristic.""" 

83 

84 raw_value: int 

85 timestamp_supported: bool 

86 multiple_users_supported: bool 

87 bmi_supported: bool 

88 weight_measurement_resolution: WeightMeasurementResolution 

89 height_measurement_resolution: HeightMeasurementResolution 

90 

91 def __post_init__(self) -> None: 

92 """Validate weight scale feature data.""" 

93 if not 0 <= self.raw_value <= UINT32_MAX: 

94 raise ValueError("Raw value must be a 32-bit unsigned integer") 

95 

96 

97class WeightScaleFeatureCharacteristic(BaseCharacteristic[WeightScaleFeatureData]): 

98 """Weight Scale Feature characteristic (0x2A9E). 

99 

100 Used to indicate which optional features are supported by the weight 

101 scale. This is a read-only characteristic that describes device 

102 capabilities. 

103 """ 

104 

105 _characteristic_name: str = "Weight Scale Feature" 

106 

107 expected_length: int = 4 

108 min_length: int = 4 # Features(4) fixed length 

109 max_length: int = 4 # Features(4) fixed length 

110 allow_variable_length: bool = False # Fixed length 

111 

112 def _decode_value( 

113 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True 

114 ) -> WeightScaleFeatureData: 

115 """Parse weight scale feature data according to Bluetooth specification. 

116 

117 Format: Features(4 bytes) - bitmask indicating supported features. 

118 

119 Args: 

120 data: Raw bytearray from BLE characteristic. 

121 ctx: Optional CharacteristicContext providing surrounding context (may be None). 

122 validate: Whether to validate ranges (default True) 

123 

124 Returns: 

125 WeightScaleFeatureData containing parsed feature flags. 

126 

127 Raises: 

128 ValueError: If data format is invalid. 

129 

130 """ 

131 features_raw = DataParser.parse_int32(data, 0, signed=False) 

132 

133 # Parse feature flags according to specification 

134 return WeightScaleFeatureData( 

135 raw_value=features_raw, 

136 timestamp_supported=bool(features_raw & WeightScaleFeatures.TIMESTAMP_SUPPORTED), 

137 multiple_users_supported=bool(features_raw & WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED), 

138 bmi_supported=bool(features_raw & WeightScaleFeatures.BMI_SUPPORTED), 

139 weight_measurement_resolution=self._get_weight_resolution(features_raw), 

140 height_measurement_resolution=self._get_height_resolution(features_raw), 

141 ) 

142 

143 def _encode_value(self, data: WeightScaleFeatureData) -> bytearray: 

144 """Encode weight scale feature value back to bytes. 

145 

146 Args: 

147 data: WeightScaleFeatureData with feature flags 

148 

149 Returns: 

150 Encoded bytes representing the weight scale features (uint32) 

151 

152 """ 

153 # Reconstruct the features bitmap from individual flags 

154 features_bitmap = 0 

155 if data.timestamp_supported: 

156 features_bitmap |= WeightScaleFeatures.TIMESTAMP_SUPPORTED 

157 if data.multiple_users_supported: 

158 features_bitmap |= WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED 

159 if data.bmi_supported: 

160 features_bitmap |= WeightScaleFeatures.BMI_SUPPORTED 

161 

162 # Add resolution bits using bit field utilities 

163 features_bitmap = BitFieldUtils.set_bit_field( 

164 features_bitmap, 

165 data.weight_measurement_resolution.value, 

166 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT, 

167 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH, 

168 ) 

169 features_bitmap = BitFieldUtils.set_bit_field( 

170 features_bitmap, 

171 data.height_measurement_resolution.value, 

172 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT, 

173 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH, 

174 ) 

175 

176 return bytearray(DataParser.encode_int32(features_bitmap, signed=False)) 

177 

178 def _get_weight_resolution(self, features: int) -> WeightMeasurementResolution: 

179 """Extract weight measurement resolution from features bitmask. 

180 

181 Args: 

182 features: Raw feature bitmask 

183 

184 Returns: 

185 WeightMeasurementResolution enum value 

186 

187 """ 

188 resolution_bits = BitFieldUtils.extract_bit_field( 

189 features, 

190 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT, 

191 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH, 

192 ) 

193 try: 

194 return WeightMeasurementResolution(resolution_bits) 

195 except ValueError: 

196 return WeightMeasurementResolution.NOT_SPECIFIED 

197 

198 def _get_height_resolution(self, features: int) -> HeightMeasurementResolution: 

199 """Extract height measurement resolution from features bitmask. 

200 

201 Args: 

202 features: Raw feature bitmask 

203 

204 Returns: 

205 HeightMeasurementResolution enum value 

206 

207 """ 

208 resolution_bits = BitFieldUtils.extract_bit_field( 

209 features, 

210 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT, 

211 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH, 

212 ) 

213 try: 

214 return HeightMeasurementResolution(resolution_bits) 

215 except ValueError: 

216 return HeightMeasurementResolution.NOT_SPECIFIED