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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +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
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 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}")
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 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}")
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}")
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}")
88class WeightMeasurementCharacteristic(BaseCharacteristic):
89 """Weight Measurement characteristic (0x2A9D).
91 Used to transmit weight measurement data with optional fields.
92 Supports metric/imperial units, timestamps, user ID, BMI, and
93 height.
94 """
96 _characteristic_name: str = "Weight Measurement"
97 _manual_unit: str = "kg" # Primary unit for weight measurement
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
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.
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).
113 Returns:
114 WeightMeasurementData containing parsed weight measurement data.
116 Raises:
117 ValueError: If data format is invalid.
119 """
120 if len(data) < 3:
121 raise ValueError("Weight Measurement data must be at least 3 bytes")
123 flags = WeightMeasurementFlags(data[0])
124 offset = 1
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
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
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
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
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
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
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 )
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.
190 Args:
191 data: WeightMeasurementData containing weight measurement data
193 Returns:
194 Encoded bytes representing the weight measurement
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
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
216 if not 0 <= weight_raw <= 0xFFFF:
217 raise ValueError(f"Weight value {weight_raw} exceeds uint16 range")
219 # Start with flags and weight
220 result = bytearray([int(flags)])
221 result.extend(DataParser.encode_int16(weight_raw, signed=False))
223 # Add optional fields based on flags
224 if data.timestamp is not None:
225 result.extend(IEEE11073Parser.encode_timestamp(data.timestamp))
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)
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))
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
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))
248 return result