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

79 statements  

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

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

2 

3from __future__ import annotations 

4 

5from enum import IntEnum, IntFlag 

6 

7import msgspec 

8 

9from ..context import CharacteristicContext 

10from .base import BaseCharacteristic 

11from .utils import BitFieldUtils, DataParser 

12 

13 

14class WeightScaleBits: 

15 """Weight scale bit field constants.""" 

16 

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

18 

19 # Weight Scale Feature bit field constants 

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

21 WEIGHT_RESOLUTION_BIT_WIDTH = 4 # Weight measurement resolution uses 4 bits 

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

23 HEIGHT_RESOLUTION_BIT_WIDTH = 3 # Height measurement resolution uses 3 bits 

24 

25 

26class WeightScaleFeatures(IntFlag): 

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

28 

29 TIMESTAMP_SUPPORTED = 0x01 

30 MULTIPLE_USERS_SUPPORTED = 0x02 

31 BMI_SUPPORTED = 0x04 

32 

33 

34class WeightMeasurementResolution(IntEnum): 

35 """Weight measurement resolution enumeration.""" 

36 

37 NOT_SPECIFIED = 0 

38 HALF_KG_OR_1_LB = 1 

39 POINT_2_KG_OR_HALF_LB = 2 

40 POINT_1_KG_OR_POINT_2_LB = 3 

41 POINT_05_KG_OR_POINT_1_LB = 4 

42 POINT_02_KG_OR_POINT_05_LB = 5 

43 POINT_01_KG_OR_POINT_02_LB = 6 

44 POINT_005_KG_OR_POINT_01_LB = 7 

45 

46 def __str__(self) -> str: 

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

48 descriptions = { 

49 0: "not_specified", 

50 1: "0.5_kg_or_1_lb", 

51 2: "0.2_kg_or_0.5_lb", 

52 3: "0.1_kg_or_0.2_lb", 

53 4: "0.05_kg_or_0.1_lb", 

54 5: "0.02_kg_or_0.05_lb", 

55 6: "0.01_kg_or_0.02_lb", 

56 7: "0.005_kg_or_0.01_lb", 

57 } 

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

59 

60 

61class HeightMeasurementResolution(IntEnum): 

62 """Height measurement resolution enumeration.""" 

63 

64 NOT_SPECIFIED = 0 

65 POINT_01_M_OR_1_INCH = 1 

66 POINT_005_M_OR_HALF_INCH = 2 

67 POINT_001_M_OR_POINT_1_INCH = 3 

68 

69 def __str__(self) -> str: 

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

71 descriptions = { 

72 0: "not_specified", 

73 1: "0.01_m_or_1_inch", 

74 2: "0.005_m_or_0.5_inch", 

75 3: "0.001_m_or_0.1_inch", 

76 } 

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

78 

79 

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

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

82 

83 raw_value: int 

84 timestamp_supported: bool 

85 multiple_users_supported: bool 

86 bmi_supported: bool 

87 weight_measurement_resolution: WeightMeasurementResolution 

88 height_measurement_resolution: HeightMeasurementResolution 

89 

90 def __post_init__(self) -> None: 

91 """Validate weight scale feature data.""" 

92 if not 0 <= self.raw_value <= 0xFFFFFFFF: 

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

94 

95 

96class WeightScaleFeatureCharacteristic(BaseCharacteristic[WeightScaleFeatureData]): 

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

98 

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

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

101 capabilities. 

102 """ 

103 

104 _characteristic_name: str = "Weight Scale Feature" 

105 

106 expected_length: int = 4 

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

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

109 allow_variable_length: bool = False # Fixed length 

110 

111 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> WeightScaleFeatureData: 

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

113 

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

115 

116 Args: 

117 data: Raw bytearray from BLE characteristic. 

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

119 

120 Returns: 

121 WeightScaleFeatureData containing parsed feature flags. 

122 

123 Raises: 

124 ValueError: If data format is invalid. 

125 

126 """ 

127 if len(data) < 4: 

128 raise ValueError("Weight Scale Feature data must be at least 4 bytes") 

129 

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

131 

132 # Parse feature flags according to specification 

133 return WeightScaleFeatureData( 

134 raw_value=features_raw, 

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

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

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

138 weight_measurement_resolution=self._get_weight_resolution(features_raw), 

139 height_measurement_resolution=self._get_height_resolution(features_raw), 

140 ) 

141 

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

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

144 

145 Args: 

146 data: WeightScaleFeatureData with feature flags 

147 

148 Returns: 

149 Encoded bytes representing the weight scale features (uint32) 

150 

151 """ 

152 # Reconstruct the features bitmap from individual flags 

153 features_bitmap = 0 

154 if data.timestamp_supported: 

155 features_bitmap |= WeightScaleFeatures.TIMESTAMP_SUPPORTED 

156 if data.multiple_users_supported: 

157 features_bitmap |= WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED 

158 if data.bmi_supported: 

159 features_bitmap |= WeightScaleFeatures.BMI_SUPPORTED 

160 

161 # Add resolution bits using bit field utilities 

162 features_bitmap = BitFieldUtils.set_bit_field( 

163 features_bitmap, 

164 data.weight_measurement_resolution.value, 

165 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT, 

166 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH, 

167 ) 

168 features_bitmap = BitFieldUtils.set_bit_field( 

169 features_bitmap, 

170 data.height_measurement_resolution.value, 

171 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT, 

172 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH, 

173 ) 

174 

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

176 

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

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

179 

180 Args: 

181 features: Raw feature bitmask 

182 

183 Returns: 

184 WeightMeasurementResolution enum value 

185 

186 """ 

187 resolution_bits = BitFieldUtils.extract_bit_field( 

188 features, 

189 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT, 

190 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH, 

191 ) 

192 try: 

193 return WeightMeasurementResolution(resolution_bits) 

194 except ValueError: 

195 return WeightMeasurementResolution.NOT_SPECIFIED 

196 

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

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

199 

200 Args: 

201 features: Raw feature bitmask 

202 

203 Returns: 

204 HeightMeasurementResolution enum value 

205 

206 """ 

207 resolution_bits = BitFieldUtils.extract_bit_field( 

208 features, 

209 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT, 

210 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH, 

211 ) 

212 try: 

213 return HeightMeasurementResolution(resolution_bits) 

214 except ValueError: 

215 return HeightMeasurementResolution.NOT_SPECIFIED