Coverage for src/bluetooth_sig/gatt/characteristics/weight_measurement.py: 90%

138 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 01:26 +0000

1"""Weight Measurement characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from datetime import datetime 

6from enum import IntFlag 

7from typing import Any, ClassVar 

8 

9import msgspec 

10 

11from bluetooth_sig.types.units import HeightUnit, MeasurementSystem, WeightUnit 

12 

13from ..constants import UINT8_MAX, UINT16_MAX 

14from ..context import CharacteristicContext 

15from .base import BaseCharacteristic 

16from .utils import DataParser, IEEE11073Parser 

17from .weight_scale_feature import WeightScaleFeatureCharacteristic 

18 

19 

20class WeightMeasurementFlags(IntFlag): 

21 """Weight Measurement flags as per Bluetooth SIG specification.""" 

22 

23 IMPERIAL_UNITS = 0x01 

24 TIMESTAMP_PRESENT = 0x02 

25 USER_ID_PRESENT = 0x04 

26 BMI_AND_HEIGHT_PRESENT = 0x08 

27 

28 

29class WeightMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes 

30 """Parsed weight measurement data.""" 

31 

32 weight: float 

33 weight_unit: WeightUnit 

34 measurement_units: MeasurementSystem 

35 flags: WeightMeasurementFlags 

36 timestamp: datetime | None = None 

37 user_id: int | None = None 

38 bmi: float | None = None 

39 height: float | None = None 

40 height_unit: HeightUnit | None = None 

41 

42 def __post_init__(self) -> None: # pylint: disable=too-many-branches 

43 """Validate weight measurement data after initialization.""" 

44 # Validate weight_unit consistency 

45 if self.measurement_units == MeasurementSystem.METRIC and self.weight_unit != WeightUnit.KG: 

46 raise ValueError(f"Metric units require weight_unit={WeightUnit.KG!r}, got {self.weight_unit!r}") 

47 if self.measurement_units == MeasurementSystem.IMPERIAL and self.weight_unit != WeightUnit.LB: 

48 raise ValueError(f"Imperial units require weight_unit={WeightUnit.LB!r}, got {self.weight_unit!r}") 

49 

50 # Validate weight range 

51 if self.measurement_units == MeasurementSystem.METRIC: 

52 # Allow any non-negative weight (no SIG-specified range) 

53 if self.weight < 0: 

54 raise ValueError(f"Weight in kg must be non-negative, got {self.weight}") 

55 # Allow any non-negative weight (no SIG-specified range) 

56 elif self.weight < 0: 

57 raise ValueError(f"Weight in lb must be non-negative, got {self.weight}") 

58 

59 # Validate height_unit consistency if height present 

60 if self.height is not None: 

61 if self.measurement_units == MeasurementSystem.METRIC and self.height_unit != HeightUnit.METERS: 

62 raise ValueError(f"Metric units require height_unit={HeightUnit.METERS!r}, got {self.height_unit!r}") 

63 if self.measurement_units == MeasurementSystem.IMPERIAL and self.height_unit != HeightUnit.INCHES: 

64 raise ValueError(f"Imperial units require height_unit={HeightUnit.INCHES!r}, got {self.height_unit!r}") 

65 

66 # Validate height range 

67 if self.measurement_units == MeasurementSystem.METRIC: 

68 # Allow any non-negative height (no SIG-specified range) 

69 if self.height < 0: 

70 raise ValueError(f"Height in m must be non-negative, got {self.height}") 

71 # Allow any non-negative height (no SIG-specified range) 

72 elif self.height < 0: 

73 raise ValueError(f"Height in in must be non-negative, got {self.height}") 

74 

75 # Validate BMI if present 

76 if self.bmi is not None and self.bmi < 0: 

77 raise ValueError(f"BMI must be non-negative, got {self.bmi}") 

78 

79 # Validate user_id if present 

80 if self.user_id is not None and not 0 <= self.user_id <= UINT8_MAX: 

81 raise ValueError(f"User ID must be 0-{UINT8_MAX}, got {self.user_id}") 

82 

83 

84class WeightMeasurementCharacteristic(BaseCharacteristic[WeightMeasurementData]): 

85 """Weight Measurement characteristic (0x2A9D). 

