Coverage for src/bluetooth_sig/gatt/characteristics/weight_scale_feature.py: 94%
78 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"""Weight Scale 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 WeightScaleBits:
15 """Weight scale bit field constants."""
17 # pylint: disable=missing-class-docstring,too-few-public-methods
19 # Weight Scale Feature bit field constants
20 WEIGHT_RESOLUTION_START_BIT = 3 # Weight measurement resolution starts at bit 3
21 WEIGHT_RESOLUTION_BIT_WIDTH = 4 # Weight measurement resolution uses 4 bits
22 HEIGHT_RESOLUTION_START_BIT = 7 # Height measurement resolution starts at bit 7
23 HEIGHT_RESOLUTION_BIT_WIDTH = 3 # Height measurement resolution uses 3 bits
26class WeightScaleFeatures(IntFlag):
27 """Weight Scale Feature flags as per Bluetooth SIG specification."""
29 TIMESTAMP_SUPPORTED = 0x01
30 MULTIPLE_USERS_SUPPORTED = 0x02
31 BMI_SUPPORTED = 0x04
34class WeightMeasurementResolution(IntEnum):
35 """Weight measurement resolution enumeration."""
37 NOT_SPECIFIED = 0
38 HALF_KG_OR_1_LB = 1
39 POINT_2_KG_OR_HALF_LB = 2
40 POINT_1_KG_OR_POINT_2_LB = 3
41 POINT_05_KG_OR_POINT_1_LB = 4
42 POINT_02_KG_OR_POINT_05_LB = 5
43 POINT_01_KG_OR_POINT_02_LB = 6
44 POINT_005_KG_OR_POINT_01_LB = 7
46 def __str__(self) -> str:
47 """Return a human-readable description of the weight measurement resolution."""
48 descriptions = {
49 0: "not_specified",
50 1: "0.5_kg_or_1_lb",
51 2: "0.2_kg_or_0.5_lb",
52 3: "0.1_kg_or_0.2_lb",
53 4: "0.05_kg_or_0.1_lb",
54 5: "0.02_kg_or_0.05_lb",
55 6: "0.01_kg_or_0.02_lb",
56 7: "0.005_kg_or_0.01_lb",
57 }
58 return descriptions.get(self.value, "Reserved for Future Use")
61class HeightMeasurementResolution(IntEnum):
62 """Height measurement resolution enumeration."""
64 NOT_SPECIFIED = 0
65 POINT_01_M_OR_1_INCH = 1
66 POINT_005_M_OR_HALF_INCH = 2
67 POINT_001_M_OR_POINT_1_INCH = 3
69 def __str__(self) -> str:
70 """Return a human-readable description of the height measurement resolution."""
71 descriptions = {
72 0: "not_specified",
73 1: "0.01_m_or_1_inch",
74 2: "0.005_m_or_0.5_inch",
75 3: "0.001_m_or_0.1_inch",
76 }
77 return descriptions.get(self.value, "Reserved for Future Use")
80class WeightScaleFeatureData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods
81 """Parsed data from Weight Scale Feature characteristic."""
83 raw_value: int
84 timestamp_supported: bool
85 multiple_users_supported: bool
86 bmi_supported: bool
87 weight_measurement_resolution: WeightMeasurementResolution
88 height_measurement_resolution: HeightMeasurementResolution
90 def __post_init__(self) -> None:
91 """Validate weight scale feature data."""
92 if not 0 <= self.raw_value <= 0xFFFFFFFF:
93 raise ValueError("Raw value must be a 32-bit unsigned integer")
96class WeightScaleFeatureCharacteristic(BaseCharacteristic):
97 """Weight Scale Feature characteristic (0x2A9E).
99 Used to indicate which optional features are supported by the weight
100 scale. This is a read-only characteristic that describes device
101 capabilities.
102 """
104 _characteristic_name: str = "Weight Scale Feature"
106 min_length: int = 4 # Features(4) fixed length
107 max_length: int = 4 # Features(4) fixed length
108 allow_variable_length: bool = False # Fixed length
110 def decode_value(self, data: bytearray, ctx: CharacteristicContext | None = None) -> WeightScaleFeatureData:
111 """Parse weight scale feature data according to Bluetooth specification.
113 Format: Features(4 bytes) - bitmask indicating supported features.
115 Args:
116 data: Raw bytearray from BLE characteristic.
117 ctx: Optional CharacteristicContext providing surrounding context (may be None).
119 Returns:
120 WeightScaleFeatureData containing parsed feature flags.
122 Raises:
123 ValueError: If data format is invalid.
125 """
126 if len(data) < 4:
127 raise ValueError("Weight Scale Feature data must be at least 4 bytes")
129 features_raw = DataParser.parse_int32(data, 0, signed=False)
131 # Parse feature flags according to specification
132 return WeightScaleFeatureData(
133 raw_value=features_raw,
134 timestamp_supported=bool(features_raw & WeightScaleFeatures.TIMESTAMP_SUPPORTED),
135 multiple_users_supported=bool(features_raw & WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED),
136 bmi_supported=bool(features_raw & WeightScaleFeatures.BMI_SUPPORTED),
137 weight_measurement_resolution=self._get_weight_resolution(features_raw),
138 height_measurement_resolution=self._get_height_resolution(features_raw),
139 )
141 def encode_value(self, data: WeightScaleFeatureData) -> bytearray:
142 """Encode weight scale feature value back to bytes.
144 Args:
145 data: WeightScaleFeatureData with feature flags
147 Returns:
148 Encoded bytes representing the weight scale features (uint32)
150 """
151 # Reconstruct the features bitmap from individual flags
152 features_bitmap = 0
153 if data.timestamp_supported:
154 features_bitmap |= WeightScaleFeatures.TIMESTAMP_SUPPORTED
155 if data.multiple_users_supported:
156 features_bitmap |= WeightScaleFeatures.MULTIPLE_USERS_SUPPORTED
157 if data.bmi_supported:
158 features_bitmap |= WeightScaleFeatures.BMI_SUPPORTED
160 # Add resolution bits using bit field utilities
161 features_bitmap = BitFieldUtils.set_bit_field(
162 features_bitmap,
163 data.weight_measurement_resolution.value,
164 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT,
165 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH,
166 )
167 features_bitmap = BitFieldUtils.set_bit_field(
168 features_bitmap,
169 data.height_measurement_resolution.value,
170 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT,
171 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH,
172 )
174 return bytearray(DataParser.encode_int32(features_bitmap, signed=False))
176 def _get_weight_resolution(self, features: int) -> WeightMeasurementResolution:
177 """Extract weight measurement resolution from features bitmask.
179 Args:
180 features: Raw feature bitmask
182 Returns:
183 WeightMeasurementResolution enum value
185 """
186 resolution_bits = BitFieldUtils.extract_bit_field(
187 features,
188 WeightScaleBits.WEIGHT_RESOLUTION_START_BIT,
189 WeightScaleBits.WEIGHT_RESOLUTION_BIT_WIDTH,
190 )
191 try:
192 return WeightMeasurementResolution(resolution_bits)
193 except ValueError:
194 return WeightMeasurementResolution.NOT_SPECIFIED
196 def _get_height_resolution(self, features: int) -> HeightMeasurementResolution:
197 """Extract height measurement resolution from features bitmask.
199 Args:
200 features: Raw feature bitmask
202 Returns:
203 HeightMeasurementResolution enum value
205 """
206 resolution_bits = BitFieldUtils.extract_bit_field(
207 features,
208 WeightScaleBits.HEIGHT_RESOLUTION_START_BIT,
209 WeightScaleBits.HEIGHT_RESOLUTION_BIT_WIDTH,
210 )
211 try:
212 return HeightMeasurementResolution(resolution_bits)
213 except ValueError:
214 return HeightMeasurementResolution.NOT_SPECIFIED