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

286 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 00:10 +0000

1"""Body Composition 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 MeasurementSystem, WeightUnit 

11 

12from ..constants import PERCENTAGE_MAX 

13from ..context import CharacteristicContext 

14from .base import BaseCharacteristic 

15from .body_composition_feature import BodyCompositionFeatureCharacteristic, BodyCompositionFeatureData 

16from .utils import DataParser, IEEE11073Parser 

17 

18BODY_FAT_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution 

19MUSCLE_PERCENTAGE_RESOLUTION = 0.1 # 0.1% resolution 

20IMPEDANCE_RESOLUTION = 0.1 # 0.1 ohm resolution 

21MASS_RESOLUTION_KG = 0.005 # 0.005 kg resolution 

22MASS_RESOLUTION_LB = 0.01 # 0.01 lb resolution 

23HEIGHT_RESOLUTION_METRIC = 0.001 # 0.001 m resolution 

24HEIGHT_RESOLUTION_IMPERIAL = 0.1 # 0.1 inch resolution 

25 

26 

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

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

29 

30 flags: BodyCompositionFlags 

31 body_fat_percentage: float 

32 offset: int 

33 

34 

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

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

37 

38 timestamp: datetime | None 

39 user_id: int | None 

40 basal_metabolism: int | None 

41 offset: int 

42 

43 

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

45 """Mass-related optional fields.""" 

46 

47 muscle_mass: float | None 

48 muscle_mass_unit: WeightUnit | None 

49 muscle_percentage: float | None 

50 fat_free_mass: float | None 

51 soft_lean_mass: float | None 

52 body_water_mass: float | None 

53 offset: int 

54 

55 

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

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

58 

59 impedance: float | None 

60 weight: float | None 

61 height: float | None 

62 

63 

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

65 """Single mass field with unit.""" 

66 

67 value: float 

68 unit: WeightUnit 

69 

70 

71class BodyCompositionFlags(IntFlag): 

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

73 

74 IMPERIAL_UNITS = 0x001 

75 TIMESTAMP_PRESENT = 0x002 

76 USER_ID_PRESENT = 0x004 

77 BASAL_METABOLISM_PRESENT = 0x008 

78 MUSCLE_MASS_PRESENT = 0x010 

79 MUSCLE_PERCENTAGE_PRESENT = 0x020 

80 FAT_FREE_MASS_PRESENT = 0x040 

81 SOFT_LEAN_MASS_PRESENT = 0x080 

82 BODY_WATER_MASS_PRESENT = 0x100 

83 IMPEDANCE_PRESENT = 0x200 

84 WEIGHT_PRESENT = 0x400 

85 HEIGHT_PRESENT = 0x800 

86 

87 

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

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

90 

91 body_fat_percentage: float 

92 flags: BodyCompositionFlags 

93 measurement_units: MeasurementSystem 

94 timestamp: datetime | None = None 

95 user_id: int | None = None 

96 basal_metabolism: int | None = None 

97 muscle_mass: float | None = None 

98 muscle_mass_unit: WeightUnit | None = None 

99 muscle_percentage: float | None = None 

100 fat_free_mass: float | None = None 

101 soft_lean_mass: float | None = None 

102 body_water_mass: float | None = None 

103 impedance: float | None = None 

104 weight: float | None = None 

105 height: float | None = None 

106 

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

108 """Validate body composition measurement data.""" 

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

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

111 if not 0 <= self.flags <= 0xFFFF: 

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

113 

114 # Validate measurement_units 

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

116 raise ValueError(f"Invalid measurement_units: {self.measurement_units!r}") 

117 

118 # Validate mass fields units and ranges 

119 mass_fields = [ 

120 ("muscle_mass", self.muscle_mass), 

121 ("fat_free_mass", self.fat_free_mass), 

122 ("soft_lean_mass", self.soft_lean_mass), 

123 ("body_water_mass", self.body_water_mass), 

124 ("weight", self.weight), 

125 ] 

126 for field_name, value in mass_fields: 

127 if value is not None: 

128 if not 0 <= value: 

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: 

139 if not 0 <= self.muscle_percentage: 

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

141 

142 # Validate impedance 

143 if self.impedance is not None: 

144 if not 0 <= self.impedance: 

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

146 

147 # Validate height 

148 if self.height is not None: 

149 if not 0 <= self.height: 

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

151 

152 # Validate basal_metabolism 

153 if self.basal_metabolism is not None: 

154 if not 0 <= self.basal_metabolism: 

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

156 

157 # Validate user_id 

158 if self.user_id is not None: 

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

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

161 

162 

163class BodyCompositionMeasurementCharacteristic(BaseCharacteristic): 

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

165 

166 Used to transmit body composition measurement data including body 

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

168 body metrics. 

169 """ 