86 

87 Used to transmit weight measurement data with optional fields. 

88 Supports metric/imperial units, timestamps, user ID, BMI, and 

89 height. 

90 """ 

91 

92 _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [WeightScaleFeatureCharacteristic] 

93 

94 _characteristic_name: str = "Weight Measurement" 

95 _manual_unit: str = "kg" # Primary unit for weight measurement 

96 

97 min_length: int = 3 # Flags(1) + Weight(2) minimum 

98 max_length: int = 21 # + Timestamp(7) + UserID(1) + BMI(2) + Height(2) maximum 

99 allow_variable_length: bool = True # Variable optional fields 

100 

101 def _decode_value( 

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

103 ) -> WeightMeasurementData: # pylint: disable=too-many-locals # Weight measurement with optional fields 

104 """Parse weight measurement data according to Bluetooth specification. 

105 

106 Format: Flags(1) + Weight(2) + [Timestamp(7)] + [User ID(1)] + 

107 [BMI(2)] + [Height(2)] 

108 

109 Args: 

110 data: Raw bytearray from BLE characteristic. 

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

112 validate: Whether to validate ranges (default True) 

113 

114 Returns: 

115 WeightMeasurementData containing parsed weight measurement data. 

116 

117 Raises: 

118 ValueError: If data format is invalid. 

119 

120 """ 

121 flags = WeightMeasurementFlags(data[0]) 

122 offset = 1 

123 

124 # Parse weight value (uint16 with 0.005 kg resolution) 

125 weight_raw = DataParser.parse_int16(data, offset, signed=False) 

126 offset += 2 

127 

128 # Convert to appropriate unit based on flags 

129 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (pounds) 

130 weight = weight_raw * 0.01 # 0.01 lb resolution for imperial 

131 weight_unit = WeightUnit.LB 

132 measurement_units = MeasurementSystem.IMPERIAL 

133 else: # SI units (kilograms) 

134 weight = weight_raw * 0.005 # 0.005 kg resolution for metric 

135 weight_unit = WeightUnit.KG 

136 measurement_units = MeasurementSystem.METRIC 

137 

138 # Parse optional timestamp (7 bytes) if present 

139 timestamp = None 

140 if WeightMeasurementFlags.TIMESTAMP_PRESENT in flags and len(data) >= offset + 7: 

141 timestamp = IEEE11073Parser.parse_timestamp(data, offset) 

142 offset += 7 

143 

144 # Parse optional user ID (1 byte) if present 

145 user_id = None 

146 if WeightMeasurementFlags.USER_ID_PRESENT in flags and len(data) >= offset + 1: 

147 user_id = int(data[offset]) 

148 offset += 1 

149 

150 # Parse optional BMI (uint16 with 0.1 resolution) and Height if present (bit 3 — paired) 

151 bmi = None 

152 height = None 

153 height_unit = None 

154 if WeightMeasurementFlags.BMI_AND_HEIGHT_PRESENT in flags and len(data) >= offset + 2: 

155 bmi_raw = DataParser.parse_int16(data, offset, signed=False) 

156 bmi = bmi_raw * 0.1 

157 offset += 2 

158 

159 # Height always paired with BMI per spec 

160 if len(data) >= offset + 2: 

161 height_raw = DataParser.parse_int16(data, offset, signed=False) 

162 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: 

163 height = height_raw * 0.1 

164 height_unit = HeightUnit.INCHES 

165 else: 

166 height = height_raw * 0.001 

167 height_unit = HeightUnit.METERS 

168 offset += 2 

169 

170 if ctx is not None: 

171 feature_data = self.get_context_characteristic(ctx, WeightScaleFeatureCharacteristic) 

172 if feature_data is not None: 

173 if (flags & WeightMeasurementFlags.TIMESTAMP_PRESENT) and not feature_data.timestamp_supported: 

174 raise ValueError("Timestamp reported but not supported by Weight Scale Feature") 

175 if (flags & WeightMeasurementFlags.USER_ID_PRESENT) and not feature_data.multiple_users_supported: 

176 raise ValueError("User ID reported but not supported by Weight Scale Feature") 

177 if (flags & WeightMeasurementFlags.BMI_AND_HEIGHT_PRESENT) and not feature_data.bmi_supported: 

178 raise ValueError("BMI and height reported but not supported by Weight Scale Feature") 

179 

180 # Create result with all parsed values 

181 return WeightMeasurementData( 

182 weight=weight, 

183 weight_unit=weight_unit, 

184 measurement_units=measurement_units, 

185 flags=flags, 

186 timestamp=timestamp, 

187 user_id=user_id, 

188 bmi=bmi, 

189 height=height, 

190 height_unit=height_unit, 

191 ) 

192 

193 def _encode_value(self, data: WeightMeasurementData) -> bytearray: # pylint: disable=too-many-branches # Complex measurement data with many optional fields 

194 """Encode weight measurement value back to bytes. 

