Coverage for src / bluetooth_sig / gatt / characteristics / body_composition_measurement.py: 83%

278 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-18 11:17 +0000

1"""Body Composition Measurement characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from datetime import datetime 

6from enum import IntFlag 

7from typing import Any, ClassVar 

8 

9import msgspec 

10 

11from bluetooth_sig.types.units import MeasurementSystem, WeightUnit 

12 

13from ..constants import PERCENTAGE_MAX, UINT8_MAX, UINT16_MAX 

14from ..context import CharacteristicContext 

15from .base import BaseCharacteristic 

16from .body_composition_feature import BodyCompositionFeatureCharacteristic, BodyCompositionFeatureData 

17from .utils import DataParser, IEEE11073Parser 

18 

19BODY_FAT_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution 

20MUSCLE_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution 

21IMPEDANCE_RESOLUTION = 0.1 # 0.1 ohm resolution 

22MASS_RESOLUTION_KG = 0.005 # 0.005 kg resolution 

23MASS_RESOLUTION_LB = 0.01 # 0.01 lb resolution 

24HEIGHT_RESOLUTION_METRIC = 0.001 # 0.001 m resolution 

25HEIGHT_RESOLUTION_IMPERIAL = 0.1 # 0.1 inch resolution 

26 

27 

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

29 """Flags and body fat percentage with parsing offset.""" 

30 

31 flags: BodyCompositionFlags 

32 body_fat_percentage: float 

33 offset: int 

34 

35 

36class BasicOptionalFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

37 """Basic optional fields: timestamp, user ID, and basal metabolism.""" 

38 

39 timestamp: datetime | None 

40 user_id: int | None 

41 basal_metabolism: int | None 

42 offset: int 

43 

44 

45class MassFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

46 """Mass-related optional fields.""" 

47 

48 muscle_mass: float | None 

49 muscle_mass_unit: WeightUnit | None 

50 muscle_percentage: float | None 

51 fat_free_mass: float | None 

52 soft_lean_mass: float | None 

53 body_water_mass: float | None 

54 offset: int 

55 

56 

57class OtherMeasurements(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

58 """Impedance, weight, and height measurements.""" 

59 

60 impedance: float | None 

61 weight: float | None 

62 height: float | None 

63 

64 

65class MassValue(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods 

66 """Single mass field with unit.""" 

67 

68 value: float 

69 unit: WeightUnit 

70 

71 

72class BodyCompositionFlags(IntFlag): 

73 """Body Composition Measurement flags as per Bluetooth SIG specification.""" 

74 

75 IMPERIAL_UNITS = 0x001 

76 TIMESTAMP_PRESENT = 0x002 

77 USER_ID_PRESENT = 0x004 

78 BASAL_METABOLISM_PRESENT = 0x008 

79 MUSCLE_MASS_PRESENT = 0x010 

80 MUSCLE_PERCENTAGE_PRESENT = 0x020 

81 FAT_FREE_MASS_PRESENT = 0x040 

82 SOFT_LEAN_MASS_PRESENT = 0x080 

83 BODY_WATER_MASS_PRESENT = 0x100 

84 IMPEDANCE_PRESENT = 0x200 

85 WEIGHT_PRESENT = 0x400 

86 HEIGHT_PRESENT = 0x800 

87 

88 

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

90 """Parsed data from Body Composition Measurement characteristic.""" 

91 

92 body_fat_percentage: float 

93 flags: BodyCompositionFlags 

94 measurement_units: MeasurementSystem 

95 timestamp: datetime | None = None 

96 user_id: int | None = None 

97 basal_metabolism: int | None = None 

98 muscle_mass: float | None = None 

99 muscle_mass_unit: WeightUnit | None = None 

100 muscle_percentage: float | None = None 

101 fat_free_mass: float | None = None 

102 soft_lean_mass: float | None = None 

103 body_water_mass: float | None = None 

104 impedance: float | None = None 

105 weight: float | None = None 

106 height: float | None = None 

107 

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

109 """Validate body composition measurement data.""" 

110 if not 0.0 <= self.body_fat_percentage <= PERCENTAGE_MAX: 

111 raise ValueError("Body fat percentage must be between 0-100%") 

112 if not 0 <= self.flags <= UINT16_MAX: 

113 raise ValueError("Flags must be a 16-bit value") 

114 

115 # Validate measurement_units 

116 if not isinstance(self.measurement_units, MeasurementSystem): 

117 raise TypeError(f"Invalid measurement_units: {self.measurement_units!r}") 

118 

119 # Validate mass fields units and ranges 

120 mass_fields = [ 

121 ("muscle_mass", self.muscle_mass), 

122 ("fat_free_mass", self.fat_free_mass), 

123 ("soft_lean_mass", self.soft_lean_mass), 

124 ("body_water_mass", self.body_water_mass), 

125 ("weight", self.weight), 

126 ] 

127 for field_name, value in mass_fields: 

128 if value is not None and not value >= 0: 

129 raise ValueError(f"{field_name} must be non-negative") 

130 

131 # Validate muscle_mass_unit consistency 

132 if self.muscle_mass is not None: 

133 expected_unit = WeightUnit.KG if self.measurement_units == MeasurementSystem.METRIC else WeightUnit.LB 

134 if self.muscle_mass_unit != expected_unit: 

135 raise ValueError(f"muscle_mass_unit must be {expected_unit!r}, got {self.muscle_mass_unit!r}") 

136 

137 # Validate muscle_percentage 

138 if self.muscle_percentage is not None and not self.muscle_percentage >= 0: 

139 raise ValueError("Muscle percentage must be non-negative") 

140 

141 # Validate impedance 

142 if self.impedance is not None and not self.impedance >= 0: 

143 raise ValueError("Impedance must be non-negative") 

144 

145 # Validate height 

146 if self.height is not None and not self.height >= 0: 

147 raise ValueError("Height must be non-negative") 

148 

149 # Validate basal_metabolism 

150 if self.basal_metabolism is not None and not self.basal_metabolism >= 0: 

151 raise ValueError("Basal metabolism must be non-negative") 

152 

153 # Validate user_id 

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

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

156 

157 

158class BodyCompositionMeasurementCharacteristic(BaseCharacteristic[BodyCompositionMeasurementData]): 

159 """Body Composition Measurement characteristic (0x2A9C). 

