Coverage for src / bluetooth_sig / gatt / characteristics / weight_measurement.py: 89%
129 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Weight Measurement characteristic implementation."""
3from __future__ import annotations
5from datetime import datetime
6from enum import IntFlag
8import msgspec
10from bluetooth_sig.types.units import HeightUnit, MeasurementSystem, WeightUnit
12from ..constants import UINT8_MAX, UINT16_MAX
13from ..context import CharacteristicContext
14from .base import BaseCharacteristic
15from .utils import DataParser, IEEE11073Parser
18class WeightMeasurementFlags(IntFlag):
19 """Weight Measurement flags as per Bluetooth SIG specification."""
21 IMPERIAL_UNITS = 0x01
22 TIMESTAMP_PRESENT = 0x02
23 USER_ID_PRESENT = 0x04
24 BMI_PRESENT = 0x08
25 HEIGHT_PRESENT = 0x10
28class WeightMeasurementData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
29 """Parsed weight measurement data."""
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
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}")
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 # Allow any non-negative weight (no SIG-specified range)
55 elif self.weight < 0:
56 raise ValueError(f"Weight in lb must be non-negative, got {self.weight}")
58 # Validate height_unit consistency if height present
59 if self.height is not None:
60 if self.measurement_units == MeasurementSystem.METRIC and self.height_unit != HeightUnit.METERS:
61 raise ValueError(f"Metric units require height_unit={HeightUnit.METERS!r}, got {self.height_unit!r}")
62 if self.measurement_units == MeasurementSystem.IMPERIAL and self.height_unit != HeightUnit.INCHES:
63 raise ValueError(f"Imperial units require height_unit={HeightUnit.INCHES!r}, got {self.height_unit!r}")
65 # Validate height range
66 if self.measurement_units == MeasurementSystem.METRIC:
67 # Allow any non-negative height (no SIG-specified range)
68 if self.height < 0:
69 raise ValueError(f"Height in m must be non-negative, got {self.height}")
70 # Allow any non-negative height (no SIG-specified range)
71 elif self.height < 0:
72 raise ValueError(f"Height in in must be non-negative, got {self.height}")
74 # Validate BMI if present
75 if self.bmi is not None and self.bmi < 0:
76 raise ValueError(f"BMI must be non-negative, got {self.bmi}")
78 # Validate user_id if present
79 if self.user_id is not None and not 0 <= self.user_id <= UINT8_MAX:
80 raise ValueError(f"User ID must be 0-{UINT8_MAX}, got {self.user_id}")
83class WeightMeasurementCharacteristic(BaseCharacteristic[WeightMeasurementData]):
84 """Weight Measurement characteristic (0x2A9D).
86 Used to transmit weight measurement data with optional fields.
87 Supports metric/imperial units, timestamps, user ID, BMI, and
88 height.
89 """
91 _characteristic_name: str = "Weight Measurement"
92 _manual_unit: str = "kg" # Primary unit for weight measurement
94 min_length: int = 3 # Flags(1) + Weight(2) minimum
95 max_length: int = 21 # + Timestamp(7) + UserID(1) + BMI(2) + Height(2) maximum
96 allow_variable_length: bool = True # Variable optional fields
98 def _decode_value(
99 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True
100 ) -> WeightMeasurementData: # pylint: disable=too-many-locals # Weight measurement with optional fields
101 """Parse weight measurement data according to Bluetooth specification.
103 Format: Flags(1) + Weight(2) + [Timestamp(7)] + [User ID(1)] +
104 [BMI(2)] + [Height(2)]
106 Args:
107 data: Raw bytearray from BLE characteristic.
108 ctx: Optional CharacteristicContext providing surrounding context (may be None).
109 validate: Whether to validate ranges (default True)
111 Returns:
112 WeightMeasurementData containing parsed weight measurement data.
114 Raises:
115 ValueError: If data format is invalid.
117 """
118 flags = WeightMeasurementFlags(data[0])
119 offset = 1
121 # Parse weight value (uint16 with 0.005 kg resolution)
122 weight_raw = DataParser.parse_int16(data, offset, signed=False)
123 offset += 2
125 # Convert to appropriate unit based on flags
126 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (pounds)
127 weight = weight_raw * 0.01 # 0.01 lb resolution for imperial
128 weight_unit = WeightUnit.LB
129 measurement_units = MeasurementSystem.IMPERIAL
130 else: # SI units (kilograms)
131 weight = weight_raw * 0.005 # 0.005 kg resolution for metric
132 weight_unit = WeightUnit.KG
133 measurement_units = MeasurementSystem.METRIC
135 # Parse optional timestamp (7 bytes) if present
136 timestamp = None
137 if WeightMeasurementFlags.TIMESTAMP_PRESENT in flags and len(data) >= offset + 7:
138 timestamp = IEEE11073Parser.parse_timestamp(data, offset)
139 offset += 7
141 # Parse optional user ID (1 byte) if present
142 user_id = None
143 if WeightMeasurementFlags.USER_ID_PRESENT in flags and len(data) >= offset + 1:
144 user_id = int(data[offset])
145 offset += 1
147 # Parse optional BMI (uint16 with 0.1 resolution) if present
148 bmi = None
149 if WeightMeasurementFlags.BMI_PRESENT in flags and len(data) >= offset + 2:
150 bmi_raw = DataParser.parse_int16(data, offset, signed=False)
151 bmi = bmi_raw * 0.1
152 offset += 2
154 # Parse optional height (uint16 with 0.001m resolution) if present
155 height = None
156 height_unit = None
157 if WeightMeasurementFlags.HEIGHT_PRESENT in flags and len(data) >= offset + 2:
158 height_raw = DataParser.parse_int16(data, offset, signed=False)
159 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (inches)
160 height = height_raw * 0.1 # 0.1 inch resolution
161 height_unit = HeightUnit.INCHES
162 else: # SI units (meters)
163 height = height_raw * 0.001 # 0.001 m resolution
164 height_unit = HeightUnit.METERS
165 offset += 2
167 # Create result with all parsed values
168 return WeightMeasurementData(
169 weight=weight,
170 weight_unit=weight_unit,
171 measurement_units=measurement_units,
172 flags=flags,
173 timestamp=timestamp,
174 user_id=user_id,
175 bmi=bmi,
176 height=height,
177 height_unit=height_unit,
178 )
180 def _encode_value(self, data: WeightMeasurementData) -> bytearray: # pylint: disable=too-many-branches # Complex measurement data with many optional fields
181 """Encode weight measurement value back to bytes.
183 Args:
184 data: WeightMeasurementData containing weight measurement data
186 Returns:
187 Encoded bytes representing the weight measurement
189 """
190 # Build flags based on available data
191 flags = WeightMeasurementFlags(0)
192 if data.measurement_units == MeasurementSystem.IMPERIAL:
193 flags |= WeightMeasurementFlags.IMPERIAL_UNITS
194 if data.timestamp is not None:
195 flags |= WeightMeasurementFlags.TIMESTAMP_PRESENT
196 if data.user_id is not None:
197 flags |= WeightMeasurementFlags.USER_ID_PRESENT
198 if data.bmi is not None:
199 flags |= WeightMeasurementFlags.BMI_PRESENT
200 if data.height is not None:
201 flags |= WeightMeasurementFlags.HEIGHT_PRESENT
203 # Convert weight to raw value based on units
204 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (pounds)
205 weight_raw = round(data.weight / 0.01) # 0.01 lb resolution
206 else: # SI units (kilograms)
207 weight_raw = round(data.weight / 0.005) # 0.005 kg resolution
209 if not 0 <= weight_raw <= UINT16_MAX:
210 raise ValueError(f"Weight value {weight_raw} exceeds uint16 range")
212 # Start with flags and weight
213 result = bytearray([int(flags)])
214 result.extend(DataParser.encode_int16(weight_raw, signed=False))
216 # Add optional fields based on flags
217 if data.timestamp is not None:
218 result.extend(IEEE11073Parser.encode_timestamp(data.timestamp))
220 if data.user_id is not None:
221 if not 0 <= data.user_id <= UINT8_MAX:
222 raise ValueError(f"User ID {data.user_id} exceeds uint8 range")
223 result.append(data.user_id)
225 if data.bmi is not None:
226 bmi_raw = round(data.bmi / 0.1) # 0.1 resolution
227 if not 0 <= bmi_raw <= UINT16_MAX:
228 raise ValueError(f"BMI value {bmi_raw} exceeds uint16 range")
229 result.extend(DataParser.encode_int16(bmi_raw, signed=False))
231 if data.height is not None:
232 if WeightMeasurementFlags.IMPERIAL_UNITS in flags: # Imperial units (inches)
233 height_raw = round(data.height / 0.1) # 0.1 inch resolution
234 else: # SI units (meters)
235 height_raw = round(data.height / 0.001) # 0.001 m resolution
237 if not 0 <= height_raw <= UINT16_MAX:
238 raise ValueError(f"Height value {height_raw} exceeds uint16 range")
239 result.extend(DataParser.encode_int16(height_raw, signed=False))
241 return result