Coverage for src / bluetooth_sig / gatt / characteristics / rower_data.py: 92%
199 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"""Rower Data characteristic implementation.
3Implements the Rower Data characteristic (0x2AD1) from the Fitness Machine
4Service. A 16-bit flags field controls the presence of optional data
5fields.
7Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the Stroke
8Rate and Stroke Count fields ARE present; when bit 0 is 1 they are absent.
9All other bits use normal logic (1 = present).
11References:
12 Bluetooth SIG Fitness Machine Service 1.0
13 org.bluetooth.characteristic.rower_data (GSS YAML)
14"""
16from __future__ import annotations
18from enum import IntFlag
20import msgspec
22from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX, UINT16_MAX, UINT24_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
40# Stroke rate: M=1, d=0, b=-1 -> actual = raw / 2
41_STROKE_RATE_DIVISOR = 2.0
43# Resistance level: M=1, d=1, b=0 -> actual = raw * 10
44_RESISTANCE_RESOLUTION = 10.0
47class RowerDataFlags(IntFlag):
48 """Rower Data flags as per Bluetooth SIG specification.
50 Bit 0 uses inverted logic: 0 = Stroke Rate + Stroke Count present,
51 1 = absent.
52 """
54 MORE_DATA = 0x0001 # Inverted: 0 -> fields present, 1 -> absent
55 AVERAGE_STROKE_RATE_PRESENT = 0x0002
56 TOTAL_DISTANCE_PRESENT = 0x0004
57 INSTANTANEOUS_PACE_PRESENT = 0x0008
58 AVERAGE_PACE_PRESENT = 0x0010
59 INSTANTANEOUS_POWER_PRESENT = 0x0020
60 AVERAGE_POWER_PRESENT = 0x0040
61 RESISTANCE_LEVEL_PRESENT = 0x0080
62 EXPENDED_ENERGY_PRESENT = 0x0100
63 HEART_RATE_PRESENT = 0x0200
64 METABOLIC_EQUIVALENT_PRESENT = 0x0400
65 ELAPSED_TIME_PRESENT = 0x0800
66 REMAINING_TIME_PRESENT = 0x1000
69class RowerData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
70 """Parsed data from Rower Data characteristic.
72 Attributes:
73 flags: Raw 16-bit flags field.
74 stroke_rate: Instantaneous stroke rate in strokes/min (0.5 resolution).
75 stroke_count: Total strokes since session start.
76 average_stroke_rate: Average stroke rate in strokes/min (0.5 resolution).
77 total_distance: Total distance in metres (uint24).
78 instantaneous_pace: Instantaneous pace in seconds per 500 m.
79 average_pace: Average pace in seconds per 500 m.
80 instantaneous_power: Instantaneous power in watts (signed).
81 average_power: Average power in watts (signed).
82 resistance_level: Resistance level (unitless, resolution 10).
83 total_energy: Total expended energy in kcal.
84 energy_per_hour: Expended energy per hour in kcal.
85 energy_per_minute: Expended energy per minute in kcal.
86 heart_rate: Heart rate in bpm.
87 metabolic_equivalent: MET value (0.1 resolution).
88 elapsed_time: Elapsed time in seconds.
89 remaining_time: Remaining time in seconds.
91 """
93 flags: RowerDataFlags
94 stroke_rate: float | None = None
95 stroke_count: int | None = None
96 average_stroke_rate: float | None = None
97 total_distance: int | None = None
98 instantaneous_pace: int | None = None
99 average_pace: int | None = None
100 instantaneous_power: int | None = None
101 average_power: int | None = None
102 resistance_level: float | None = None
103 total_energy: int | None = None
104 energy_per_hour: int | None = None
105 energy_per_minute: int | None = None
106 heart_rate: int | None = None
107 metabolic_equivalent: float | None = None
108 elapsed_time: int | None = None
109 remaining_time: int | None = None
111 def __post_init__(self) -> None:
112 """Validate field ranges."""
113 if self.stroke_rate is not None and not 0.0 <= self.stroke_rate <= UINT8_MAX / _STROKE_RATE_DIVISOR:
114 raise ValueError(f"Stroke rate must be 0.0-{UINT8_MAX / _STROKE_RATE_DIVISOR}, got {self.stroke_rate}")
115 if self.stroke_count is not None and not 0 <= self.stroke_count <= UINT16_MAX:
116 raise ValueError(f"Stroke count must be 0-{UINT16_MAX}, got {self.stroke_count}")
117 if (
118 self.average_stroke_rate is not None
119 and not 0.0 <= self.average_stroke_rate <= UINT8_MAX / _STROKE_RATE_DIVISOR
120 ):
121 raise ValueError(
122 f"Average stroke rate must be 0.0-{UINT8_MAX / _STROKE_RATE_DIVISOR}, got {self.average_stroke_rate}"
123 )
124 if self.total_distance is not None and not 0 <= self.total_distance <= UINT24_MAX:
125 raise ValueError(f"Total distance must be 0-{UINT24_MAX}, got {self.total_distance}")
126 if self.instantaneous_pace is not None and not 0 <= self.instantaneous_pace <= UINT16_MAX:
127 raise ValueError(f"Instantaneous pace must be 0-{UINT16_MAX}, got {self.instantaneous_pace}")
128 if self.average_pace is not None and not 0 <= self.average_pace <= UINT16_MAX:
129 raise ValueError(f"Average pace must be 0-{UINT16_MAX}, got {self.average_pace}")
130 if self.instantaneous_power is not None and not SINT16_MIN <= self.instantaneous_power <= SINT16_MAX:
131 raise ValueError(f"Instantaneous power must be {SINT16_MIN}-{SINT16_MAX}, got {self.instantaneous_power}")
132 if self.average_power is not None and not SINT16_MIN <= self.average_power <= SINT16_MAX:
133 raise ValueError(f"Average power must be {SINT16_MIN}-{SINT16_MAX}, got {self.average_power}")
134 if self.resistance_level is not None and not 0.0 <= self.resistance_level <= UINT8_MAX * _RESISTANCE_RESOLUTION:
135 raise ValueError(
136 f"Resistance level must be 0.0-{UINT8_MAX * _RESISTANCE_RESOLUTION}, got {self.resistance_level}"
137 )
138 if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX:
139 raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}")
140 if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX:
141 raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}")
142 if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX:
143 raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}")
144 if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX:
145 raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}")
146 if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION:
147 raise ValueError(
148 f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}"
149 )
150 if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX:
151 raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}")
152 if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX:
153 raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}")
156class RowerDataCharacteristic(BaseCharacteristic[RowerData]):
157 """Rower Data characteristic (0x2AD1).
159 Used in the Fitness Machine Service to transmit rowing workout data.
160 A 16-bit flags field controls which optional fields are present.
162 Flag-bit assignments (from GSS YAML):
163 Bit 0: More Data -- **inverted**: 0 -> Stroke Rate + Count present
164 Bit 1: Average Stroke Rate present
165 Bit 2: Total Distance present
166 Bit 3: Instantaneous Pace present
167 Bit 4: Average Pace present
168 Bit 5: Instantaneous Power present
169 Bit 6: Average Power present
170 Bit 7: Resistance Level present
171 Bit 8: Expended Energy present (gates triplet: total + /hr + /min)
172 Bit 9: Heart Rate present
173 Bit 10: Metabolic Equivalent present
174 Bit 11: Elapsed Time present
175 Bit 12: Remaining Time present
176 Bits 13-15: Reserved for Future Use
178 """
180 expected_type = RowerData
181 min_length: int = 2 # Flags only (all optional fields absent)
182 allow_variable_length: bool = True
184 def _decode_value(
185 self,
186 data: bytearray,
187 ctx: CharacteristicContext | None = None,
188 *,
189 validate: bool = True,
190 ) -> RowerData:
191 """Parse Rower Data from raw BLE bytes.
193 Args:
194 data: Raw bytearray from BLE characteristic.
195 ctx: Optional context (unused).
196 validate: Whether to validate ranges.
198 Returns:
199 RowerData with all present fields populated.
201 """
202 flags = RowerDataFlags(DataParser.parse_int16(data, 0, signed=False))
203 offset = 2
205 # Bit 0 -- inverted: Stroke Rate + Stroke Count present when bit is NOT set
206 stroke_rate = None
207 stroke_count = None
208 if not (flags & RowerDataFlags.MORE_DATA) and len(data) >= offset + 3:
209 raw_stroke_rate = DataParser.parse_int8(data, offset, signed=False)
210 stroke_rate = raw_stroke_rate / _STROKE_RATE_DIVISOR
211 offset += 1
212 stroke_count = DataParser.parse_int16(data, offset, signed=False)
213 offset += 2
215 # Bit 1 -- Average Stroke Rate
216 average_stroke_rate = None
217 if (flags & RowerDataFlags.AVERAGE_STROKE_RATE_PRESENT) and len(data) >= offset + 1:
218 raw_avg = DataParser.parse_int8(data, offset, signed=False)
219 average_stroke_rate = raw_avg / _STROKE_RATE_DIVISOR
220 offset += 1
222 # Bit 2 -- Total Distance (uint24)
223 total_distance = None
224 if (flags & RowerDataFlags.TOTAL_DISTANCE_PRESENT) and len(data) >= offset + 3:
225 total_distance = DataParser.parse_int24(data, offset, signed=False)
226 offset += 3
228 # Bit 3 -- Instantaneous Pace
229 instantaneous_pace = None
230 if (flags & RowerDataFlags.INSTANTANEOUS_PACE_PRESENT) and len(data) >= offset + 2:
231 instantaneous_pace = DataParser.parse_int16(data, offset, signed=False)
232 offset += 2
234 # Bit 4 -- Average Pace
235 average_pace = None
236 if (flags & RowerDataFlags.AVERAGE_PACE_PRESENT) and len(data) >= offset + 2:
237 average_pace = DataParser.parse_int16(data, offset, signed=False)
238 offset += 2
240 # Bit 5 -- Instantaneous Power (sint16)
241 instantaneous_power = None
242 if (flags & RowerDataFlags.INSTANTANEOUS_POWER_PRESENT) and len(data) >= offset + 2:
243 instantaneous_power = DataParser.parse_int16(data, offset, signed=True)
244 offset += 2
246 # Bit 6 -- Average Power (sint16)
247 average_power = None
248 if (flags & RowerDataFlags.AVERAGE_POWER_PRESENT) and len(data) >= offset + 2:
249 average_power = DataParser.parse_int16(data, offset, signed=True)
250 offset += 2
252 # Bit 7 -- Resistance Level
253 resistance_level = None
254 if (flags & RowerDataFlags.RESISTANCE_LEVEL_PRESENT) and len(data) >= offset + 1:
255 raw_resistance = DataParser.parse_int8(data, offset, signed=False)
256 resistance_level = raw_resistance * _RESISTANCE_RESOLUTION
257 offset += 1
259 # Bit 8 -- Energy triplet (Total + Per Hour + Per Minute)
260 total_energy = None
261 energy_per_hour = None
262 energy_per_minute = None
263 if flags & RowerDataFlags.EXPENDED_ENERGY_PRESENT:
264 total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset)
266 # Bit 9 -- Heart Rate
267 heart_rate = None
268 if flags & RowerDataFlags.HEART_RATE_PRESENT:
269 heart_rate, offset = decode_heart_rate(data, offset)
271 # Bit 10 -- Metabolic Equivalent
272 metabolic_equivalent = None
273 if flags & RowerDataFlags.METABOLIC_EQUIVALENT_PRESENT:
274 metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset)
276 # Bit 11 -- Elapsed Time
277 elapsed_time = None
278 if flags & RowerDataFlags.ELAPSED_TIME_PRESENT:
279 elapsed_time, offset = decode_elapsed_time(data, offset)
281 # Bit 12 -- Remaining Time
282 remaining_time = None
283 if flags & RowerDataFlags.REMAINING_TIME_PRESENT:
284 remaining_time, offset = decode_remaining_time(data, offset)
286 return RowerData(
287 flags=flags,
288 stroke_rate=stroke_rate,
289 stroke_count=stroke_count,
290 average_stroke_rate=average_stroke_rate,
291 total_distance=total_distance,
292 instantaneous_pace=instantaneous_pace,
293 average_pace=average_pace,
294 instantaneous_power=instantaneous_power,
295 average_power=average_power,
296 resistance_level=resistance_level,
297 total_energy=total_energy,
298 energy_per_hour=energy_per_hour,
299 energy_per_minute=energy_per_minute,
300 heart_rate=heart_rate,
301 metabolic_equivalent=metabolic_equivalent,
302 elapsed_time=elapsed_time,
303 remaining_time=remaining_time,
304 )
306 def _encode_value(self, data: RowerData) -> bytearray: # noqa: PLR0912
307 """Encode RowerData back to BLE bytes.
309 Reconstructs flags from present fields so round-trip encoding
310 preserves the original wire format.
312 Args:
313 data: RowerData instance.
315 Returns:
316 Encoded bytearray matching the BLE wire format.
318 """
319 flags = RowerDataFlags(0)
321 # Bit 0 -- inverted: set MORE_DATA when Stroke Rate/Count absent
322 if data.stroke_rate is None:
323 flags |= RowerDataFlags.MORE_DATA
324 if data.average_stroke_rate is not None:
325 flags |= RowerDataFlags.AVERAGE_STROKE_RATE_PRESENT
326 if data.total_distance is not None:
327 flags |= RowerDataFlags.TOTAL_DISTANCE_PRESENT
328 if data.instantaneous_pace is not None:
329 flags |= RowerDataFlags.INSTANTANEOUS_PACE_PRESENT
330 if data.average_pace is not None:
331 flags |= RowerDataFlags.AVERAGE_PACE_PRESENT
332 if data.instantaneous_power is not None:
333 flags |= RowerDataFlags.INSTANTANEOUS_POWER_PRESENT
334 if data.average_power is not None:
335 flags |= RowerDataFlags.AVERAGE_POWER_PRESENT
336 if data.resistance_level is not None:
337 flags |= RowerDataFlags.RESISTANCE_LEVEL_PRESENT
338 if data.total_energy is not None:
339 flags |= RowerDataFlags.EXPENDED_ENERGY_PRESENT
340 if data.heart_rate is not None:
341 flags |= RowerDataFlags.HEART_RATE_PRESENT
342 if data.metabolic_equivalent is not None:
343 flags |= RowerDataFlags.METABOLIC_EQUIVALENT_PRESENT
344 if data.elapsed_time is not None:
345 flags |= RowerDataFlags.ELAPSED_TIME_PRESENT
346 if data.remaining_time is not None:
347 flags |= RowerDataFlags.REMAINING_TIME_PRESENT
349 result = DataParser.encode_int16(int(flags), signed=False)
351 if data.stroke_rate is not None:
352 raw_stroke = round(data.stroke_rate * _STROKE_RATE_DIVISOR)
353 result.extend(DataParser.encode_int8(raw_stroke, signed=False))
354 if data.stroke_count is not None:
355 result.extend(DataParser.encode_int16(data.stroke_count, signed=False))
356 if data.average_stroke_rate is not None:
357 raw_avg = round(data.average_stroke_rate * _STROKE_RATE_DIVISOR)
358 result.extend(DataParser.encode_int8(raw_avg, signed=False))
359 if data.total_distance is not None:
360 result.extend(DataParser.encode_int24(data.total_distance, signed=False))
361 if data.instantaneous_pace is not None:
362 result.extend(DataParser.encode_int16(data.instantaneous_pace, signed=False))
363 if data.average_pace is not None:
364 result.extend(DataParser.encode_int16(data.average_pace, signed=False))
365 if data.instantaneous_power is not None:
366 result.extend(DataParser.encode_int16(data.instantaneous_power, signed=True))
367 if data.average_power is not None:
368 result.extend(DataParser.encode_int16(data.average_power, signed=True))
369 if data.resistance_level is not None:
370 raw_resistance = round(data.resistance_level / _RESISTANCE_RESOLUTION)
371 result.extend(DataParser.encode_int8(raw_resistance, signed=False))
372 if data.total_energy is not None:
373 result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute))
374 if data.heart_rate is not None:
375 result.extend(encode_heart_rate(data.heart_rate))
376 if data.metabolic_equivalent is not None:
377 result.extend(encode_metabolic_equivalent(data.metabolic_equivalent))
378 if data.elapsed_time is not None:
379 result.extend(encode_elapsed_time(data.elapsed_time))
380 if data.remaining_time is not None:
381 result.extend(encode_remaining_time(data.remaining_time))
383 return result