160 

161 Used to transmit body composition measurement data including body 

162 fat percentage, muscle mass, bone mass, water percentage, and other 

163 body metrics. 

164 """ 

165 

166 _manual_unit: str = "various" # Multiple units in measurement 

167 

168 _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [BodyCompositionFeatureCharacteristic] 

169 

170 min_length: int = 4 # Flags(2) + BodyFat(2) minimum 

171 max_length: int = 50 # + Timestamp(7) + UserID(1) + Multiple measurements maximum 

172 allow_variable_length: bool = True # Variable optional fields 

173 

174 def _decode_value( 

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

176 ) -> BodyCompositionMeasurementData: 

177 """Parse body composition measurement data according to Bluetooth specification. 

178 

179 Format: Flags(2) + Body Fat %(2) + [Timestamp(7)] + [User ID(1)] + 

180 [Basal Metabolism(2)] + [Muscle Mass(2)] + [etc...] 

181 

182 Args: 

183 data: Raw bytearray from BLE characteristic. 

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

185 validate: Whether to perform validation (currently unused). 

186 

187 Returns: 

188 BodyCompositionMeasurementData containing parsed body composition data. 

189 

190 Raises: 

191 ValueError: If data format is invalid. 

192 

193 """ 

194 # Parse flags and required body fat percentage 

195 header = self._parse_flags_and_body_fat(data) 

196 flags_enum = BodyCompositionFlags(header.flags) 

197 measurement_units = ( 

198 MeasurementSystem.IMPERIAL 

199 if BodyCompositionFlags.IMPERIAL_UNITS in flags_enum 

200 else MeasurementSystem.METRIC 

201 ) 

202 

203 # Parse optional fields based on flags 

204 basic = self._parse_basic_optional_fields(data, flags_enum, header.offset) 

205 mass = self._parse_mass_fields(data, flags_enum, basic.offset) 

206 other = self._parse_other_measurements(data, flags_enum, mass.offset) 

207 

208 # Validate against Body Composition Feature if context is available 

209 if ctx is not None: 

210 feature_value = self.get_context_characteristic(ctx, BodyCompositionFeatureCharacteristic) 

211 if feature_value is not None: 

212 self._validate_against_feature_characteristic(basic, mass, other, feature_value) 

213 

214 # Create struct with all parsed values 

215 return BodyCompositionMeasurementData( 

216 body_fat_percentage=header.body_fat_percentage, 

217 flags=flags_enum, 

218 measurement_units=measurement_units, 

219 timestamp=basic.timestamp, 

220 user_id=basic.user_id, 

221 basal_metabolism=basic.basal_metabolism, 

222 muscle_mass=mass.muscle_mass, 

223 muscle_mass_unit=mass.muscle_mass_unit, 

224 muscle_percentage=mass.muscle_percentage, 

225 fat_free_mass=mass.fat_free_mass, 

226 soft_lean_mass=mass.soft_lean_mass, 

227 body_water_mass=mass.body_water_mass, 

228 impedance=other.impedance, 

229 weight=other.weight, 

230 height=other.height, 

231 ) 

232 

233 def _encode_value(self, data: BodyCompositionMeasurementData) -> bytearray: 

234 """Encode body composition measurement value back to bytes. 

