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

1"""Stair Climber Data characteristic implementation. 

2 

3Implements the Stair Climber Data characteristic (0x2AD0) from the Fitness 

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

5data fields. 

6 

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

10 

11References: 

12 Bluetooth SIG Fitness Machine Service 1.0 

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

14""" 

15 

16from __future__ import annotations 

17 

18from enum import IntFlag 

19 

20import msgspec 

21 

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 

39 

40 

41class StairClimberDataFlags(IntFlag): 

42 """Stair Climber Data flags as per Bluetooth SIG specification. 

43 

44 Bit 0 uses inverted logic: 0 = Floors present, 1 = Floors absent. 

45 """ 

46 

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 

57 

58 

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. 

61 

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. 

76 

77 """ 

78 

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 

92 

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

121 

122 

123class StairClimberDataCharacteristic(BaseCharacteristic[StairClimberData]): 

124 """Stair Climber Data characteristic (0x2AD0). 

125 

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. 

128 

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 

141 

142 """ 

143 

144 expected_type = StairClimberData 

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

146 allow_variable_length: bool = True 

147 

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. 

156 

157 Args: 

158 data: Raw bytearray from BLE characteristic. 

159 ctx: Optional context (unused). 

160 validate: Whether to validate ranges. 

161 

162 Returns: 

163 StairClimberData with all present fields populated. 

164 

165 """ 

166 flags = StairClimberDataFlags(DataParser.parse_int16(data, 0, signed=False)) 

167 offset = 2 

168 

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 

174 

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 

180 

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 

186 

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 

192 

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 

198 

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) 

205 

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) 

210 

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) 

215 

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) 

220 

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) 

225 

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 ) 

241 

242 def _encode_value(self, data: StairClimberData) -> bytearray: 

243 """Encode StairClimberData back to BLE bytes. 

244 

245 Reconstructs flags from present fields so round-trip encoding 

246 preserves the original wire format. 

247 

248 Args: 

249 data: StairClimberData instance. 

250 

251 Returns: 

252 Encoded bytearray matching the BLE wire format. 

253 

254 """ 

255 flags = StairClimberDataFlags(0) 

256 

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 

278 

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

280 

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

301 

302 return result