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

1"""Body Composition Feature characteristic implementation.""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum, IntFlag 

6 

7import msgspec 

8 

9from ..context import CharacteristicContext 

10from .base import BaseCharacteristic 

11from .utils import BitFieldUtils, DataParser 

12 

13 

14class BodyCompositionFeatureBits: 

15 """Body Composition Feature bit field constants.""" 

16 

17 # pylint: disable=too-few-public-methods 

18 

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 

23 

24 

25class MassMeasurementResolution(IntEnum): 

26 """Mass measurement resolution enumeration.""" 

27 

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 

36 

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") 

50 

51 

52class HeightMeasurementResolution(IntEnum): 

53 """Height measurement resolution enumeration.""" 

54 

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 

59 

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") 

69 

70 

71class BodyCompositionFeatures(IntFlag): 

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

73 

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 

85 

86 

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.""" 

89 

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 

104 

105 

106class BodyCompositionFeatureCharacteristic(BaseCharacteristic[BodyCompositionFeatureData]): 

107 """Body Composition Feature characteristic (0x2A9B). 

108 

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 """ 

113 

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 

118 

119 def _decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> BodyCompositionFeatureData: 

120 """Parse body composition feature data according to Bluetooth specification. 

121 

122 Format: Features(4 bytes) - bitmask indicating supported measurements. 

123 

124 Args: 

125 data: Raw bytearray from BLE characteristic. 

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

127 

128 Returns: 

129 BodyCompositionFeatureData containing parsed feature flags. 

130 

131 Raises: 

132 ValueError: If data format is invalid. 

133 

134 """ 

135 if len(data) < 4: 

136 raise ValueError("Body Composition Feature data must be at least 4 bytes") 

137 

138 features_raw = DataParser.parse_int32(data, 0, signed=False) 

139 

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 ) 

160 

161 def _encode_value(self, data: BodyCompositionFeatureData) -> bytearray: 

162 """Encode BodyCompositionFeatureData back to bytes. 

163 

164 Args: 

165 data: BodyCompositionFeatureData instance to encode 

166 

167 Returns: 

168 Encoded bytes representing the body composition features 

169 

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 

195 

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 ) 

208 

209 # Pack as little-endian 32-bit integer 

210 return bytearray(DataParser.encode_int32(features_bitmap, signed=False)) 

211 

212 def _get_mass_resolution(self, features: int) -> MassMeasurementResolution: 

213 """Extract mass measurement resolution from features bitmask. 

214 

215 Args: 

216 features: Raw feature bitmask 

217 

218 Returns: 

219 MassMeasurementResolution enum value 

220 

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) 

227 

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 

233 

234 def _get_height_resolution(self, features: int) -> HeightMeasurementResolution: 

235 """Extract height measurement resolution from features bitmask. 

236 

237 Args: 

238 features: Raw feature bitmask 

239 

240 Returns: 

241 HeightMeasurementResolution enum value 

242 

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) 

249 

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