235 

236 Args: 

237 data: BodyCompositionMeasurementData containing body composition measurement data 

238 

239 Returns: 

240 Encoded bytes representing the measurement 

241 

242 """ 

243 result = bytearray() 

244 

245 # Encode flags and body fat percentage 

246 self._encode_flags_and_body_fat(result, data) 

247 

248 # Encode optional fields based on flags 

249 self._encode_optional_fields(result, data) 

250 

251 return result 

252 

253 def _encode_flags_and_body_fat(self, result: bytearray, data: BodyCompositionMeasurementData) -> None: 

254 """Encode flags and body fat percentage.""" 

255 # Encode flags (16-bit) 

256 flags = int(data.flags) 

257 result.extend(DataParser.encode_int16(flags, signed=False)) 

258 

259 # Encode body fat percentage (uint16 with 0.1% resolution) 

260 body_fat_raw = round(data.body_fat_percentage / BODY_FAT_PERCENTAGE_RESOLUTION) 

261 if not 0 <= body_fat_raw <= UINT16_MAX: 

262 raise ValueError(f"Body fat percentage {body_fat_raw} exceeds uint16 range") 

263 result.extend(DataParser.encode_int16(body_fat_raw, signed=False)) 

264 

265 def _encode_optional_fields(self, result: bytearray, data: BodyCompositionMeasurementData) -> None: 

266 """Encode optional fields based on measurement data.""" 

267 # Encode optional timestamp if present 

268 if data.timestamp is not None: 

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

270 

271 # Encode optional user ID if present 

272 if data.user_id is not None: 

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

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

275 result.append(data.user_id) 

276 

277 # Encode optional basal metabolism if present 

278 if data.basal_metabolism is not None: 

279 if not 0 <= data.basal_metabolism <= UINT16_MAX: 

280 raise ValueError(f"Basal metabolism {data.basal_metabolism} exceeds uint16 range") 

281 result.extend(DataParser.encode_int16(data.basal_metabolism, signed=False)) 

282 

283 # Encode mass-related fields 

284 self._encode_mass_fields(result, data) 

285 

286 # Encode other measurements 

287 self._encode_other_measurements(result, data) 

288 

289 def _encode_mass_fields(self, result: bytearray, data: BodyCompositionMeasurementData) -> None: 

290 """Encode mass-related optional fields.""" 

291 # Encode optional muscle mass if present 

292 if data.muscle_mass is not None: 

293 mass_raw = round(data.muscle_mass / MASS_RESOLUTION_KG) 

294 if not 0 <= mass_raw <= UINT16_MAX: 

295 raise ValueError(f"Muscle mass raw value {mass_raw} exceeds uint16 range") 

296 result.extend(DataParser.encode_int16(mass_raw, signed=False)) 

297 

298 # Encode optional muscle percentage if present 

299 if data.muscle_percentage is not None: 

300 muscle_pct_raw = round(data.muscle_percentage / MUSCLE_PERCENTAGE_RESOLUTION) 

301 if not 0 <= muscle_pct_raw <= UINT16_MAX: 

302 raise ValueError(f"Muscle percentage raw value {muscle_pct_raw} exceeds uint16 range") 

303 result.extend(DataParser.encode_int16(muscle_pct_raw, signed=False)) 

304 

305 # Encode optional fat free mass if present 

306 if data.fat_free_mass is not None: 

307 mass_raw = round(data.fat_free_mass / MASS_RESOLUTION_KG) 

308 if not 0 <= mass_raw <= UINT16_MAX: 

309 raise ValueError(f"Fat free mass raw value {mass_raw} exceeds uint16 range") 

310 result.extend(DataParser.encode_int16(mass_raw, signed=False)) 

311 

312 # Encode optional soft lean mass if present 

313 if data.soft_lean_mass is not None: 

314 mass_raw = round(data.soft_lean_mass / MASS_RESOLUTION_KG) 

315 if not 0 <= mass_raw <= UINT16_MAX: 

316 raise ValueError(f"Soft lean mass raw value {mass_raw} exceeds uint16 range") 

317 result.extend(DataParser.encode_int16(mass_raw, signed=False)) 

318 

319 # Encode optional body water mass if present 

320 if data.body_water_mass is not None: 

321 mass_raw = round(data.body_water_mass / MASS_RESOLUTION_KG) 

322 if not 0 <= mass_raw <= UINT16_MAX: 

323 raise ValueError(f"Body water mass raw value {mass_raw} exceeds uint16 range") 

324 result.extend(DataParser.encode_int16(mass_raw, signed=False)) 

325 

326 def _encode_other_measurements(self, result: bytearray, data: BodyCompositionMeasurementData) -> None: 

327 """Encode impedance, weight, and height measurements.""" 

328 # Encode optional impedance if present 

329 if data.impedance is not None: 

330 impedance_raw = round(data.impedance / IMPEDANCE_RESOLUTION) 

331 if not 0 <= impedance_raw <= UINT16_MAX: 

332 raise ValueError(f"Impedance raw value {impedance_raw} exceeds uint16 range") 

333 result.extend(DataParser.encode_int16(impedance_raw, signed=False)) 

334 

335 # Encode optional weight if present 

336 if data.weight is not None: 

337 mass_raw = round(data.weight / MASS_RESOLUTION_KG) 

338 if not 0 <= mass_raw <= UINT16_MAX: 

339 raise ValueError(f"Weight raw value {mass_raw} exceeds uint16 range") 

340 result.extend(DataParser.encode_int16(mass_raw, signed=False)) 

341 

342 # Encode optional height if present 

343 if data.height is not None: 

344 if data.measurement_units == MeasurementSystem.IMPERIAL: 

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

346 else: 

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

348 if not 0 <= height_raw <= UINT16_MAX: 

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

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

351 

352 def _validate_against_feature_characteristic( 

353 self, 

354 basic: BasicOptionalFields, 

355 mass: MassFields, 

356 other: OtherMeasurements, 

357 feature_data: BodyCompositionFeatureData, 

358 ) -> None: 

359 """Validate measurement data against Body Composition Feature characteristic. 

