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

1"""Rower Data characteristic implementation. 

2 

3Implements the Rower Data characteristic (0x2AD1) from the Fitness Machine 

4Service. A 16-bit flags field controls the presence of optional data 

5fields. 

6 

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

10 

11References: 

12 Bluetooth SIG Fitness Machine Service 1.0 

13 org.bluetooth.characteristic.rower_data (GSS YAML) 

14""" 

15 

16from __future__ import annotations 

17 

18from enum import IntFlag 

19 

20import msgspec 

21 

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 

39 

40# Stroke rate: M=1, d=0, b=-1 -> actual = raw / 2 

41_STROKE_RATE_DIVISOR = 2.0 

42 

43# Resistance level: M=1, d=1, b=0 -> actual = raw * 10 

44_RESISTANCE_RESOLUTION = 10.0 

45 

46 

47class RowerDataFlags(IntFlag): 

48 """Rower Data flags as per Bluetooth SIG specification. 

49 

50 Bit 0 uses inverted logic: 0 = Stroke Rate + Stroke Count present, 

51 1 = absent. 

52 """ 

53 

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 

67 

68 

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. 

71 

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. 

90 

91 """ 

92 

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 

110 

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}") 

154 

155 

156class RowerDataCharacteristic(BaseCharacteristic[RowerData]): 

157 """Rower Data characteristic (0x2AD1). 

158 

159 Used in the Fitness Machine Service to transmit rowing workout data. 

160 A 16-bit flags field controls which optional fields are present. 

161 

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 

177 

178 """ 

179 

180 expected_type = RowerData 

181 min_length: int = 2 # Flags only (all optional fields absent) 

182 allow_variable_length: bool = True 

183 

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. 

192 

193 Args: 

194 data: Raw bytearray from BLE characteristic. 

195 ctx: Optional context (unused). 

196 validate: Whether to validate ranges. 

197 

198 Returns: 

199 RowerData with all present fields populated. 

200 

201 """ 

202 flags = RowerDataFlags(DataParser.parse_int16(data, 0, signed=False)) 

203 offset = 2 

204 

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 

214 

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 

221 

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 

227 

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 

233 

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 

239 

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 

245 

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 

251 

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 

258 

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) 

265 

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) 

270 

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) 

275 

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) 

280 

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) 

285 

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 ) 

305 

306 def _encode_value(self, data: RowerData) -> bytearray: # noqa: PLR0912 

307 """Encode RowerData back to BLE bytes. 

308 

309 Reconstructs flags from present fields so round-trip encoding 

310 preserves the original wire format. 

311 

312 Args: 

313 data: RowerData instance. 

314 

315 Returns: 

316 Encoded bytearray matching the BLE wire format. 

317 

318 """ 

319 flags = RowerDataFlags(0) 

320 

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 

348 

349 result = DataParser.encode_int16(int(flags), signed=False) 

350 

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

382 

383 return result