195 

196 Args: 

197 data: WeightMeasurementData containing weight measurement data 

198 

199 Returns: 

200 Encoded bytes representing the weight measurement 

201 

202 """ 

203 # Build flags based on available data 

204 flags = WeightMeasurementFlags(0) 

205 if data.measurement_units == MeasurementSystem.IMPERIAL: 

206 flags |= WeightMeasurementFlags.IMPERIAL_UNITS 

207 if data.timestamp is not None: 

208 flags |= WeightMeasurementFlags.TIMESTAMP_PRESENT 

209 if data.user_id is not None: 

210 flags |= WeightMeasurementFlags.USER_ID_PRESENT 

211 if data.bmi is not None or data.height is not None: 

212 flags |= WeightMeasurementFlags.BMI_AND_HEIGHT_PRESENT 

213 

214 # Convert weight to raw value based on units 

215 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (pounds) 

216 weight_raw = round(data.weight / 0.01) # 0.01 lb resolution 

217 else: # SI units (kilograms) 

218 weight_raw = round(data.weight / 0.005) # 0.005 kg resolution 

219 

220 if not 0 <= weight_raw <= UINT16_MAX: 

221 raise ValueError(f"Weight value {weight_raw} exceeds uint16 range") 

222 

223 # Start with flags and weight 

224 result = bytearray([int(flags)]) 

225 result.extend(DataParser.encode_int16(weight_raw, signed=False)) 

226 

227 # Add optional fields based on flags 

228 if data.timestamp is not None: 

229 result.extend(IEEE11073Parser.encode_timestamp(data.timestamp)) 

230 

231 if data.user_id is not None: 

232 if not 0 <= data.user_id <= UINT8_MAX: 

233 raise ValueError(f"User ID {data.user_id} exceeds uint8 range") 

234 result.append(data.user_id) 

235 

236 if data.bmi is not None: 

237 bmi_raw = round(data.bmi / 0.1) # 0.1 resolution 

238 if not 0 <= bmi_raw <= UINT16_MAX: 

239 raise ValueError(f"BMI value {bmi_raw} exceeds uint16 range") 

240 result.extend(DataParser.encode_int16(bmi_raw, signed=False)) 

241 

242 # Height always paired with BMI per spec 

243 if data.height is not None: 

244 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: 

245 height_raw = round(data.height / 0.1) 

246 else: 

247 height_raw = round(data.height / 0.001) 

248 

249 if not 0 <= height_raw <= UINT16_MAX: 

250 raise ValueError(f"Height value {height_raw} exceeds uint16 range") 

251 result.extend(DataParser.encode_int16(height_raw, signed=False)) 

252 

253 return result