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

126 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:41 +0000

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

2 

3from __future__ import annotations 

4 

5from datetime import datetime 

6from enum import IntFlag 

7 

8import msgspec 

9 

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

11 

12from ..constants import UINT8_MAX, UINT16_MAX 

13from ..context import CharacteristicContext 

14from .base import BaseCharacteristic 

15from .utils import DataParser, IEEE11073Parser 

16 

17 

18class WeightMeasurementFlags(IntFlag): 

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

20 

21 IMPERIAL_UNITS = 0x01 

22 TIMESTAMP_PRESENT = 0x02 

23 USER_ID_PRESENT = 0x04 

24 BMI_AND_HEIGHT_PRESENT = 0x08 

25 

26 

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

28 """Parsed weight measurement data.""" 

29 

30 weight: float 

31 weight_unit: WeightUnit 

32 measurement_units: MeasurementSystem 

33 flags: WeightMeasurementFlags 

34 timestamp: datetime | None = None 

35 user_id: int | None = None 

36 bmi: float | None = None 

37 height: float | None = None 

38 height_unit: HeightUnit | None = None 

39 

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

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

42 # Validate weight_unit consistency 

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

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

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

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

47 

48 # Validate weight range 

49 if self.measurement_units == MeasurementSystem.METRIC: 

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

51 if self.weight < 0: 

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

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

54 elif self.weight < 0: 

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

56 

57 # Validate height_unit consistency if height present 

58 if self.height is not None: 

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

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

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

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

63 

64 # Validate height range 

65 if self.measurement_units == MeasurementSystem.METRIC: 

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

67 if self.height < 0: 

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

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

70 elif self.height < 0: 

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

72 

73 # Validate BMI if present 

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

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

76 

77 # Validate user_id if present 

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

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

80 

81 

82class WeightMeasurementCharacteristic(BaseCharacteristic[WeightMeasurementData]): 

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

84 

85 Used to transmit weight measurement data with optional fields. 

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

87 height. 

88 """ 

89 

90 _characteristic_name: str = "Weight Measurement" 

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

92 

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

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

95 allow_variable_length: bool = True # Variable optional fields 

96 

97 def _decode_value( 

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

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

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

101 

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

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

104 

105 Args: 

106 data: Raw bytearray from BLE characteristic. 

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

108 validate: Whether to validate ranges (default True) 

109 

110 Returns: 

111 WeightMeasurementData containing parsed weight measurement data. 

112 

113 Raises: 

114 ValueError: If data format is invalid. 

115 

116 """ 

117 flags = WeightMeasurementFlags(data[0]) 

118 offset = 1 

119 

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

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

122 offset += 2 

123 

124 # Convert to appropriate unit based on flags 

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

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

127 weight_unit = WeightUnit.LB 

128 measurement_units = MeasurementSystem.IMPERIAL 

129 else: # SI units (kilograms) 

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

131 weight_unit = WeightUnit.KG 

132 measurement_units = MeasurementSystem.METRIC 

133 

134 # Parse optional timestamp (7 bytes) if present 

135 timestamp = None 

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

137 timestamp = IEEE11073Parser.parse_timestamp(data, offset) 

138 offset += 7 

139 

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

141 user_id = None 

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

143 user_id = int(data[offset]) 

144 offset += 1 

145 

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

147 bmi = None 

148 height = None 

149 height_unit = None 

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

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

152 bmi = bmi_raw * 0.1 

153 offset += 2 

154 

155 # Height always paired with BMI per spec 

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

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

158 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: 

159 height = height_raw * 0.1 

160 height_unit = HeightUnit.INCHES 

161 else: 

162 height = height_raw * 0.001 

163 height_unit = HeightUnit.METERS 

164 offset += 2 

165 

166 # Create result with all parsed values 

167 return WeightMeasurementData( 

168 weight=weight, 

169 weight_unit=weight_unit, 

170 measurement_units=measurement_units, 

171 flags=flags, 

172 timestamp=timestamp, 

173 user_id=user_id, 

174 bmi=bmi, 

175 height=height, 

176 height_unit=height_unit, 

177 ) 

178 

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

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

181 

182 Args: 

183 data: WeightMeasurementData containing weight measurement data 

184 

185 Returns: 

186 Encoded bytes representing the weight measurement 

187 

188 """ 

189 # Build flags based on available data 

190 flags = WeightMeasurementFlags(0) 

191 if data.measurement_units == MeasurementSystem.IMPERIAL: 

192 flags |= WeightMeasurementFlags.IMPERIAL_UNITS 

193 if data.timestamp is not None: 

194 flags |= WeightMeasurementFlags.TIMESTAMP_PRESENT 

195 if data.user_id is not None: 

196 flags |= WeightMeasurementFlags.USER_ID_PRESENT 

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

198 flags |= WeightMeasurementFlags.BMI_AND_HEIGHT_PRESENT 

199 

200 # Convert weight to raw value based on units 

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

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

203 else: # SI units (kilograms) 

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

205 

206 if not 0 <= weight_raw <= UINT16_MAX: 

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

208 

209 # Start with flags and weight 

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

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

212 

213 # Add optional fields based on flags 

214 if data.timestamp is not None: 

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

216 

217 if data.user_id is not None: 

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

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

220 result.append(data.user_id) 

221 

222 if data.bmi is not None: 

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

224 if not 0 <= bmi_raw <= UINT16_MAX: 

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

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

227 

228 # Height always paired with BMI per spec 

229 if data.height is not None: 

230 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: 

231 height_raw = round(data.height / 0.1) 

232 else: 

233 height_raw = round(data.height / 0.001) 

234 

235 if not 0 <= height_raw <= UINT16_MAX: 

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

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

238 

239 return result