360 

361 Args: 

362 basic: Basic optional fields (timestamp, user_id, basal_metabolism) 

363 mass: Mass-related fields 

364 other: Other measurements (impedance, weight, height) 

365 feature_data: BodyCompositionFeatureData from feature characteristic 

366 

367 Raises: 

368 ValueError: If measurement reports unsupported features 

369 

370 """ 

371 # Check that reported measurements are supported by device features 

372 if basic.timestamp is not None and not feature_data.timestamp_supported: 

373 raise ValueError("Timestamp reported but not supported by device features") 

374 

375 if basic.user_id is not None and not feature_data.multiple_users_supported: 

376 raise ValueError("User ID reported but not supported by device features") 

377 

378 if basic.basal_metabolism is not None and not feature_data.basal_metabolism_supported: 

379 raise ValueError("Basal metabolism reported but not supported by device features") 

380 

381 if mass.muscle_mass is not None and not feature_data.muscle_mass_supported: 

382 raise ValueError("Muscle mass reported but not supported by device features") 

383 

384 if mass.muscle_percentage is not None and not feature_data.muscle_percentage_supported: 

385 raise ValueError("Muscle percentage reported but not supported by device features") 

386 

387 if mass.fat_free_mass is not None and not feature_data.fat_free_mass_supported: 

388 raise ValueError("Fat free mass reported but not supported by device features") 

389 

390 if mass.soft_lean_mass is not None and not feature_data.soft_lean_mass_supported: 

391 raise ValueError("Soft lean mass reported but not supported by device features") 

392 

393 if mass.body_water_mass is not None and not feature_data.body_water_mass_supported: 

394 raise ValueError("Body water mass reported but not supported by device features") 

395 

396 if other.impedance is not None and not feature_data.impedance_supported: 

397 raise ValueError("Impedance reported but not supported by device features") 

398 

399 if other.weight is not None and not feature_data.weight_supported: 

400 raise ValueError("Weight reported but not supported by device features") 

401 

402 if other.height is not None and not feature_data.height_supported: 

403 raise ValueError("Height reported but not supported by device features") 

404 

405 def _parse_flags_and_body_fat(self, data: bytearray) -> FlagsAndBodyFat: 

406 """Parse flags and body fat percentage from data. 

