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
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 01:26 +0000
1"""Weight Measurement characteristic implementation."""
3from __future__ import annotations
5from datetime import datetime
6from enum import IntFlag
7from typing import Any, ClassVar
9import msgspec
11from bluetooth_sig.types.units import HeightUnit, MeasurementSystem, WeightUnit
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
20class WeightMeasurementFlags(IntFlag):
21 """Weight Measurement flags as per Bluetooth SIG specification."""
23 IMPERIAL_UNITS = 0x01
24 TIMESTAMP_PRESENT = 0x02
25 USER_ID_PRESENT = 0x04
26 BMI_AND_HEIGHT_PRESENT = 0x08
29class WeightMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
30 """Parsed weight measurement data."""
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
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}")
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}")
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}")
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}")
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}")
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}")
84class WeightMeasurementCharacteristic(BaseCharacteristic[WeightMeasurementData]):
85 """Weight Measurement characteristic (0x2A9D).
87 Used to transmit weight measurement data with optional fields.
88 Supports metric/imperial units, timestamps, user ID, BMI, and
89 height.
90 """
92 _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [WeightScaleFeatureCharacteristic]
94 _characteristic_name: str = "Weight Measurement"
95 _manual_unit: str = "kg" # Primary unit for weight measurement
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
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.
106 Format: Flags(1) + Weight(2) + [Timestamp(7)] + [User ID(1)] +
107 [BMI(2)] + [Height(2)]
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)
114 Returns:
115 WeightMeasurementData containing parsed weight measurement data.
117 Raises:
118 ValueError: If data format is invalid.
120 """
121 flags = WeightMeasurementFlags(data[0])
122 offset = 1
124 # Parse weight value (uint16 with 0.005 kg resolution)
125 weight_raw = DataParser.parse_int16(data, offset, signed=False)
126 offset += 2
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
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
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
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
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
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")
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 )
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.
196 Args:
197 data: WeightMeasurementData containing weight measurement data
199 Returns:
200 Encoded bytes representing the weight measurement
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
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
220 if not 0 <= weight_raw <= UINT16_MAX:
221 raise ValueError(f"Weight value {weight_raw} exceeds uint16 range")
223 # Start with flags and weight
224 result = bytearray([int(flags)])
225 result.extend(DataParser.encode_int16(weight_raw, signed=False))
227 # Add optional fields based on flags
228 if data.timestamp is not None:
229 result.extend(IEEE11073Parser.encode_timestamp(data.timestamp))
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)
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))
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)
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))
253 return result