Coverage for src / bluetooth_sig / gatt / characteristics / stair_climber_data.py: 92%
147 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"""Stair Climber Data characteristic implementation.
3Implements the Stair Climber Data characteristic (0x2AD0) from the Fitness
4Machine Service. A 16-bit flags field controls the presence of optional
5data fields.
7Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the Floors
8field IS present; when bit 0 is 1 it is absent. All other bits use normal
9logic (1 = present).
11References:
12 Bluetooth SIG Fitness Machine Service 1.0
13 org.bluetooth.characteristic.stair_climber_data (GSS YAML)
14"""
16from __future__ import annotations
18from enum import IntFlag
20import msgspec
22from ..constants import UINT8_MAX, UINT16_MAX
23from ..context import CharacteristicContext
24from .base import BaseCharacteristic
25from .fitness_machine_common import (
26 MET_RESOLUTION,
27 decode_elapsed_time,
28 decode_energy_triplet,
29 decode_heart_rate,
30 decode_metabolic_equivalent,
31 decode_remaining_time,
32 encode_elapsed_time,
33 encode_energy_triplet,
34 encode_heart_rate,
35 encode_metabolic_equivalent,
36 encode_remaining_time,
37)
38from .utils import DataParser
41class StairClimberDataFlags(IntFlag):
42 """Stair Climber Data flags as per Bluetooth SIG specification.
44 Bit 0 uses inverted logic: 0 = Floors present, 1 = Floors absent.
45 """
47 MORE_DATA = 0x0001 # Inverted: 0 -> Floors present, 1 -> absent
48 STEPS_PER_MINUTE_PRESENT = 0x0002
49 AVERAGE_STEP_RATE_PRESENT = 0x0004
50 POSITIVE_ELEVATION_GAIN_PRESENT = 0x0008
51 STRIDE_COUNT_PRESENT = 0x0010
52 EXPENDED_ENERGY_PRESENT = 0x0020
53 HEART_RATE_PRESENT = 0x0040
54 METABOLIC_EQUIVALENT_PRESENT = 0x0080
55 ELAPSED_TIME_PRESENT = 0x0100
56 REMAINING_TIME_PRESENT = 0x0200
59class StairClimberData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
60 """Parsed data from Stair Climber Data characteristic.
62 Attributes:
63 flags: Raw 16-bit flags field.
64 floors: Total floors counted (present when bit 0 is 0).
65 steps_per_minute: Step rate in steps/min.
66 average_step_rate: Average step rate in steps/min.
67 positive_elevation_gain: Positive elevation gain in metres.
68 stride_count: Total strides since session start.
69 total_energy: Total expended energy in kcal.
70 energy_per_hour: Expended energy per hour in kcal.
71 energy_per_minute: Expended energy per minute in kcal.
72 heart_rate: Heart rate in bpm.
73 metabolic_equivalent: MET value (0.1 resolution).
74 elapsed_time: Elapsed time in seconds.
75 remaining_time: Remaining time in seconds.
77 """
79 flags: StairClimberDataFlags
80 floors: int | None = None
81 steps_per_minute: int | None = None
82 average_step_rate: int | None = None
83 positive_elevation_gain: int | None = None
84 stride_count: int | None = None
85 total_energy: int | None = None
86 energy_per_hour: int | None = None
87 energy_per_minute: int | None = None
88 heart_rate: int | None = None
89 metabolic_equivalent: float | None = None
90 elapsed_time: int | None = None
91 remaining_time: int | None = None
93 def __post_init__(self) -> None:
94 """Validate field ranges."""
95 if self.floors is not None and not 0 <= self.floors <= UINT16_MAX:
96 raise ValueError(f"Floors must be 0-{UINT16_MAX}, got {self.floors}")
97 if self.steps_per_minute is not None and not 0 <= self.steps_per_minute <= UINT16_MAX:
98 raise ValueError(f"Steps per minute must be 0-{UINT16_MAX}, got {self.steps_per_minute}")
99 if self.average_step_rate is not None and not 0 <= self.average_step_rate <= UINT16_MAX:
100 raise ValueError(f"Average step rate must be 0-{UINT16_MAX}, got {self.average_step_rate}")
101 if self.positive_elevation_gain is not None and not 0 <= self.positive_elevation_gain <= UINT16_MAX:
102 raise ValueError(f"Positive elevation gain must be 0-{UINT16_MAX}, got {self.positive_elevation_gain}")
103 if self.stride_count is not None and not 0 <= self.stride_count <= UINT16_MAX:
104 raise ValueError(f"Stride count must be 0-{UINT16_MAX}, got {self.stride_count}")
105 if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX:
106 raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}")
107 if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX:
108 raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}")
109 if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX:
110 raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}")
111 if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX:
112 raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}")
113 if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION:
114 raise ValueError(
115 f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}"
116 )
117 if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX:
118 raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}")
119 if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX:
120 raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}")
123class StairClimberDataCharacteristic(BaseCharacteristic[StairClimberData]):
124 """Stair Climber Data characteristic (0x2AD0).
126 Used in the Fitness Machine Service to transmit stair climber workout
127 data. A 16-bit flags field controls which optional fields are present.
129 Flag-bit assignments (from GSS YAML):
130 Bit 0: More Data -- **inverted**: 0 -> Floors present, 1 -> absent
131 Bit 1: Steps Per Minute present
132 Bit 2: Average Step Rate present
133 Bit 3: Positive Elevation Gain present
134 Bit 4: Stride Count present
135 Bit 5: Expended Energy present (gates triplet: total + /hr + /min)
136 Bit 6: Heart Rate present
137 Bit 7: Metabolic Equivalent present
138 Bit 8: Elapsed Time present
139 Bit 9: Remaining Time present
140 Bits 10-15: Reserved for Future Use
142 """
144 expected_type = StairClimberData
145 min_length: int = 2 # Flags only (all optional fields absent)
146 allow_variable_length: bool = True
148 def _decode_value(
149 self,
150 data: bytearray,
151 ctx: CharacteristicContext | None = None,
152 *,
153 validate: bool = True,
154 ) -> StairClimberData:
155 """Parse Stair Climber Data from raw BLE bytes.
157 Args:
158 data: Raw bytearray from BLE characteristic.
159 ctx: Optional context (unused).
160 validate: Whether to validate ranges.
162 Returns:
163 StairClimberData with all present fields populated.
165 """
166 flags = StairClimberDataFlags(DataParser.parse_int16(data, 0, signed=False))
167 offset = 2
169 # Bit 0 -- inverted: Floors present when bit is NOT set
170 floors = None
171 if not (flags & StairClimberDataFlags.MORE_DATA) and len(data) >= offset + 2:
172 floors = DataParser.parse_int16(data, offset, signed=False)
173 offset += 2
175 # Bit 1 -- Steps Per Minute
176 steps_per_minute = None
177 if (flags & StairClimberDataFlags.STEPS_PER_MINUTE_PRESENT) and len(data) >= offset + 2:
178 steps_per_minute = DataParser.parse_int16(data, offset, signed=False)
179 offset += 2
181 # Bit 2 -- Average Step Rate
182 average_step_rate = None
183 if (flags & StairClimberDataFlags.AVERAGE_STEP_RATE_PRESENT) and len(data) >= offset + 2:
184 average_step_rate = DataParser.parse_int16(data, offset, signed=False)
185 offset += 2
187 # Bit 3 -- Positive Elevation Gain
188 positive_elevation_gain = None
189 if (flags & StairClimberDataFlags.POSITIVE_ELEVATION_GAIN_PRESENT) and len(data) >= offset + 2:
190 positive_elevation_gain = DataParser.parse_int16(data, offset, signed=False)
191 offset += 2
193 # Bit 4 -- Stride Count
194 stride_count = None
195 if (flags & StairClimberDataFlags.STRIDE_COUNT_PRESENT) and len(data) >= offset + 2:
196 stride_count = DataParser.parse_int16(data, offset, signed=False)
197 offset += 2
199 # Bit 5 -- Energy triplet (Total + Per Hour + Per Minute)
200 total_energy = None
201 energy_per_hour = None
202 energy_per_minute = None
203 if flags & StairClimberDataFlags.EXPENDED_ENERGY_PRESENT:
204 total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset)
206 # Bit 6 -- Heart Rate
207 heart_rate = None
208 if flags & StairClimberDataFlags.HEART_RATE_PRESENT:
209 heart_rate, offset = decode_heart_rate(data, offset)
211 # Bit 7 -- Metabolic Equivalent
212 metabolic_equivalent = None
213 if flags & StairClimberDataFlags.METABOLIC_EQUIVALENT_PRESENT:
214 metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset)
216 # Bit 8 -- Elapsed Time
217 elapsed_time = None
218 if flags & StairClimberDataFlags.ELAPSED_TIME_PRESENT:
219 elapsed_time, offset = decode_elapsed_time(data, offset)
221 # Bit 9 -- Remaining Time
222 remaining_time = None
223 if flags & StairClimberDataFlags.REMAINING_TIME_PRESENT:
224 remaining_time, offset = decode_remaining_time(data, offset)
226 return StairClimberData(
227 flags=flags,
228 floors=floors,
229 steps_per_minute=steps_per_minute,
230 average_step_rate=average_step_rate,
231 positive_elevation_gain=positive_elevation_gain,
232 stride_count=stride_count,
233 total_energy=total_energy,
234 energy_per_hour=energy_per_hour,
235 energy_per_minute=energy_per_minute,
236 heart_rate=heart_rate,
237 metabolic_equivalent=metabolic_equivalent,
238 elapsed_time=elapsed_time,
239 remaining_time=remaining_time,
240 )
242 def _encode_value(self, data: StairClimberData) -> bytearray:
243 """Encode StairClimberData back to BLE bytes.
245 Reconstructs flags from present fields so round-trip encoding
246 preserves the original wire format.
248 Args:
249 data: StairClimberData instance.
251 Returns:
252 Encoded bytearray matching the BLE wire format.
254 """
255 flags = StairClimberDataFlags(0)
257 # Bit 0 -- inverted: set MORE_DATA when Floors is absent
258 if data.floors is None:
259 flags |= StairClimberDataFlags.MORE_DATA
260 if data.steps_per_minute is not None:
261 flags |= StairClimberDataFlags.STEPS_PER_MINUTE_PRESENT
262 if data.average_step_rate is not None:
263 flags |= StairClimberDataFlags.AVERAGE_STEP_RATE_PRESENT
264 if data.positive_elevation_gain is not None:
265 flags |= StairClimberDataFlags.POSITIVE_ELEVATION_GAIN_PRESENT
266 if data.stride_count is not None:
267 flags |= StairClimberDataFlags.STRIDE_COUNT_PRESENT
268 if data.total_energy is not None:
269 flags |= StairClimberDataFlags.EXPENDED_ENERGY_PRESENT
270 if data.heart_rate is not None:
271 flags |= StairClimberDataFlags.HEART_RATE_PRESENT
272 if data.metabolic_equivalent is not None:
273 flags |= StairClimberDataFlags.METABOLIC_EQUIVALENT_PRESENT
274 if data.elapsed_time is not None:
275 flags |= StairClimberDataFlags.ELAPSED_TIME_PRESENT
276 if data.remaining_time is not None:
277 flags |= StairClimberDataFlags.REMAINING_TIME_PRESENT
279 result = DataParser.encode_int16(int(flags), signed=False)
281 if data.floors is not None:
282 result.extend(DataParser.encode_int16(data.floors, signed=False))
283 if data.steps_per_minute is not None:
284 result.extend(DataParser.encode_int16(data.steps_per_minute, signed=False))
285 if data.average_step_rate is not None:
286 result.extend(DataParser.encode_int16(data.average_step_rate, signed=False))
287 if data.positive_elevation_gain is not None:
288 result.extend(DataParser.encode_int16(data.positive_elevation_gain, signed=False))
289 if data.stride_count is not None:
290 result.extend(DataParser.encode_int16(data.stride_count, signed=False))
291 if data.total_energy is not None:
292 result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute))
293 if data.heart_rate is not None:
294 result.extend(encode_heart_rate(data.heart_rate))
295 if data.metabolic_equivalent is not None:
296 result.extend(encode_metabolic_equivalent(data.metabolic_equivalent))
297 if data.elapsed_time is not None:
298 result.extend(encode_elapsed_time(data.elapsed_time))
299 if data.remaining_time is not None:
300 result.extend(encode_remaining_time(data.remaining_time))
302 return result