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

287 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 20:14 +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[BodyCompositionMeasurementData]): 

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 _optional_dependencies = [BodyCompositionFeatureCharacteristic] 

174 

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

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

177 allow_variable_length: bool = True # Variable optional fields 

178 

179 def _decode_value( 

180 self, data: bytearray, ctx: CharacteristicContext | None = None 

181 ) -> BodyCompositionMeasurementData: 

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

183 

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

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

186 

187 Args: 

188 data: Raw bytearray from BLE characteristic. 

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

190 

191 Returns: 

192 BodyCompositionMeasurementData containing parsed body composition data. 

193 

194 Raises: 

195 ValueError: If data format is invalid. 

196 

197 """ 

198 if len(data) < 4: 

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

200 

201 # Parse flags and required body fat percentage 

202 header = self._parse_flags_and_body_fat(data) 

203 flags_enum = BodyCompositionFlags(header.flags) 

204 measurement_units = ( 

205 MeasurementSystem.IMPERIAL 

206 if BodyCompositionFlags.IMPERIAL_UNITS in flags_enum 

207 else MeasurementSystem.METRIC 

208 ) 

209 

210 # Parse optional fields based on flags 

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

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

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

214 

215 # Validate against Body Composition Feature if context is available 

216 if ctx is not None: 

217 feature_value = self.get_context_characteristic(ctx, BodyCompositionFeatureCharacteristic) 

218 if feature_value is not None: 

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

220 

221 # Create struct with all parsed values 

222 return BodyCompositionMeasurementData( 

223 body_fat_percentage=header.body_fat_percentage, 

224 flags=flags_enum, 

225 measurement_units=measurement_units, 

226 timestamp=basic.timestamp, 

227 user_id=basic.user_id, 

228 basal_metabolism=basic.basal_metabolism, 

229 muscle_mass=mass.muscle_mass, 

230 muscle_mass_unit=mass.muscle_mass_unit, 

231 muscle_percentage=mass.muscle_percentage, 

232 fat_free_mass=mass.fat_free_mass, 

233 soft_lean_mass=mass.soft_lean_mass, 

234 body_water_mass=mass.body_water_mass, 

235 impedance=other.impedance, 

236 weight=other.weight, 

237 height=other.height, 

238 ) 

239 

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

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

242 

243 Args: 

244 data: BodyCompositionMeasurementData containing body composition measurement data 

245 

246 Returns: 

247 Encoded bytes representing the measurement 

248 

249 """ 

250 result = bytearray() 

251 

252 # Encode flags and body fat percentage 

253 self._encode_flags_and_body_fat(result, data) 

254 

255 # Encode optional fields based on flags 

256 self._encode_optional_fields(result, data) 

257 

258 return result 

259 

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

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

262 # Encode flags (16-bit) 

263 flags = int(data.flags) 

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

265 

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

267 body_fat_raw = round(data.body_fat_percentage / BODY_FAT_PERCENTAGE_RESOLUTION) 

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

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

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

271 

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

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

274 # Encode optional timestamp if present 

275 if data.timestamp is not None: 

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

277 

278 # Encode optional user ID if present 

279 if data.user_id is not None: 

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

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

282 result.append(data.user_id) 

283 

284 # Encode optional basal metabolism if present 

285 if data.basal_metabolism is not None: 

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

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

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

289 

290 # Encode mass-related fields 

291 self._encode_mass_fields(result, data) 

292 

293 # Encode other measurements 

294 self._encode_other_measurements(result, data) 

295 

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

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

298 # Encode optional muscle mass if present 

299 if data.muscle_mass is not None: 

300 mass_raw = round(data.muscle_mass / MASS_RESOLUTION_KG) 

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

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

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

304 

305 # Encode optional muscle percentage if present 

306 if data.muscle_percentage is not None: 

307 muscle_pct_raw = round(data.muscle_percentage / MUSCLE_PERCENTAGE_RESOLUTION) 

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

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

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

311 

312 # Encode optional fat free mass if present 

313 if data.fat_free_mass is not None: 

314 mass_raw = round(data.fat_free_mass / MASS_RESOLUTION_KG) 

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

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

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

318 

319 # Encode optional soft lean mass if present 

320 if data.soft_lean_mass is not None: 

321 mass_raw = round(data.soft_lean_mass / MASS_RESOLUTION_KG) 

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

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

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

325 

326 # Encode optional body water mass if present 

327 if data.body_water_mass is not None: 

328 mass_raw = round(data.body_water_mass / MASS_RESOLUTION_KG) 

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

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

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

332 

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

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

335 # Encode optional impedance if present 

336 if data.impedance is not None: 

337 impedance_raw = round(data.impedance / IMPEDANCE_RESOLUTION) 

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

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

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

341 

342 # Encode optional weight if present 

343 if data.weight is not None: 

344 mass_raw = round(data.weight / MASS_RESOLUTION_KG) 

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

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

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

348 

349 # Encode optional height if present 

350 if data.height is not None: 

351 if data.measurement_units == MeasurementSystem.IMPERIAL: 

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

353 else: 

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

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

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

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

358 

359 def _validate_against_feature_characteristic( 

360 self, 

361 basic: BasicOptionalFields, 

362 mass: MassFields, 

363 other: OtherMeasurements, 

364 feature_data: BodyCompositionFeatureData, 

365 ) -> None: 

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

367 

368 Args: 

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