170 

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

172 

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

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

175 allow_variable_length: bool = True # Variable optional fields 

176 

177 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BodyCompositionMeasurementData: 

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

179 

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

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

182 

183 Args: 

184 data: Raw bytearray from BLE characteristic. 

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

186 

187 Returns: 

188 BodyCompositionMeasurementData containing parsed body composition data. 

189 

190 Raises: 

191 ValueError: If data format is invalid. 

192 

193 """ 

194 if len(data) < 4: 

195 raise ValueError("Body Composition Measurement data must be at least 4 bytes") 

196 

197 # Parse flags and required body fat percentage 

198 header = self._parse_flags_and_body_fat(data) 

199 flags_enum = BodyCompositionFlags(header.flags) 

200 measurement_units = ( 

201 MeasurementSystem.IMPERIAL 

202 if BodyCompositionFlags.IMPERIAL_UNITS in flags_enum 

203 else MeasurementSystem.METRIC 

204 ) 

205 

206 # Parse optional fields based on flags 

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

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

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

210 

211 # Validate against Body Composition Feature if context is available 

212 if ctx is not None: 

213 feature_char = self.get_context_characteristic(ctx, BodyCompositionFeatureCharacteristic) 

214 if feature_char and feature_char.parse_success and feature_char.value: 

215 self._validate_against_feature_characteristic(basic, mass, other, feature_char.value) 

216 

217 # Create struct with all parsed values 

218 return BodyCompositionMeasurementData( 

219 body_fat_percentage=header.body_fat_percentage, 

220 flags=flags_enum, 

221 measurement_units=measurement_units, 

222 timestamp=basic.timestamp, 

223 user_id=basic.user_id, 

224 basal_metabolism=basic.basal_metabolism, 

225 muscle_mass=mass.muscle_mass, 

226 muscle_mass_unit=mass.muscle_mass_unit, 

227 muscle_percentage=mass.muscle_percentage, 

228 fat_free_mass=mass.fat_free_mass, 

229 soft_lean_mass=mass.soft_lean_mass, 

230 body_water_mass=mass.body_water_mass, 

231 impedance=other.impedance, 

232 weight=other.weight, 

233 height=other.height, 

234 ) 

235 

236 def encode_value(self, data: BodyCompositionMeasurementData) -> bytearray: 

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

238 

239 Args: 

240 data: BodyCompositionMeasurementData containing body composition measurement data 

241 

242 Returns: 

243 Encoded bytes representing the measurement 

244 

245 """ 

246 result = bytearray() 

247 

248 # Encode flags and body fat percentage 

249 self._encode_flags_and_body_fat(result, data) 

250 

251 # Encode optional fields based on flags 

252 self._encode_optional_fields(result, data) 

253 

254 return result 

255 

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

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

258 # Encode flags (16-bit) 

259 flags = int(data.flags) 

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

261 

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

263 body_fat_raw = round(data.body_fat_percentage / BODY_FAT_PERCENTAGE_RESOLUTION) 

264 if not 0 <= body_fat_raw <= 0xFFFF: 

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

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

267 

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

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

270 # Encode optional timestamp if present 

271 if data.timestamp is not None: 

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

273 

274 # Encode optional user ID if present 

275 if data.user_id is not None: 

276 if not 0 <= data.user_id <= 0xFF: 

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

278 result.append(data.user_id) 

279 

