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

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

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

117 

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

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

120 

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

122 

123 Args: 

124 data: Raw bytearray from BLE characteristic. 

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

126 

127 Returns: 

128 BodyCompositionFeatureData containing parsed feature flags. 

129 

130 Raises: 

131 ValueError: If data format is invalid. 

132 

133 """ 

134 if len(data) < 4: 

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

136 

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

138 

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 ) 

159 

160 def encode_value(self, data: BodyCompositionFeatureData) -> bytearray: 

161 """Encode BodyCompositionFeatureData back to bytes. 

162 

163 Args: 

164 data: BodyCompositionFeatureData instance to encode 

165 

166 Returns: 

167 Encoded bytes representing the body composition features 

168 

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 

194 

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 ) 

207 

208 # Pack as little-endian 32-bit integer 

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

210 

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

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

213 

214 Args: 

215 features: Raw feature bitmask 

216 

217 Returns: 

218 MassMeasurementResolution enum value 

219 

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) 

226 

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 

232 

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

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

235 

236 Args: 

237 features: Raw feature bitmask 

238 

239 Returns: 

240 HeightMeasurementResolution enum value 

241 

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) 

248 

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