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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-18 11:17 +0000
1"""Body Composition Measurement characteristic implementation."""
3from __future__ import annotations
5from datetime import datetime
6from enum import IntFlag
7from typing import Any, ClassVar
9import msgspec
11from bluetooth_sig.types.units import MeasurementSystem, WeightUnit
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
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
28class FlagsAndBodyFat(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
29 """Flags and body fat percentage with parsing offset."""
31 flags: BodyCompositionFlags
32 body_fat_percentage: float
33 offset: int
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."""
39 timestamp: datetime | None
40 user_id: int | None
41 basal_metabolism: int | None
42 offset: int
45class MassFields(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
46 """Mass-related optional fields."""
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
57class OtherMeasurements(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
58 """Impedance, weight, and height measurements."""
60 impedance: float | None
61 weight: float | None
62 height: float | None
65class MassValue(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
66 """Single mass field with unit."""
68 value: float
69 unit: WeightUnit
72class BodyCompositionFlags(IntFlag):
73 """Body Composition Measurement flags as per Bluetooth SIG specification."""
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
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."""
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
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")
115 # Validate measurement_units
116 if not isinstance(self.measurement_units, MeasurementSystem):
117 raise TypeError(f"Invalid measurement_units: {self.measurement_units!r}")
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")
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}")
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")
141 # Validate impedance
142 if self.impedance is not None and not self.impedance >= 0:
143 raise ValueError("Impedance must be non-negative")
145 # Validate height
146 if self.height is not None and not self.height >= 0:
147 raise ValueError("Height must be non-negative")
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")
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}")
158class BodyCompositionMeasurementCharacteristic(BaseCharacteristic[BodyCompositionMeasurementData]):
159 """Body Composition Measurement characteristic (0x2A9C).
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 """
166 _manual_unit: str = "various" # Multiple units in measurement
168 _optional_dependencies: ClassVar[list[type[BaseCharacteristic[Any]]]] = [BodyCompositionFeatureCharacteristic]
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
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.
179 Format: Flags(2) + Body Fat %(2) + [Timestamp(7)] + [User ID(1)] +
180 [Basal Metabolism(2)] + [Muscle Mass(2)] + [etc...]
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).
187 Returns:
188 BodyCompositionMeasurementData containing parsed body composition data.
190 Raises:
191 ValueError: If data format is invalid.
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 )
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)
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)
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 )
233 def _encode_value(self, data: BodyCompositionMeasurementData) -> bytearray:
234 """Encode body composition measurement value back to bytes.
236 Args:
237 data: BodyCompositionMeasurementData containing body composition measurement data
239 Returns:
240 Encoded bytes representing the measurement
242 """
243 result = bytearray()
245 # Encode flags and body fat percentage
246 self._encode_flags_and_body_fat(result, data)
248 # Encode optional fields based on flags
249 self._encode_optional_fields(result, data)
251 return result
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))
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))
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))
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)
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))
283 # Encode mass-related fields
284 self._encode_mass_fields(result, data)
286 # Encode other measurements
287 self._encode_other_measurements(result, data)
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))
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))
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))
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))
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))
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))
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))
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))
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.
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
367 Raises:
368 ValueError: If measurement reports unsupported features
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")
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")
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")
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")
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")
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")
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")
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")
396 if other.impedance is not None and not feature_data.impedance_supported:
397 raise ValueError("Impedance reported but not supported by device features")
399 if other.weight is not None and not feature_data.weight_supported:
400 raise ValueError("Weight reported but not supported by device features")
402 if other.height is not None and not feature_data.height_supported:
403 raise ValueError("Height reported but not supported by device features")
405 def _parse_flags_and_body_fat(self, data: bytearray) -> FlagsAndBodyFat:
406 """Parse flags and body fat percentage from data.
408 Returns:
409 FlagsAndBodyFat containing flags, body fat percentage, and offset
411 """
412 # Parse flags (2 bytes)
413 flags = BodyCompositionFlags(DataParser.parse_int16(data, 0, signed=False))
415 # Validate and parse body fat percentage data
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
420 return FlagsAndBodyFat(flags=flags, body_fat_percentage=body_fat_percentage, offset=4)
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).
430 Args:
431 data: Raw bytearray
432 flags: Parsed flags indicating which fields are present
433 offset: Current offset in data
435 Returns:
436 BasicOptionalFields containing timestamp, user_id, basal_metabolism, and updated offset
438 """
439 timestamp: datetime | None = None
440 user_id: int | None = None
441 basal_metabolism: int | None = None
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
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
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
459 return BasicOptionalFields(
460 timestamp=timestamp, user_id=user_id, basal_metabolism=basal_metabolism, offset=offset
461 )
463 def _parse_mass_fields(
464 self,
465 data: bytearray,
466 flags: BodyCompositionFlags,
467 offset: int,
468 ) -> MassFields:
469 """Parse mass-related optional fields.
471 Args:
472 data: Raw bytearray
473 flags: Parsed flags
474 offset: Current offset
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
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
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
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
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
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
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
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 )
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.
534 Args:
535 data: Raw bytearray
536 flags: Parsed flags
537 offset: Current offset
539 Returns:
540 OtherMeasurements containing impedance, weight, and height
542 """
543 impedance: float | None = None
544 weight: float | None = None
545 height: float | None = None
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
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
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
567 return OtherMeasurements(impedance=impedance, weight=weight, height=height)
569 def _parse_mass_field(self, data: bytearray, flags: BodyCompositionFlags, offset: int) -> MassValue:
570 """Parse a mass field with unit conversion.
572 Args:
573 data: Raw bytearray
574 flags: Parsed flags for unit determination
575 offset: Current offset
577 Returns:
578 MassValue containing mass value and unit string
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)