Coverage for src / bluetooth_sig / gatt / characteristics / body_composition_feature.py: 92%
107 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 20:14 +0000
1"""Body Composition Feature characteristic implementation."""
3from __future__ import annotations
5from enum import IntEnum, IntFlag
7import msgspec
9from ..context import CharacteristicContext
10from .base import BaseCharacteristic
11from .utils import BitFieldUtils, DataParser
14class BodyCompositionFeatureBits:
15 """Body Composition Feature bit field constants."""
17 # pylint: disable=too-few-public-methods
19 MASS_RESOLUTION_START_BIT = 11 # Mass resolution starts at bit 11
20 MASS_RESOLUTION_BIT_WIDTH = 4 # Mass resolution uses 4 bits
21 HEIGHT_RESOLUTION_START_BIT = 15 # Height resolution starts at bit 15
22 HEIGHT_RESOLUTION_BIT_WIDTH = 3 # Height resolution uses 3 bits
25class MassMeasurementResolution(IntEnum):
26 """Mass measurement resolution enumeration."""
28 NOT_SPECIFIED = 0
29 KG_0_5_OR_LB_1 = 1
30 KG_0_2_OR_LB_0_5 = 2
31 KG_0_1_OR_LB_0_2 = 3
32 KG_0_05_OR_LB_0_1 = 4
33 KG_0_02_OR_LB_0_05 = 5
34 KG_0_01_OR_LB_0_02 = 6
35 KG_0_005_OR_LB_0_01 = 7
37 def __str__(self) -> str:
38 """Return human-readable mass resolution description."""
39 descriptions = {
40 self.NOT_SPECIFIED: "not_specified",
41 self.KG_0_5_OR_LB_1: "0.5_kg_or_1_lb",
42 self.KG_0_2_OR_LB_0_5: "0.2_kg_or_0.5_lb",
43 self.KG_0_1_OR_LB_0_2: "0.1_kg_or_0.2_lb",
44 self.KG_0_05_OR_LB_0_1: "0.05_kg_or_0.1_lb",
45 self.KG_0_02_OR_LB_0_05: "0.02_kg_or_0.05_lb",
46 self.KG_0_01_OR_LB_0_02: "0.01_kg_or_0.02_lb",
47 self.KG_0_005_OR_LB_0_01: "0.005_kg_or_0.01_lb",
48 }
49 return descriptions.get(self, "Reserved for Future Use")
52class HeightMeasurementResolution(IntEnum):
53 """Height measurement resolution enumeration."""
55 NOT_SPECIFIED = 0
56 M_0_01_OR_INCH_1 = 1
57 M_0_005_OR_INCH_0_5 = 2
58 M_0_001_OR_INCH_0_1 = 3
60 def __str__(self) -> str:
61 """Return human-readable height resolution description."""
62 descriptions = {
63 self.NOT_SPECIFIED: "not_specified",
64 self.M_0_01_OR_INCH_1: "0.01_m_or_1_inch",
65 self.M_0_005_OR_INCH_0_5: "0.005_m_or_0.5_inch",
66 self.M_0_001_OR_INCH_0_1: "0.001_m_or_0.1_inch",
67 }
68 return descriptions.get(self, "Reserved for Future Use")
71class BodyCompositionFeatures(IntFlag):
72 """Body Composition Feature flags as per Bluetooth SIG specification."""
74 TIMESTAMP_SUPPORTED = 0x01
75 MULTIPLE_USERS_SUPPORTED = 0x02
76 BASAL_METABOLISM_SUPPORTED = 0x04
77 MUSCLE_MASS_SUPPORTED = 0x08
78 MUSCLE_PERCENTAGE_SUPPORTED = 0x10
79 FAT_FREE_MASS_SUPPORTED = 0x20
80 SOFT_LEAN_MASS_SUPPORTED = 0x40
81 BODY_WATER_MASS_SUPPORTED = 0x80
82 IMPEDANCE_SUPPORTED = 0x100
83 WEIGHT_SUPPORTED = 0x200
84 HEIGHT_SUPPORTED = 0x400
87class BodyCompositionFeatureData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
88 """Parsed data from Body Composition Feature characteristic."""
90 features: BodyCompositionFeatures
91 timestamp_supported: bool
92 multiple_users_supported: bool
93 basal_metabolism_supported: bool
94 muscle_mass_supported: bool
95 muscle_percentage_supported: bool
96 fat_free_mass_supported: bool
97 soft_lean_mass_supported: bool
98 body_water_mass_supported: bool
99 impedance_supported: bool
100 weight_supported: bool
101 height_supported: bool
102 mass_measurement_resolution: MassMeasurementResolution
103 height_measurement_resolution: HeightMeasurementResolution
106class BodyCompositionFeatureCharacteristic(BaseCharacteristic[BodyCompositionFeatureData]):
107 """Body Composition Feature characteristic (0x2A9B).
109 Used to indicate which optional features and measurements are
110 supported by the body composition device. This is a read-only
111 characteristic that describes device capabilities.
112 """
114 expected_length: int = 4
115 min_length: int = 4 # Features(4) fixed length
116 max_length: int = 4 # Features(4) fixed length
117 allow_variable_length: bool = False # Fixed length
119 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BodyCompositionFeatureData:
120 """Parse body composition feature data according to Bluetooth specification.
122 Format: Features(4 bytes) - bitmask indicating supported measurements.
124 Args:
125 data: Raw bytearray from BLE characteristic.
126 ctx: Optional CharacteristicContext providing surrounding context (may be None).
128 Returns:
129 BodyCompositionFeatureData containing parsed feature flags.
131 Raises:
132 ValueError: If data format is invalid.
134 """
135 if len(data) < 4:
136 raise ValueError("Body Composition Feature data must be at least 4 bytes")
138 features_raw = DataParser.parse_int32(data, 0, signed=False)
140 # Parse feature flags according to specification
141 return BodyCompositionFeatureData(
142 features=BodyCompositionFeatures(features_raw),
143 # Basic features
144 timestamp_supported=bool(features_raw & BodyCompositionFeatures.TIMESTAMP_SUPPORTED),
145 multiple_users_supported=bool(features_raw & BodyCompositionFeatures.MULTIPLE_USERS_SUPPORTED),
146 basal_metabolism_supported=bool(features_raw & BodyCompositionFeatures.BASAL_METABOLISM_SUPPORTED),
147 muscle_mass_supported=bool(features_raw & BodyCompositionFeatures.MUSCLE_MASS_SUPPORTED),
148 muscle_percentage_supported=bool(features_raw & BodyCompositionFeatures.MUSCLE_PERCENTAGE_SUPPORTED),
149 fat_free_mass_supported=bool(features_raw & BodyCompositionFeatures.FAT_FREE_MASS_SUPPORTED),
150 soft_lean_mass_supported=bool(features_raw & BodyCompositionFeatures.SOFT_LEAN_MASS_SUPPORTED),
151 body_water_mass_supported=bool(features_raw & BodyCompositionFeatures.BODY_WATER_MASS_SUPPORTED),
152 impedance_supported=bool(features_raw & BodyCompositionFeatures.IMPEDANCE_SUPPORTED),
153 weight_supported=bool(features_raw & BodyCompositionFeatures.WEIGHT_SUPPORTED),
154 height_supported=bool(features_raw & BodyCompositionFeatures.HEIGHT_SUPPORTED),
155 # Mass measurement resolution (bits 11-14)
156 mass_measurement_resolution=self._get_mass_resolution(features_raw),
157 # Height measurement resolution (bits 15-17)
158 height_measurement_resolution=self._get_height_resolution(features_raw),
159 )
161 def _encode_value(self, data: BodyCompositionFeatureData) -> bytearray:
162 """Encode BodyCompositionFeatureData back to bytes.
164 Args:
165 data: BodyCompositionFeatureData instance to encode
167 Returns:
168 Encoded bytes representing the body composition features
170 """
171 # Reconstruct the features bitmap from individual flags
172 features_bitmap = 0
173 if data.timestamp_supported:
174 features_bitmap |= BodyCompositionFeatures.TIMESTAMP_SUPPORTED
175 if data.multiple_users_supported:
176 features_bitmap |= BodyCompositionFeatures.MULTIPLE_USERS_SUPPORTED
177 if data.basal_metabolism_supported:
178 features_bitmap |= BodyCompositionFeatures.BASAL_METABOLISM_SUPPORTED
179 if data.muscle_mass_supported:
180 features_bitmap |= BodyCompositionFeatures.MUSCLE_MASS_SUPPORTED
181 if data.muscle_percentage_supported:
182 features_bitmap |= BodyCompositionFeatures.MUSCLE_PERCENTAGE_SUPPORTED
183 if data.fat_free_mass_supported:
184 features_bitmap |= BodyCompositionFeatures.FAT_FREE_MASS_SUPPORTED
185 if data.soft_lean_mass_supported:
186 features_bitmap |= BodyCompositionFeatures.SOFT_LEAN_MASS_SUPPORTED
187 if data.body_water_mass_supported:
188 features_bitmap |= BodyCompositionFeatures.BODY_WATER_MASS_SUPPORTED
189 if data.impedance_supported:
190 features_bitmap |= BodyCompositionFeatures.IMPEDANCE_SUPPORTED
191 if data.weight_supported:
192 features_bitmap |= BodyCompositionFeatures.WEIGHT_SUPPORTED
193 if data.height_supported:
194 features_bitmap |= BodyCompositionFeatures.HEIGHT_SUPPORTED
196 features_bitmap = BitFieldUtils.set_bit_field(
197 features_bitmap,
198 data.mass_measurement_resolution.value,
199 BodyCompositionFeatureBits.MASS_RESOLUTION_START_BIT,
200 BodyCompositionFeatureBits.MASS_RESOLUTION_BIT_WIDTH,
201 )
202 features_bitmap = BitFieldUtils.set_bit_field(
203 features_bitmap,
204 data.height_measurement_resolution.value,
205 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_START_BIT,
206 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_BIT_WIDTH,
207 )
209 # Pack as little-endian 32-bit integer
210 return bytearray(DataParser.encode_int32(features_bitmap, signed=False))
212 def _get_mass_resolution(self, features: int) -> MassMeasurementResolution:
213 """Extract mass measurement resolution from features bitmask.
215 Args:
216 features: Raw feature bitmask
218 Returns:
219 MassMeasurementResolution enum value
221 """
222 resolution_bits = BitFieldUtils.extract_bit_field(
223 features,
224 BodyCompositionFeatureBits.MASS_RESOLUTION_START_BIT,
225 BodyCompositionFeatureBits.MASS_RESOLUTION_BIT_WIDTH,
226 ) # Bits 11-14 (4 bits)
228 try:
229 return MassMeasurementResolution(resolution_bits)
230 except ValueError:
231 # Values not in enum are reserved per Bluetooth SIG spec
232 return MassMeasurementResolution.NOT_SPECIFIED
234 def _get_height_resolution(self, features: int) -> HeightMeasurementResolution:
235 """Extract height measurement resolution from features bitmask.
237 Args:
238 features: Raw feature bitmask
240 Returns:
241 HeightMeasurementResolution enum value
243 """
244 resolution_bits = BitFieldUtils.extract_bit_field(
245 features,
246 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_START_BIT,
247 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_BIT_WIDTH,
248 ) # Bits 15-17 (3 bits)
250 try:
251 return HeightMeasurementResolution(resolution_bits)
252 except ValueError:
253 # Values not in enum are reserved per Bluetooth SIG spec
254 return HeightMeasurementResolution.NOT_SPECIFIED