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

78 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +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): 

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 min_length: int = 4 # Features(4) fixed length 

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

108 allow_variable_length: bool = False # Fixed length 

109 

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

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

112 

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

114 

115 Args: 

116 data: Raw bytearray from BLE characteristic. 

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

118 

119 Returns: 

120 WeightScaleFeatureData containing parsed feature flags. 

121 

122 Raises: 

123 ValueError: If data format is invalid. 

124 

125 """ 

126 if len(data) < 4: 

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

128 

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

130 

131 # Parse feature flags according to specification 

132 return WeightScaleFeatureData( 

133 raw_value=features_raw, 

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

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

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

137 weight_measurement_resolution=self._get_weight_resolution(features_raw), 

138 height_measurement_resolution=self._get_height_resolution(features_raw), 

139 ) 

140 

141 def encode_value(self, data: WeightScaleFeatureData) -> bytearray: 

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

143 

144 Args: 

145 data: WeightScaleFeatureData with feature flags 

146 

147 Returns: 

148 Encoded bytes representing the weight scale features (uint32) 

149 

150 """ 

151 # Reconstruct the features bitmap from individual flags 

152 features_bitmap = 0 

153 if data.timestamp_supported: 

154 features_bitmap |= WeightScaleFeatures.TIMESTAMP_SUPPORTED 

155 if data.multiple_users_supported: 

156 features_bitmap |= WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED 

157 if data.bmi_supported: 

158 features_bitmap |= WeightScaleFeatures.BMI_SUPPORTED 

159 

160 # Add resolution bits using bit field utilities 

161 features_bitmap = BitFieldUtils.set_bit_field( 

162 features_bitmap, 

163 data.weight_measurement_resolution.value, 

164 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT, 

165 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH, 

166 ) 

167 features_bitmap = BitFieldUtils.set_bit_field( 

168 features_bitmap, 

169 data.height_measurement_resolution.value, 

170 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT, 

171 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH, 

172 ) 

173 

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

175 

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

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

178 

179 Args: 

180 features: Raw feature bitmask 

181 

182 Returns: 

183 WeightMeasurementResolution enum value 

184 

185 """ 

186 resolution_bits = BitFieldUtils.extract_bit_field( 

187 features, 

188 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT, 

189 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH, 

190 ) 

191 try: 

192 return WeightMeasurementResolution(resolution_bits) 

193 except ValueError: 

194 return WeightMeasurementResolution.NOT_SPECIFIED 

195 

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

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

198 

199 Args: 

200 features: Raw feature bitmask 

201 

202 Returns: 

203 HeightMeasurementResolution enum value 

204 

205 """ 

206 resolution_bits = BitFieldUtils.extract_bit_field( 

207 features, 

208 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT, 

209 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH, 

210 ) 

211 try: 

212 return HeightMeasurementResolution(resolution_bits) 

213 except ValueError: 

214 return HeightMeasurementResolution.NOT_SPECIFIED