280 # Encode optional basal metabolism if present 

281 if data.basal_metabolism is not None: 

282 if not 0 <= data.basal_metabolism <= 0xFFFF: 

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

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

285 

286 # Encode mass-related fields 

287 self._encode_mass_fields(result, data) 

288 

289 # Encode other measurements 

290 self._encode_other_measurements(result, data) 

291 

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

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

294 # Encode optional muscle mass if present 

295 if data.muscle_mass is not None: 

296 mass_raw = round(data.muscle_mass / MASS_RESOLUTION_KG) 

297 if not 0 <= mass_raw <= 0xFFFF: 

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

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

300 

301 # Encode optional muscle percentage if present 

302 if data.muscle_percentage is not None: 

303 muscle_pct_raw = round(data.muscle_percentage / MUSCLE_PERCENTAGE_RESOLUTION) 

304 if not 0 <= muscle_pct_raw <= 0xFFFF: 

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

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

307 

308 # Encode optional fat free mass if present 

309 if data.fat_free_mass is not None: 

310 mass_raw = round(data.fat_free_mass / MASS_RESOLUTION_KG) 

311 if not 0 <= mass_raw <= 0xFFFF: 

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

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

314 

315 # Encode optional soft lean mass if present 

316 if data.soft_lean_mass is not None: 

317 mass_raw = round(data.soft_lean_mass / MASS_RESOLUTION_KG) 

318 if not 0 <= mass_raw <= 0xFFFF: 

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

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

321 

322 # Encode optional body water mass if present 

323 if data.body_water_mass is not None: 

324 mass_raw = round(data.body_water_mass / MASS_RESOLUTION_KG) 

325 if not 0 <= mass_raw <= 0xFFFF: 

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

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

328 

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

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

331 # Encode optional impedance if present 

332 if data.impedance is not None: 

333 impedance_raw = round(data.impedance / IMPEDANCE_RESOLUTION) 

334 if not 0 <= impedance_raw <= 0xFFFF: 

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

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

337 

338 # Encode optional weight if present 

339 if data.weight is not None: 

340 mass_raw = round(data.weight / MASS_RESOLUTION_KG) 

341 if not 0 <= mass_raw <= 0xFFFF: 

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

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

344 

345 # Encode optional height if present 

346 if data.height is not None: 

347 if data.measurement_units == MeasurementSystem.IMPERIAL: 

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

349 else: 

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

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

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

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

354 

355 def _validate_against_feature_characteristic( 

356 self, 

357 basic: BasicOptionalFields, 

358 mass: MassFields, 

359 other: OtherMeasurements, 

360 feature_data: BodyCompositionFeatureData, 

361 ) -> None: 

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

363 

364 Args: 

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

366 mass: Mass-related fields 

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

368 feature_data: BodyCompositionFeatureData from feature characteristic 

369 

370 Raises: 

371 ValueError: If measurement reports unsupported features 

372 

373 """ 

374 # Check that reported measurements are supported by device features 

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

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

377 

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

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

380 

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

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

383 

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

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

386 

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

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

389 

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

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

392 

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

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

395 

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

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

398 

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

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

401 

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

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

404 

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

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

407 

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

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

410 

411 Returns: 

412 FlagsAndBodyFat containing flags, body fat percentage, and offset 

413 

414 """ 

415 # Parse flags (2 bytes) 

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

417 

418 # Validate and parse body fat percentage data 

419 if len(data) < 4: 

420 raise ValueError("Insufficient data for body fat percentage") 

421 

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

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

424 

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

426 

427 def _parse_basic_optional_fields( 

428 self, 

429 data: bytearray, 

430 flags: BodyCompositionFlags, 

431 offset: int, 

432 ) -> BasicOptionalFields: 

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

434 

435 Args: 

436 data: Raw bytearray 

437 flags: Parsed flags indicating which fields are present 

438 offset: Current offset in data 

439 

440 Returns: 

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

442 