370 mass: Mass-related fields 

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

372 feature_data: BodyCompositionFeatureData from feature characteristic 

373 

374 Raises: 

375 ValueError: If measurement reports unsupported features 

376 

377 """ 

378 # Check that reported measurements are supported by device features 

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

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

381 

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

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

384 

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

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

387 

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

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

390 

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

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

393 

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

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

396 

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

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

399 

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

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

402 

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

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

405 

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

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

408 

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

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

411 

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

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

414 

415 Returns: 

416 FlagsAndBodyFat containing flags, body fat percentage, and offset 

417 

418 """ 

419 # Parse flags (2 bytes) 

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

421 

422 # Validate and parse body fat percentage data 

423 if len(data) < 4: 

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

425 

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

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

428 

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

430 

431 def _parse_basic_optional_fields( 

432 self, 

433 data: bytearray, 

434 flags: BodyCompositionFlags, 

435 offset: int, 

436 ) -> BasicOptionalFields: 

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

438 

439 Args: 

440 data: Raw bytearray 

441 flags: Parsed flags indicating which fields are present 

442 offset: Current offset in data 

443 

444 Returns: 

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

446 

447 """ 

448 timestamp: datetime | None = None 

449 user_id: int | None = None 

450 basal_metabolism: int | None = None 

451 

452 # Parse optional timestamp (7 bytes) if present 

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

454 timestamp = IEEE11073Parser.parse_timestamp(data, offset) 

455 offset += 7 

456 

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

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

459 user_id = int(data[offset]) 

460 offset += 1 

461 

462 # Parse optional basal metabolism (uint16) if present 

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

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

465 basal_metabolism = basal_metabolism_raw # in kJ 

466 offset += 2 

467 

468 return BasicOptionalFields( 

469 timestamp=timestamp, user_id=user_id, basal_metabolism=basal_metabolism, offset=offset 

470 ) 

471 

472 def _parse_mass_fields( 

473 self, 

474 data: bytearray, 

475 flags: BodyCompositionFlags, 

476 offset: int, 

477 ) -> MassFields: 

478 """Parse mass-related optional fields. 

479 

480 Args: 

481 data: Raw bytearray 

482 flags: Parsed flags 

483 offset: Current offset 

484 

485 Returns: 

486 MassFields containing muscle_mass, muscle_mass_unit, muscle_percentage, 

487 fat_free_mass, soft_lean_mass, body_water_mass, and updated offset 

488 

489 """ 

490 muscle_mass: float | None = None 

491 muscle_mass_unit: WeightUnit | None = None 

492 muscle_percentage: float | None = None 

493 fat_free_mass: float | None = None 

494 soft_lean_mass: float | None = None 

495 body_water_mass: float | None = None 

496 

497 # Parse optional muscle mass 

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

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

500 muscle_mass = mass_value.value 

501 muscle_mass_unit = mass_value.unit 

502 offset += 2 

503 

504 # Parse optional muscle percentage 

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

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

507 muscle_percentage = muscle_percentage_raw * MUSCLE_PERCENTAGE_RESOLUTION 

508 offset += 2 

509 

510 # Parse optional fat free mass 

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

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

513 offset += 2 

514 

515 # Parse optional soft lean mass 

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

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

518 offset += 2 

519 

520 # Parse optional body water mass 

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

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

523 offset += 2 

524 

525 return MassFields( 

526 muscle_mass=muscle_mass, 

527 muscle_mass_unit=muscle_mass_unit, 

528 muscle_percentage=muscle_percentage, 

529 fat_free_mass=fat_free_mass, 

530 soft_lean_mass=soft_lean_mass, 

531 body_water_mass=body_water_mass, 

532 offset=offset, 

533 ) 

534 

535 def _parse_other_measurements( 

536 self, 

537 data: bytearray, 

538 flags: BodyCompositionFlags, 

539 offset: int, 

540 ) -> OtherMeasurements: 

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

542 

543 Args: 

544 data: Raw bytearray 

545 flags: Parsed flags 

546 offset: Current offset 

547 

548 Returns: 

549 OtherMeasurements containing impedance, weight, and height 

550 

551 """ 

552 impedance: float | None = None 

553 weight: float | None = None 

554 height: float | None = None 

555 

556 # Parse optional impedance 

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

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

559 impedance = impedance_raw * IMPEDANCE_RESOLUTION 

560 offset += 2 

561 

562 # Parse optional weight 

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

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

565 offset += 2 

566 

567 # Parse optional height 

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

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

570 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units 

571 height = height_raw * HEIGHT_RESOLUTION_IMPERIAL # 0.1 inch resolution 

572 else: # SI units 

573 height = height_raw * HEIGHT_RESOLUTION_METRIC # 0.001 m resolution 

574 offset += 2 

575 

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

577 

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

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

580 

581 Args: 

582 data: Raw bytearray 

583 flags: Parsed flags for unit determination 

584 offset: Current offset 

585 

586 Returns: 

587 MassValue containing mass value and unit string 

588 

589 """ 

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

591 if BodyCompositionFlags.IMPERIAL_UNITS in flags: # Imperial units 

592 mass = mass_raw * MASS_RESOLUTION_LB # 0.01 lb resolution 

593 mass_unit = WeightUnit.LB 

594 else: # SI units 

595 mass = mass_raw * MASS_RESOLUTION_KG # 0.005 kg resolution 

596 mass_unit = WeightUnit.KG 

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