Coverage for src/bluetooth_sig/gatt/characteristics/body_composition_feature.py: 96%
106 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 00:10 +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):
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 min_length: int = 4 # Features(4) fixed length
115 max_length: int = 4 # Features(4) fixed length
116 allow_variable_length: bool = False # Fixed length
118 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BodyCompositionFeatureData:
119 """Parse body composition feature data according to Bluetooth specification.
121 Format: Features(4 bytes) - bitmask indicating supported measurements.
123 Args:
124 data: Raw bytearray from BLE characteristic.
125 ctx: Optional CharacteristicContext providing surrounding context (may be None).
127 Returns:
128 BodyCompositionFeatureData containing parsed feature flags.
130 Raises:
131 ValueError: If data format is invalid.
133 """
134 if len(data) < 4:
135 raise ValueError("Body Composition Feature data must be at least 4 bytes")
137 features_raw = DataParser.parse_int32(data, 0, signed=False)
139 # Parse feature flags according to specification
140 return BodyCompositionFeatureData(
141 features=BodyCompositionFeatures(features_raw),
142 # Basic features
143 timestamp_supported=bool(features_raw & BodyCompositionFeatures.TIMESTAMP_SUPPORTED),
144 multiple_users_supported=bool(features_raw & BodyCompositionFeatures.MULTIPLE_USERS_SUPPORTED),
145 basal_metabolism_supported=bool(features_raw & BodyCompositionFeatures.BASAL_METABOLISM_SUPPORTED),
146 muscle_mass_supported=bool(features_raw & BodyCompositionFeatures.MUSCLE_MASS_SUPPORTED),
147 muscle_percentage_supported=bool(features_raw & BodyCompositionFeatures.MUSCLE_PERCENTAGE_SUPPORTED),
148 fat_free_mass_supported=bool(features_raw & BodyCompositionFeatures.FAT_FREE_MASS_SUPPORTED),
149 soft_lean_mass_supported=bool(features_raw & BodyCompositionFeatures.SOFT_LEAN_MASS_SUPPORTED),
150 body_water_mass_supported=bool(features_raw & BodyCompositionFeatures.BODY_WATER_MASS_SUPPORTED),
151 impedance_supported=bool(features_raw & BodyCompositionFeatures.IMPEDANCE_SUPPORTED),
152 weight_supported=bool(features_raw & BodyCompositionFeatures.WEIGHT_SUPPORTED),
153 height_supported=bool(features_raw & BodyCompositionFeatures.HEIGHT_SUPPORTED),
154 # Mass measurement resolution (bits 11-14)
155 mass_measurement_resolution=self._get_mass_resolution(features_raw),
156 # Height measurement resolution (bits 15-17)
157 height_measurement_resolution=self._get_height_resolution(features_raw),
158 )
160 def encode_value(self, data: BodyCompositionFeatureData) -> bytearray:
161 """Encode BodyCompositionFeatureData back to bytes.
163 Args:
164 data: BodyCompositionFeatureData instance to encode
166 Returns:
167 Encoded bytes representing the body composition features
169 """
170 # Reconstruct the features bitmap from individual flags
171 features_bitmap = 0
172 if data.timestamp_supported:
173 features_bitmap |= BodyCompositionFeatures.TIMESTAMP_SUPPORTED
174 if data.multiple_users_supported:
175 features_bitmap |= BodyCompositionFeatures.MULTIPLE_USERS_SUPPORTED
176 if data.basal_metabolism_supported:
177 features_bitmap |= BodyCompositionFeatures.BASAL_METABOLISM_SUPPORTED
178 if data.muscle_mass_supported:
179 features_bitmap |= BodyCompositionFeatures.MUSCLE_MASS_SUPPORTED
180 if data.muscle_percentage_supported:
181 features_bitmap |= BodyCompositionFeatures.MUSCLE_PERCENTAGE_SUPPORTED
182 if data.fat_free_mass_supported:
183 features_bitmap |= BodyCompositionFeatures.FAT_FREE_MASS_SUPPORTED
184 if data.soft_lean_mass_supported:
185 features_bitmap |= BodyCompositionFeatures.SOFT_LEAN_MASS_SUPPORTED
186 if data.body_water_mass_supported:
187 features_bitmap |= BodyCompositionFeatures.BODY_WATER_MASS_SUPPORTED
188 if data.impedance_supported:
189 features_bitmap |= BodyCompositionFeatures.IMPEDANCE_SUPPORTED
190 if data.weight_supported:
191 features_bitmap |= BodyCompositionFeatures.WEIGHT_SUPPORTED
192 if data.height_supported:
193 features_bitmap |= BodyCompositionFeatures.HEIGHT_SUPPORTED
195 features_bitmap = BitFieldUtils.set_bit_field(
196 features_bitmap,
197 data.mass_measurement_resolution.value,
198 BodyCompositionFeatureBits.MASS_RESOLUTION_START_BIT,
199 BodyCompositionFeatureBits.MASS_RESOLUTION_BIT_WIDTH,
200 )
201 features_bitmap = BitFieldUtils.set_bit_field(
202 features_bitmap,
203 data.height_measurement_resolution.value,
204 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_START_BIT,
205 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_BIT_WIDTH,
206 )
208 # Pack as little-endian 32-bit integer
209 return bytearray(DataParser.encode_int32(features_bitmap, signed=False))
211 def _get_mass_resolution(self, features: int) -> MassMeasurementResolution:
212 """Extract mass measurement resolution from features bitmask.
214 Args:
215 features: Raw feature bitmask
217 Returns:
218 MassMeasurementResolution enum value
220 """
221 resolution_bits = BitFieldUtils.extract_bit_field(
222 features,
223 BodyCompositionFeatureBits.MASS_RESOLUTION_START_BIT,
224 BodyCompositionFeatureBits.MASS_RESOLUTION_BIT_WIDTH,
225 ) # Bits 11-14 (4 bits)
227 try:
228 return MassMeasurementResolution(resolution_bits)
229 except ValueError:
230 # Values not in enum are reserved per Bluetooth SIG spec
231 return MassMeasurementResolution.NOT_SPECIFIED
233 def _get_height_resolution(self, features: int) -> HeightMeasurementResolution:
234 """Extract height measurement resolution from features bitmask.
236 Args:
237 features: Raw feature bitmask
239 Returns:
240 HeightMeasurementResolution enum value
242 """
243 resolution_bits = BitFieldUtils.extract_bit_field(
244 features,
245 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_START_BIT,
246 BodyCompositionFeatureBits.HEIGHT_RESOLUTION_BIT_WIDTH,
247 ) # Bits 15-17 (3 bits)
249 try:
250 return HeightMeasurementResolution(resolution_bits)
251 except ValueError:
252 # Values not in enum are reserved per Bluetooth SIG spec
253 return HeightMeasurementResolution.NOT_SPECIFIED