443 """ 

444 timestamp: datetime | None = None 

445 user_id: int | None = None 

446 basal_metabolism: int | None = None 

447 

448 # Parse optional timestamp (7 bytes) if present 

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

450 timestamp = IEEE11073Parser.parse_timestamp(data, offset) 

451 offset += 7 

452 

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

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

455 user_id = int(data[offset]) 

456 offset += 1 

457 

458 # Parse optional basal metabolism (uint16) if present 

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

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

461 basal_metabolism = basal_metabolism_raw # in kJ 

462 offset += 2 

463 

464 return BasicOptionalFields( 

465 timestamp=timestamp, user_id=user_id, basal_metabolism=basal_metabolism, offset=offset 

466 ) 

467 

468 def _parse_mass_fields( 

469 self, 

470 data: bytearray, 

471 flags: BodyCompositionFlags, 

472 offset: int, 

473 ) -> MassFields: 

474 """Parse mass-related optional fields. 

475 

476 Args: 

477 data: Raw bytearray 

478 flags: Parsed flags 

479 offset: Current offset 

480 

481 Returns: 

482 MassFields containing muscle_mass, muscle_mass_unit, muscle_percentage, 

483 fat_free_mass, soft_lean_mass, body_water_mass, and updated offset 

484 

485 """ 

486 muscle_mass: float | None = None 

487 muscle_mass_unit: WeightUnit | None = None 

488 muscle_percentage: float | None = None 

489 fat_free_mass: float | None = None 

490 soft_lean_mass: float | None = None 

491 body_water_mass: float | None = None 

492 

493 # Parse optional muscle mass 

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

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

496 muscle_mass = mass_value.value 

497 muscle_mass_unit = mass_value.unit 

498 offset += 2 

499 

500 # Parse optional muscle percentage 

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

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

503 muscle_percentage = muscle_percentage_raw * MUSCLE_PERCENTAGE_RESOLUTION 

504 offset += 2 

505 

506 # Parse optional fat free mass 

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

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

509 offset += 2 

510 

511 # Parse optional soft lean mass 

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

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

514 offset += 2 

515 

516 # Parse optional body water mass 

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

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

519 offset += 2 

520 

521 return MassFields( 

522 muscle_mass=muscle_mass, 

523 muscle_mass_unit=muscle_mass_unit, 

524 muscle_percentage=muscle_percentage, 

525 fat_free_mass=fat_free_mass, 

526 soft_lean_mass=soft_lean_mass, 

527 body_water_mass=body_water_mass, 

528 offset=offset, 

529 ) 

530 

531 def _parse_other_measurements( 

532 self, 

533 data: bytearray, 

534 flags: BodyCompositionFlags, 

535 offset: int, 

536 ) -> OtherMeasurements: 

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

538 

539 Args: 

540 data: Raw bytearray 

541 flags: Parsed flags 

542 offset: Current offset 

543 

544 Returns: 

545 OtherMeasurements containing impedance, weight, and height 

546 

547 """ 

548 impedance: float | None = None 

549 weight: float | None = None 

550 height: float | None = None 

551 

552 # Parse optional impedance 

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

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

555 impedance = impedance_raw * IMPEDANCE_RESOLUTION 

556 offset += 2 

557 

558 # Parse optional weight 

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

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

561 offset += 2 

562 

563 # Parse optional height 

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

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

566 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units 

567 height = height_raw * HEIGHT_RESOLUTION_IMPERIAL # 0.1 inch resolution 

568 else: # SI units 

569 height = height_raw * HEIGHT_RESOLUTION_METRIC # 0.001 m resolution 

570 offset += 2 

571 

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

573 

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

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

576 

577 Args: 

578 data: Raw bytearray 

579 flags: Parsed flags for unit determination 

580 offset: Current offset 

581 

582 Returns: 

583 MassValue containing mass value and unit string 

584 

585 """ 

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

587 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units 

588 mass = mass_raw * MASS_RESOLUTION_LB # 0.01 lb resolution 

589 mass_unit = WeightUnit.LB 

590 else: # SI units 

591 mass = mass_raw * MASS_RESOLUTION_KG # 0.005 kg resolution 

592 mass_unit = WeightUnit.KG 

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