407 

408 Returns: 

409 FlagsAndBodyFat containing flags, body fat percentage, and offset 

410 

411 """ 

412 # Parse flags (2 bytes) 

413 flags = BodyCompositionFlags(DataParser.parse_int16(data, 0, signed=False)) 

414 

415 # Validate and parse body fat percentage data 

416 

417 body_fat_raw = DataParser.parse_int16(data, 2, signed=False) 

418 body_fat_percentage = float(body_fat_raw) * BODY_FAT_PERCENTAGE_RESOLUTION # 0.1% resolution 

419 

420 return FlagsAndBodyFat(flags=flags, body_fat_percentage=body_fat_percentage, offset=4) 

421 

422 def _parse_basic_optional_fields( 

423 self, 

424 data: bytearray, 

425 flags: BodyCompositionFlags, 

426 offset: int, 

427 ) -> BasicOptionalFields: 

428 """Parse basic optional fields (timestamp, user ID, basal metabolism). 

429 

430 Args: 

431 data: Raw bytearray 

432 flags: Parsed flags indicating which fields are present 

433 offset: Current offset in data 

434 

435 Returns: 

436 BasicOptionalFields containing timestamp, user_id, basal_metabolism, and updated offset 

437 

438 """ 

439 timestamp: datetime | None = None 

440 user_id: int | None = None 

441 basal_metabolism: int | None = None 

442 

443 # Parse optional timestamp (7 bytes) if present 

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

445 timestamp = IEEE11073Parser.parse_timestamp(data, offset) 

446 offset += 7 

447 

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

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

450 user_id = int(data[offset]) 

451 offset += 1 

452 

453 # Parse optional basal metabolism (uint16) if present 

454 if BodyCompositionFlags.BASAL_METABOLISM_PRESENT in flags and len(data) >= offset + 2: 

455 basal_metabolism_raw = DataParser.parse_int16(data, offset, signed=False) 

456 basal_metabolism = basal_metabolism_raw # in kJ 

457 offset += 2 

458 

459 return BasicOptionalFields( 

460 timestamp=timestamp, user_id=user_id, basal_metabolism=basal_metabolism, offset=offset 

461 ) 

462 

463 def _parse_mass_fields( 

464 self, 

465 data: bytearray, 

466 flags: BodyCompositionFlags, 

467 offset: int, 

468 ) -> MassFields: 

469 """Parse mass-related optional fields. 

470 

471 Args: 

472 data: Raw bytearray 

473 flags: Parsed flags 

474 offset: Current offset 

475 

476 Returns: 

477 MassFields containing muscle_mass, muscle_mass_unit, muscle_percentage, 

478 fat_free_mass, soft_lean_mass, body_water_mass, and updated offset 

479 

