Coverage for src / bluetooth_sig / gatt / characteristics / weight_scale_feature.py: 88%
78 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"""Weight Scale Feature characteristic implementation."""
3from __future__ import annotations
5from enum import IntEnum, IntFlag
7import msgspec
9from ..constants import UINT32_MAX
10from ..context import CharacteristicContext
11from .base import BaseCharacteristic
12from .utils import BitFieldUtils, DataParser
15class WeightScaleBits:
16 """Weight scale bit field constants."""
18 # pylint: disable=missing-class-docstring,too-few-public-methods
20 # Weight Scale Feature bit field constants
21 WEIGHT_RESOLUTION_START_BIT = 3 # Weight measurement resolution starts at bit 3
22 WEIGHT_RESOLUTION_BIT_WIDTH = 4 # Weight measurement resolution uses 4 bits
23 HEIGHT_RESOLUTION_START_BIT = 7 # Height measurement resolution starts at bit 7
24 HEIGHT_RESOLUTION_BIT_WIDTH = 3 # Height measurement resolution uses 3 bits
27class WeightScaleFeatures(IntFlag):
28 """Weight Scale Feature flags as per Bluetooth SIG specification."""
30 TIMESTAMP_SUPPORTED = 0x01
31 MULTIPLE_USERS_SUPPORTED = 0x02
32 BMI_SUPPORTED = 0x04
35class WeightMeasurementResolution(IntEnum):
36 """Weight measurement resolution enumeration."""
38 NOT_SPECIFIED = 0
39 HALF_KG_OR_1_LB = 1
40 POINT_2_KG_OR_HALF_LB = 2
41 POINT_1_KG_OR_POINT_2_LB = 3
42 POINT_05_KG_OR_POINT_1_LB = 4
43 POINT_02_KG_OR_POINT_05_LB = 5
44 POINT_01_KG_OR_POINT_02_LB = 6
45 POINT_005_KG_OR_POINT_01_LB = 7
47 def __str__(self) -> str:
48 """Return a human-readable description of the weight measurement resolution."""
49 descriptions = {
50 0: "not_specified",
51 1: "0.5_kg_or_1_lb",
52 2: "0.2_kg_or_0.5_lb",
53 3: "0.1_kg_or_0.2_lb",
54 4: "0.05_kg_or_0.1_lb",
55 5: "0.02_kg_or_0.05_lb",
56 6: "0.01_kg_or_0.02_lb",
57 7: "0.005_kg_or_0.01_lb",
58 }
59 return descriptions.get(self.value, "Reserved for Future Use")
62class HeightMeasurementResolution(IntEnum):
63 """Height measurement resolution enumeration."""
65 NOT_SPECIFIED = 0
66 POINT_01_M_OR_1_INCH = 1
67 POINT_005_M_OR_HALF_INCH = 2
68 POINT_001_M_OR_POINT_1_INCH = 3
70 def __str__(self) -> str:
71 """Return a human-readable description of the height measurement resolution."""
72 descriptions = {
73 0: "not_specified",
74 1: "0.01_m_or_1_inch",
75 2: "0.005_m_or_0.5_inch",
76 3: "0.001_m_or_0.1_inch",
77 }
78 return descriptions.get(self.value, "Reserved for Future Use")
81class WeightScaleFeatureData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
82 """Parsed data from Weight Scale Feature characteristic."""
84 raw_value: int
85 timestamp_supported: bool
86 multiple_users_supported: bool
87 bmi_supported: bool
88 weight_measurement_resolution: WeightMeasurementResolution
89 height_measurement_resolution: HeightMeasurementResolution
91 def __post_init__(self) -> None:
92 """Validate weight scale feature data."""
93 if not 0 <= self.raw_value <= UINT32_MAX:
94 raise ValueError("Raw value must be a 32-bit unsigned integer")
97class WeightScaleFeatureCharacteristic(BaseCharacteristic[WeightScaleFeatureData]):
98 """Weight Scale Feature characteristic (0x2A9E).
100 Used to indicate which optional features are supported by the weight
101 scale. This is a read-only characteristic that describes device
102 capabilities.
103 """
105 _characteristic_name: str = "Weight Scale Feature"
107 expected_length: int = 4
108 min_length: int = 4 # Features(4) fixed length
109 max_length: int = 4 # Features(4) fixed length
110 allow_variable_length: bool = False # Fixed length
112 def _decode_value(
113 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True
114 ) -> WeightScaleFeatureData:
115 """Parse weight scale feature data according to Bluetooth specification.
117 Format: Features(4 bytes) - bitmask indicating supported features.
119 Args:
120 data: Raw bytearray from BLE characteristic.
121 ctx: Optional CharacteristicContext providing surrounding context (may be None).
122 validate: Whether to validate ranges (default True)
124 Returns:
125 WeightScaleFeatureData containing parsed feature flags.
127 Raises:
128 ValueError: If data format is invalid.
130 """
131 features_raw = DataParser.parse_int32(data, 0, signed=False)
133 # Parse feature flags according to specification
134 return WeightScaleFeatureData(
135 raw_value=features_raw,
136 timestamp_supported=bool(features_raw & WeightScaleFeatures.TIMESTAMP_SUPPORTED),
137 multiple_users_supported=bool(features_raw & WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED),
138 bmi_supported=bool(features_raw & WeightScaleFeatures.BMI_SUPPORTED),
139 weight_measurement_resolution=self._get_weight_resolution(features_raw),
140 height_measurement_resolution=self._get_height_resolution(features_raw),
141 )
143 def _encode_value(self, data: WeightScaleFeatureData) -> bytearray:
144 """Encode weight scale feature value back to bytes.
146 Args:
147 data: WeightScaleFeatureData with feature flags
149 Returns:
150 Encoded bytes representing the weight scale features (uint32)
152 """
153 # Reconstruct the features bitmap from individual flags
154 features_bitmap = 0
155 if data.timestamp_supported:
156 features_bitmap |= WeightScaleFeatures.TIMESTAMP_SUPPORTED
157 if data.multiple_users_supported:
158 features_bitmap |= WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED
159 if data.bmi_supported:
160 features_bitmap |= WeightScaleFeatures.BMI_SUPPORTED
162 # Add resolution bits using bit field utilities
163 features_bitmap = BitFieldUtils.set_bit_field(
164 features_bitmap,
165 data.weight_measurement_resolution.value,
166 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT,
167 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH,
168 )
169 features_bitmap = BitFieldUtils.set_bit_field(
170 features_bitmap,
171 data.height_measurement_resolution.value,
172 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT,
173 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH,
174 )
176 return bytearray(DataParser.encode_int32(features_bitmap, signed=False))
178 def _get_weight_resolution(self, features: int) -> WeightMeasurementResolution:
179 """Extract weight measurement resolution from features bitmask.
181 Args:
182 features: Raw feature bitmask
184 Returns:
185 WeightMeasurementResolution enum value
187 """
188 resolution_bits = BitFieldUtils.extract_bit_field(
189 features,
190 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT,
191 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH,
192 )
193 try:
194 return WeightMeasurementResolution(resolution_bits)
195 except ValueError:
196 return WeightMeasurementResolution.NOT_SPECIFIED
198 def _get_height_resolution(self, features: int) -> HeightMeasurementResolution:
199 """Extract height measurement resolution from features bitmask.
201 Args:
202 features: Raw feature bitmask
204 Returns:
205 HeightMeasurementResolution enum value
207 """
208 resolution_bits = BitFieldUtils.extract_bit_field(
209 features,
210 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT,
211 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH,
212 )
213 try:
214 return HeightMeasurementResolution(resolution_bits)
215 except ValueError:
216 return HeightMeasurementResolution.NOT_SPECIFIED