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

135 statements  

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

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_PRESENT = 0x08 

25 HEIGHT_PRESENT = 0x10 

26 

27 

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

29 """Parsed weight measurement data.""" 

30 

31 weight: float 

32 weight_unit: WeightUnit 

33 measurement_units: MeasurementSystem 

34 flags: WeightMeasurementFlags 

35 timestamp: datetime | None = None 

36 user_id: int | None = None 

37 bmi: float | None = None 

38 height: float | None = None 

39 height_unit: HeightUnit | None = None 

40 

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

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

43 # Validate weight_unit consistency 

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

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

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

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

48 

49 # Validate weight range 

50 if self.measurement_units == MeasurementSystem.METRIC: 

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

52 if self.weight < 0: 

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

54 else: # imperial 

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

56 if 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 else: # imperial 

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

73 if self.height < 0: 

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

75 

76 # Validate BMI if present 

77 if self.bmi is not None: 

78 # Allow any non-negative BMI (no SIG-specified range) 

79 if self.bmi < 0: 

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

81 

82 # Validate user_id if present 

83 if self.user_id is not None: 

84 if not 0 <= self.user_id <= 255: 

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

86 

87 

88class WeightMeasurementCharacteristic(BaseCharacteristic): 

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

90 

91 Used to transmit weight measurement data with optional fields. 

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

93 height. 

94 """ 

95 

96 _characteristic_name: str = "Weight Measurement" 

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

98 

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

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

101 allow_variable_length: bool = True # Variable optional fields 

102 

103 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> WeightMeasurementData: # pylint: disable=too-many-locals 

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 

113 Returns: 

114 WeightMeasurementData containing parsed weight measurement data. 

115 

116 Raises: 

117 ValueError: If data format is invalid. 

118 

119 """ 

120 if len(data) < 3: 

121 raise ValueError("Weight Measurement data must be at least 3 bytes") 

122 

123 flags = WeightMeasurementFlags(data[0]) 

124 offset = 1 

125 

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

127 if len(data) < offset + 2: 

128 raise ValueError("Insufficient data for weight value") 

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

130 offset += 2 

131 

132 # Convert to appropriate unit based on flags 

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

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

135 weight_unit = WeightUnit.LB 

136 measurement_units = MeasurementSystem.IMPERIAL 

137 else: # SI units (kilograms) 

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

139 weight_unit = WeightUnit.KG 

140 measurement_units = MeasurementSystem.METRIC 

141 

142 # Parse optional timestamp (7 bytes) if present 

143 timestamp = None 

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

145 timestamp = IEEE11073Parser.parse_timestamp(data, offset) 

146 offset += 7 

147 

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

149 user_id = None 

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

151 user_id = int(data[offset]) 

152 offset += 1 

153 

154 # Parse optional BMI (uint16 with 0.1 resolution) if present 

155 bmi = None 

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

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

158 bmi = bmi_raw * 0.1 

159 offset += 2 

160 

161 # Parse optional height (uint16 with 0.001m resolution) if present 

162 height = None 

163 height_unit = None 

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

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

166 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (inches) 

167 height = height_raw * 0.1 # 0.1 inch resolution 

168 height_unit = HeightUnit.INCHES 

169 else: # SI units (meters) 

170 height = height_raw * 0.001 # 0.001 m resolution 

171 height_unit = HeightUnit.METERS 

172 offset += 2 

173 

174 # Create result with all parsed values 

175 return WeightMeasurementData( 

176 weight=weight, 

177 weight_unit=weight_unit, 

178 measurement_units=measurement_units, 

179 flags=flags, 

180 timestamp=timestamp, 

181 user_id=user_id, 

182 bmi=bmi, 

183 height=height, 

184 height_unit=height_unit, 

185 ) 

186 

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

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

189 

190 Args: 

191 data: WeightMeasurementData containing weight measurement data 

192 

193 Returns: 

194 Encoded bytes representing the weight measurement 

195 

196 """ 

197 # Build flags based on available data 

198 flags = WeightMeasurementFlags(0) 

199 if data.measurement_units == MeasurementSystem.IMPERIAL: 

200 flags |= WeightMeasurementFlags.IMPERIAL_UNITS 

201 if data.timestamp is not None: 

202 flags |= WeightMeasurementFlags.TIMESTAMP_PRESENT 

203 if data.user_id is not None: 

204 flags |= WeightMeasurementFlags.USER_ID_PRESENT 

205 if data.bmi is not None: 

206 flags |= WeightMeasurementFlags.BMI_PRESENT 

207 if data.height is not None: 

208 flags |= WeightMeasurementFlags.HEIGHT_PRESENT 

209 

210 # Convert weight to raw value based on units 

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

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

213 else: # SI units (kilograms) 

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

215 

216 if not 0 <= weight_raw <= 0xFFFF: 

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

218 

219 # Start with flags and weight 

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

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

222 

223 # Add optional fields based on flags 

224 if data.timestamp is not None: 

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

226 

227 if data.user_id is not None: 

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

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

230 result.append(data.user_id) 

231 

232 if data.bmi is not None: 

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

234 if not 0 <= bmi_raw <= 0xFFFF: 

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

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

237 

238 if data.height is not None: 

239 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (inches) 

240 height_raw = round(data.height / 0.1) # 0.1 inch resolution 

241 else: # SI units (meters) 

242 height_raw = round(data.height / 0.001) # 0.001 m resolution 

243 

244 if not 0 <= height_raw <= 0xFFFF: 

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

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

247 

248 return result