480 """ 

481 muscle_mass: float | None = None 

482 muscle_mass_unit: WeightUnit | None = None 

483 muscle_percentage: float | None = None 

484 fat_free_mass: float | None = None 

485 soft_lean_mass: float | None = None 

486 body_water_mass: float | None = None 

487 

488 # Parse optional muscle mass 

489 if BodyCompositionFlags.MUSCLE_MASS_PRESENT in flags and len(data) >= offset + 2: 

490 mass_value = self._parse_mass_field(data, flags, offset) 

491 muscle_mass = mass_value.value 

492 muscle_mass_unit = mass_value.unit 

493 offset += 2 

494 

495 # Parse optional muscle percentage 

496 if BodyCompositionFlags.MUSCLE_PERCENTAGE_PRESENT in flags and len(data) >= offset + 2: 

497 muscle_percentage_raw = DataParser.parse_int16(data, offset, signed=False) 

498 muscle_percentage = muscle_percentage_raw * MUSCLE_PERCENTAGE_RESOLUTION 

499 offset += 2 

500 

501 # Parse optional fat free mass 

502 if BodyCompositionFlags.FAT_FREE_MASS_PRESENT in flags and len(data) >= offset + 2: 

503 fat_free_mass = self._parse_mass_field(data, flags, offset).value 

504 offset += 2 

505 

506 # Parse optional soft lean mass 

507 if BodyCompositionFlags.SOFT_LEAN_MASS_PRESENT in flags and len(data) >= offset + 2: 

508 soft_lean_mass = self._parse_mass_field(data, flags, offset).value 

509 offset += 2 

510 

511 # Parse optional body water mass 

512 if BodyCompositionFlags.BODY_WATER_MASS_PRESENT in flags and len(data) >= offset + 2: 

513 body_water_mass = self._parse_mass_field(data, flags, offset).value 

514 offset += 2 

515 

516 return MassFields( 

517 muscle_mass=muscle_mass, 

518 muscle_mass_unit=muscle_mass_unit, 

519 muscle_percentage=muscle_percentage, 

520 fat_free_mass=fat_free_mass, 

521 soft_lean_mass=soft_lean_mass, 

522 body_water_mass=body_water_mass, 

523 offset=offset, 

524 ) 

525 

526 def _parse_other_measurements( 

527 self, 

528 data: bytearray, 

529 flags: BodyCompositionFlags, 

530 offset: int, 

531 ) -> OtherMeasurements: 

532 """Parse impedance, weight, and height measurements. 

533 

534 Args: 

535 data: Raw bytearray 

536 flags: Parsed flags 

537 offset: Current offset 

538 

539 Returns: 

540 OtherMeasurements containing impedance, weight, and height 

541 

542 """ 

543 impedance: float | None = None 

544 weight: float | None = None 

545 height: float | None = None 

546 

547 # Parse optional impedance 

548 if BodyCompositionFlags.IMPEDANCE_PRESENT in flags and len(data) >= offset + 2: 

549 impedance_raw = DataParser.parse_int16(data, offset, signed=False) 

550 impedance = impedance_raw * IMPEDANCE_RESOLUTION 

551 offset += 2 

552 

553 # Parse optional weight 

554 if BodyCompositionFlags.WEIGHT_PRESENT in flags and len(data) >= offset + 2: 

555 weight = self._parse_mass_field(data, flags, offset).value 

556 offset += 2 

557 

558 # Parse optional height 

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

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

561 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units 

562 height = height_raw * HEIGHT_RESOLUTION_IMPERIAL # 0.1 inch resolution 

563 else: # SI units 

564 height = height_raw * HEIGHT_RESOLUTION_METRIC # 0.001 m resolution 

565 offset += 2 

566 

567 return OtherMeasurements(impedance=impedance, weight=weight, height=height) 

568 

569 def _parse_mass_field(self, data: bytearray, flags: BodyCompositionFlags, offset: int) -> MassValue: 

570 """Parse a mass field with unit conversion. 

571 

572 Args: 

573 data: Raw bytearray 

574 flags: Parsed flags for unit determination 

575 offset: Current offset 

576 

577 Returns: 

578 MassValue containing mass value and unit string 

579 

580 """ 

581 mass_raw = DataParser.parse_int16(data, offset, signed=False) 

582 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units 

583 mass = mass_raw * MASS_RESOLUTION_LB # 0.01 lb resolution 

584 mass_unit = WeightUnit.LB 

585 else: # SI units 

586 mass = mass_raw * MASS_RESOLUTION_KG # 0.005 kg resolution 

587 mass_unit = WeightUnit.KG 

588 return MassValue(value=mass, unit=mass_unit)