Coverage for src / bluetooth_sig / gatt / characteristics / cross_trainer_data.py: 83%
252 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"""Cross Trainer Data characteristic implementation.
3Implements the Cross Trainer Data characteristic (0x2ACE) from the Fitness
4Machine Service. A 24-bit flags field (3 bytes) controls the presence of
5optional data fields -- the widest flags field in the fitness machine set.
7Bit 0 ("More Data") uses **inverted logic**: when bit 0 is 0 the
8Instantaneous Speed field IS present; when bit 0 is 1 it is absent.
9All other presence bits use normal logic (1 = present).
11Bit 15 is a **semantic bit** (Movement Direction): 0 = Forward, 1 = Backward.
12It does NOT gate any data fields.
14References:
15 Bluetooth SIG Fitness Machine Service 1.0
16 org.bluetooth.characteristic.cross_trainer_data (GSS YAML)
17"""
19from __future__ import annotations
21from enum import IntFlag
23import msgspec
25from ..constants import SINT16_MAX, SINT16_MIN, UINT8_MAX, UINT16_MAX, UINT24_MAX
26from ..context import CharacteristicContext
27from .base import BaseCharacteristic
28from .fitness_machine_common import (
29 MET_RESOLUTION,
30 decode_elapsed_time,
31 decode_energy_triplet,
32 decode_heart_rate,
33 decode_metabolic_equivalent,
34 decode_remaining_time,
35 encode_elapsed_time,
36 encode_energy_triplet,
37 encode_heart_rate,
38 encode_metabolic_equivalent,
39 encode_remaining_time,
40)
41from .utils import DataParser
43# Speed: M=1, d=-2, b=0 -> actual = raw / 100 km/h
44_SPEED_RESOLUTION = 100.0
46# Stride Count: M=1, d=-1, b=0 -> actual = raw / 10
47_STRIDE_COUNT_RESOLUTION = 10.0
49# Inclination: M=1, d=-1, b=0 -> actual = raw / 10 %
50# Ramp Setting: M=1, d=-1, b=0 -> actual = raw / 10 degrees
51_TENTH_RESOLUTION = 10.0
53# Resistance Level: M=1, d=1, b=0 -> actual = raw * 10
54_RESISTANCE_RESOLUTION = 10.0
57class CrossTrainerDataFlags(IntFlag):
58 """Cross Trainer Data flags as per Bluetooth SIG specification.
60 24-bit flags field (3 bytes). Bit 0 uses inverted logic:
61 0 = Instantaneous Speed present, 1 = absent.
62 Bit 15 is a semantic modifier (Movement Direction), not a presence flag.
63 """
65 MORE_DATA = 0x000001 # Inverted: 0 -> Speed present, 1 -> absent
66 AVERAGE_SPEED_PRESENT = 0x000002
67 TOTAL_DISTANCE_PRESENT = 0x000004
68 STEP_COUNT_PRESENT = 0x000008
69 STRIDE_COUNT_PRESENT = 0x000010
70 ELEVATION_GAIN_PRESENT = 0x000020
71 INCLINATION_AND_RAMP_PRESENT = 0x000040
72 RESISTANCE_LEVEL_PRESENT = 0x000080
73 INSTANTANEOUS_POWER_PRESENT = 0x000100
74 AVERAGE_POWER_PRESENT = 0x000200
75 EXPENDED_ENERGY_PRESENT = 0x000400
76 HEART_RATE_PRESENT = 0x000800
77 METABOLIC_EQUIVALENT_PRESENT = 0x001000
78 ELAPSED_TIME_PRESENT = 0x002000
79 REMAINING_TIME_PRESENT = 0x004000
80 MOVEMENT_DIRECTION_BACKWARD = 0x008000 # Semantic: 0=Forward, 1=Backward
83class CrossTrainerData(msgspec.Struct, frozen=True, kw_only=True): # pylint: disable=too-few-public-methods,too-many-instance-attributes
84 """Parsed data from Cross Trainer Data characteristic.
86 Attributes:
87 flags: Raw 24-bit flags field.
88 instantaneous_speed: Instantaneous speed in km/h (0.01 resolution).
89 average_speed: Average speed in km/h (0.01 resolution).
90 total_distance: Total distance in metres (uint24).
91 steps_per_minute: Steps per minute.
92 average_step_rate: Average step rate in steps/min.
93 stride_count: Stride count (0.1 resolution, a stride is a pair of steps).
94 positive_elevation_gain: Positive elevation gain in metres.
95 negative_elevation_gain: Negative elevation gain in metres.
96 inclination: Current inclination in % (0.1 resolution, signed).
97 ramp_setting: Current ramp angle in degrees (0.1 resolution, signed).
98 resistance_level: Resistance level (unitless, resolution 10).
99 instantaneous_power: Instantaneous power in watts (signed).
100 average_power: Average power in watts (signed).
101 total_energy: Total expended energy in kcal.
102 energy_per_hour: Expended energy per hour in kcal.
103 energy_per_minute: Expended energy per minute in kcal.
104 heart_rate: Heart rate in bpm.
105 metabolic_equivalent: MET value (0.1 resolution).
106 elapsed_time: Elapsed time in seconds.
107 remaining_time: Remaining time in seconds.
108 movement_direction_backward: True if movement is backward, False if forward.
110 """
112 flags: CrossTrainerDataFlags
113 instantaneous_speed: float | None = None
114 average_speed: float | None = None
115 total_distance: int | None = None
116 steps_per_minute: int | None = None
117 average_step_rate: int | None = None
118 stride_count: float | None = None
119 positive_elevation_gain: int | None = None
120 negative_elevation_gain: int | None = None
121 inclination: float | None = None
122 ramp_setting: float | None = None
123 resistance_level: float | None = None
124 instantaneous_power: int | None = None
125 average_power: int | None = None
126 total_energy: int | None = None
127 energy_per_hour: int | None = None
128 energy_per_minute: int | None = None
129 heart_rate: int | None = None
130 metabolic_equivalent: float | None = None
131 elapsed_time: int | None = None
132 remaining_time: int | None = None
133 movement_direction_backward: bool = False
135 def __post_init__(self) -> None:
136 """Validate field ranges."""
137 if (
138 self.instantaneous_speed is not None
139 and not 0.0 <= self.instantaneous_speed <= UINT16_MAX / _SPEED_RESOLUTION
140 ):
141 raise ValueError(
142 f"Instantaneous speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.instantaneous_speed}"
143 )
144 if self.average_speed is not None and not 0.0 <= self.average_speed <= UINT16_MAX / _SPEED_RESOLUTION:
145 raise ValueError(f"Average speed must be 0.0-{UINT16_MAX / _SPEED_RESOLUTION}, got {self.average_speed}")
146 if self.total_distance is not None and not 0 <= self.total_distance <= UINT24_MAX:
147 raise ValueError(f"Total distance must be 0-{UINT24_MAX}, got {self.total_distance}")
148 if self.steps_per_minute is not None and not 0 <= self.steps_per_minute <= UINT16_MAX:
149 raise ValueError(f"Steps per minute must be 0-{UINT16_MAX}, got {self.steps_per_minute}")
150 if self.average_step_rate is not None and not 0 <= self.average_step_rate <= UINT16_MAX:
151 raise ValueError(f"Average step rate must be 0-{UINT16_MAX}, got {self.average_step_rate}")
152 if self.stride_count is not None and not 0.0 <= self.stride_count <= UINT16_MAX / _STRIDE_COUNT_RESOLUTION:
153 raise ValueError(
154 f"Stride count must be 0.0-{UINT16_MAX / _STRIDE_COUNT_RESOLUTION}, got {self.stride_count}"
155 )
156 if self.positive_elevation_gain is not None and not 0 <= self.positive_elevation_gain <= UINT16_MAX:
157 raise ValueError(f"Positive elevation must be 0-{UINT16_MAX}, got {self.positive_elevation_gain}")
158 if self.negative_elevation_gain is not None and not 0 <= self.negative_elevation_gain <= UINT16_MAX:
159 raise ValueError(f"Negative elevation must be 0-{UINT16_MAX}, got {self.negative_elevation_gain}")
160 if (
161 self.inclination is not None
162 and not SINT16_MIN / _TENTH_RESOLUTION <= self.inclination <= SINT16_MAX / _TENTH_RESOLUTION
163 ):
164 raise ValueError(
165 f"Inclination must be {SINT16_MIN / _TENTH_RESOLUTION}-{SINT16_MAX / _TENTH_RESOLUTION}, "
166 f"got {self.inclination}"
167 )
168 if (
169 self.ramp_setting is not None
170 and not SINT16_MIN / _TENTH_RESOLUTION <= self.ramp_setting <= SINT16_MAX / _TENTH_RESOLUTION
171 ):
172 raise ValueError(
173 f"Ramp setting must be {SINT16_MIN / _TENTH_RESOLUTION}-{SINT16_MAX / _TENTH_RESOLUTION}, "
174 f"got {self.ramp_setting}"
175 )
176 if self.resistance_level is not None and not 0.0 <= self.resistance_level <= UINT8_MAX * _RESISTANCE_RESOLUTION:
177 raise ValueError(
178 f"Resistance level must be 0.0-{UINT8_MAX * _RESISTANCE_RESOLUTION}, got {self.resistance_level}"
179 )
180 if self.instantaneous_power is not None and not SINT16_MIN <= self.instantaneous_power <= SINT16_MAX:
181 raise ValueError(f"Instantaneous power must be {SINT16_MIN}-{SINT16_MAX}, got {self.instantaneous_power}")
182 if self.average_power is not None and not SINT16_MIN <= self.average_power <= SINT16_MAX:
183 raise ValueError(f"Average power must be {SINT16_MIN}-{SINT16_MAX}, got {self.average_power}")
184 if self.total_energy is not None and not 0 <= self.total_energy <= UINT16_MAX:
185 raise ValueError(f"Total energy must be 0-{UINT16_MAX}, got {self.total_energy}")
186 if self.energy_per_hour is not None and not 0 <= self.energy_per_hour <= UINT16_MAX:
187 raise ValueError(f"Energy per hour must be 0-{UINT16_MAX}, got {self.energy_per_hour}")
188 if self.energy_per_minute is not None and not 0 <= self.energy_per_minute <= UINT8_MAX:
189 raise ValueError(f"Energy per minute must be 0-{UINT8_MAX}, got {self.energy_per_minute}")
190 if self.heart_rate is not None and not 0 <= self.heart_rate <= UINT8_MAX:
191 raise ValueError(f"Heart rate must be 0-{UINT8_MAX}, got {self.heart_rate}")
192 if self.metabolic_equivalent is not None and not 0.0 <= self.metabolic_equivalent <= UINT8_MAX / MET_RESOLUTION:
193 raise ValueError(
194 f"Metabolic equivalent must be 0.0-{UINT8_MAX / MET_RESOLUTION}, got {self.metabolic_equivalent}"
195 )
196 if self.elapsed_time is not None and not 0 <= self.elapsed_time <= UINT16_MAX:
197 raise ValueError(f"Elapsed time must be 0-{UINT16_MAX}, got {self.elapsed_time}")
198 if self.remaining_time is not None and not 0 <= self.remaining_time <= UINT16_MAX:
199 raise ValueError(f"Remaining time must be 0-{UINT16_MAX}, got {self.remaining_time}")
202class CrossTrainerDataCharacteristic(BaseCharacteristic[CrossTrainerData]):
203 """Cross Trainer Data characteristic (0x2ACE).
205 Used in the Fitness Machine Service to transmit cross trainer workout
206 data. A 24-bit flags field (3 bytes) controls which optional fields
207 are present -- the widest flags field in the fitness machine set.
209 Flag-bit assignments (from GSS YAML):
210 Bit 0: More Data -- **inverted**: 0 -> Inst. Speed present, 1 -> absent
211 Bit 1: Average Speed present
212 Bit 2: Total Distance present
213 Bit 3: Step Count present (gates Steps/Min + Avg Step Rate)
214 Bit 4: Stride Count present
215 Bit 5: Elevation Gain present (gates Pos + Neg)
216 Bit 6: Inclination and Ramp Angle Setting present (gates 2 fields)
217 Bit 7: Resistance Level present
218 Bit 8: Instantaneous Power present
219 Bit 9: Average Power present
220 Bit 10: Expended Energy present (gates triplet: total + /hr + /min)
221 Bit 11: Heart Rate present
222 Bit 12: Metabolic Equivalent present
223 Bit 13: Elapsed Time present
224 Bit 14: Remaining Time present
225 Bit 15: Movement Direction (0=Forward, 1=Backward) -- semantic, not presence
226 Bits 16-23: Reserved for Future Use
228 """
230 expected_type = CrossTrainerData
231 min_length: int = 3 # Flags only (24-bit = 3 bytes)
232 allow_variable_length: bool = True
234 def _decode_value(
235 self,
236 data: bytearray,
237 ctx: CharacteristicContext | None = None,
238 *,
239 validate: bool = True,
240 ) -> CrossTrainerData:
241 """Parse Cross Trainer Data from raw BLE bytes.
243 Args:
244 data: Raw bytearray from BLE characteristic.
245 ctx: Optional context (unused).
246 validate: Whether to validate ranges.
248 Returns:
249 CrossTrainerData with all present fields populated.
251 """
252 flags = CrossTrainerDataFlags(DataParser.parse_int24(data, 0, signed=False))
253 offset = 3
255 # Bit 0 -- inverted: Instantaneous Speed present when bit is NOT set
256 instantaneous_speed = None
257 if not (flags & CrossTrainerDataFlags.MORE_DATA) and len(data) >= offset + 2:
258 raw_speed = DataParser.parse_int16(data, offset, signed=False)
259 instantaneous_speed = raw_speed / _SPEED_RESOLUTION
260 offset += 2
262 # Bit 1 -- Average Speed
263 average_speed = None
264 if (flags & CrossTrainerDataFlags.AVERAGE_SPEED_PRESENT) and len(data) >= offset + 2:
265 raw_avg_speed = DataParser.parse_int16(data, offset, signed=False)
266 average_speed = raw_avg_speed / _SPEED_RESOLUTION
267 offset += 2
269 # Bit 2 -- Total Distance (uint24)
270 total_distance = None
271 if (flags & CrossTrainerDataFlags.TOTAL_DISTANCE_PRESENT) and len(data) >= offset + 3:
272 total_distance = DataParser.parse_int24(data, offset, signed=False)
273 offset += 3
275 # Bit 3 -- Steps Per Minute (uint16) + Average Step Rate (uint16)
276 steps_per_minute = None
277 average_step_rate = None
278 if (flags & CrossTrainerDataFlags.STEP_COUNT_PRESENT) and len(data) >= offset + 4:
279 steps_per_minute = DataParser.parse_int16(data, offset, signed=False)
280 offset += 2
281 average_step_rate = DataParser.parse_int16(data, offset, signed=False)
282 offset += 2
284 # Bit 4 -- Stride Count (uint16, d=-1 -> raw/10)
285 stride_count = None
286 if (flags & CrossTrainerDataFlags.STRIDE_COUNT_PRESENT) and len(data) >= offset + 2:
287 raw_stride = DataParser.parse_int16(data, offset, signed=False)
288 stride_count = raw_stride / _STRIDE_COUNT_RESOLUTION
289 offset += 2
291 # Bit 5 -- Positive Elevation Gain (uint16) + Negative Elevation Gain (uint16)
292 positive_elevation_gain = None
293 negative_elevation_gain = None
294 if (flags & CrossTrainerDataFlags.ELEVATION_GAIN_PRESENT) and len(data) >= offset + 4:
295 positive_elevation_gain = DataParser.parse_int16(data, offset, signed=False)
296 offset += 2
297 negative_elevation_gain = DataParser.parse_int16(data, offset, signed=False)
298 offset += 2
300 # Bit 6 -- Inclination (sint16, d=-1) + Ramp Setting (sint16, d=-1)
301 inclination = None
302 ramp_setting = None
303 if (flags & CrossTrainerDataFlags.INCLINATION_AND_RAMP_PRESENT) and len(data) >= offset + 4:
304 raw_incl = DataParser.parse_int16(data, offset, signed=True)
305 inclination = raw_incl / _TENTH_RESOLUTION
306 offset += 2
307 raw_ramp = DataParser.parse_int16(data, offset, signed=True)
308 ramp_setting = raw_ramp / _TENTH_RESOLUTION
309 offset += 2
311 # Bit 7 -- Resistance Level (uint8, d=1 -> raw * 10)
312 resistance_level = None
313 if (flags & CrossTrainerDataFlags.RESISTANCE_LEVEL_PRESENT) and len(data) >= offset + 1:
314 raw_resistance = DataParser.parse_int8(data, offset, signed=False)
315 resistance_level = raw_resistance * _RESISTANCE_RESOLUTION
316 offset += 1
318 # Bit 8 -- Instantaneous Power (sint16)
319 instantaneous_power = None
320 if (flags & CrossTrainerDataFlags.INSTANTANEOUS_POWER_PRESENT) and len(data) >= offset + 2:
321 instantaneous_power = DataParser.parse_int16(data, offset, signed=True)
322 offset += 2
324 # Bit 9 -- Average Power (sint16)
325 average_power = None
326 if (flags & CrossTrainerDataFlags.AVERAGE_POWER_PRESENT) and len(data) >= offset + 2:
327 average_power = DataParser.parse_int16(data, offset, signed=True)
328 offset += 2
330 # Bit 10 -- Energy triplet (Total + Per Hour + Per Minute)
331 total_energy = None
332 energy_per_hour = None
333 energy_per_minute = None
334 if flags & CrossTrainerDataFlags.EXPENDED_ENERGY_PRESENT:
335 total_energy, energy_per_hour, energy_per_minute, offset = decode_energy_triplet(data, offset)
337 # Bit 11 -- Heart Rate
338 heart_rate = None
339 if flags & CrossTrainerDataFlags.HEART_RATE_PRESENT:
340 heart_rate, offset = decode_heart_rate(data, offset)
342 # Bit 12 -- Metabolic Equivalent
343 metabolic_equivalent = None
344 if flags & CrossTrainerDataFlags.METABOLIC_EQUIVALENT_PRESENT:
345 metabolic_equivalent, offset = decode_metabolic_equivalent(data, offset)
347 # Bit 13 -- Elapsed Time
348 elapsed_time = None
349 if flags & CrossTrainerDataFlags.ELAPSED_TIME_PRESENT:
350 elapsed_time, offset = decode_elapsed_time(data, offset)
352 # Bit 14 -- Remaining Time
353 remaining_time = None
354 if flags & CrossTrainerDataFlags.REMAINING_TIME_PRESENT:
355 remaining_time, offset = decode_remaining_time(data, offset)
357 # Bit 15 -- Movement Direction (semantic, no data fields)
358 movement_direction_backward = bool(flags & CrossTrainerDataFlags.MOVEMENT_DIRECTION_BACKWARD)
360 return CrossTrainerData(
361 flags=flags,
362 instantaneous_speed=instantaneous_speed,
363 average_speed=average_speed,
364 total_distance=total_distance,
365 steps_per_minute=steps_per_minute,
366 average_step_rate=average_step_rate,
367 stride_count=stride_count,
368 positive_elevation_gain=positive_elevation_gain,
369 negative_elevation_gain=negative_elevation_gain,
370 inclination=inclination,
371 ramp_setting=ramp_setting,
372 resistance_level=resistance_level,
373 instantaneous_power=instantaneous_power,
374 average_power=average_power,
375 total_energy=total_energy,
376 energy_per_hour=energy_per_hour,
377 energy_per_minute=energy_per_minute,
378 heart_rate=heart_rate,
379 metabolic_equivalent=metabolic_equivalent,
380 elapsed_time=elapsed_time,
381 remaining_time=remaining_time,
382 movement_direction_backward=movement_direction_backward,
383 )
385 def _encode_value(self, data: CrossTrainerData) -> bytearray: # noqa: PLR0912
386 """Encode CrossTrainerData back to BLE bytes.
388 Reconstructs 24-bit flags from present fields so round-trip encoding
389 preserves the original wire format.
391 Args:
392 data: CrossTrainerData instance.
394 Returns:
395 Encoded bytearray matching the BLE wire format.
397 """
398 flags = CrossTrainerDataFlags(0)
400 # Bit 0 -- inverted: set MORE_DATA when Speed is absent
401 if data.instantaneous_speed is None:
402 flags |= CrossTrainerDataFlags.MORE_DATA
403 if data.average_speed is not None:
404 flags |= CrossTrainerDataFlags.AVERAGE_SPEED_PRESENT
405 if data.total_distance is not None:
406 flags |= CrossTrainerDataFlags.TOTAL_DISTANCE_PRESENT
407 if data.steps_per_minute is not None:
408 flags |= CrossTrainerDataFlags.STEP_COUNT_PRESENT
409 if data.stride_count is not None:
410 flags |= CrossTrainerDataFlags.STRIDE_COUNT_PRESENT
411 if data.positive_elevation_gain is not None:
412 flags |= CrossTrainerDataFlags.ELEVATION_GAIN_PRESENT
413 if data.inclination is not None:
414 flags |= CrossTrainerDataFlags.INCLINATION_AND_RAMP_PRESENT
415 if data.resistance_level is not None:
416 flags |= CrossTrainerDataFlags.RESISTANCE_LEVEL_PRESENT
417 if data.instantaneous_power is not None:
418 flags |= CrossTrainerDataFlags.INSTANTANEOUS_POWER_PRESENT
419 if data.average_power is not None:
420 flags |= CrossTrainerDataFlags.AVERAGE_POWER_PRESENT
421 if data.total_energy is not None:
422 flags |= CrossTrainerDataFlags.EXPENDED_ENERGY_PRESENT
423 if data.heart_rate is not None:
424 flags |= CrossTrainerDataFlags.HEART_RATE_PRESENT
425 if data.metabolic_equivalent is not None:
426 flags |= CrossTrainerDataFlags.METABOLIC_EQUIVALENT_PRESENT
427 if data.elapsed_time is not None:
428 flags |= CrossTrainerDataFlags.ELAPSED_TIME_PRESENT
429 if data.remaining_time is not None:
430 flags |= CrossTrainerDataFlags.REMAINING_TIME_PRESENT
431 if data.movement_direction_backward:
432 flags |= CrossTrainerDataFlags.MOVEMENT_DIRECTION_BACKWARD
434 result = DataParser.encode_int24(int(flags), signed=False)
436 if data.instantaneous_speed is not None:
437 raw_speed = round(data.instantaneous_speed * _SPEED_RESOLUTION)
438 result.extend(DataParser.encode_int16(raw_speed, signed=False))
439 if data.average_speed is not None:
440 raw_avg_speed = round(data.average_speed * _SPEED_RESOLUTION)
441 result.extend(DataParser.encode_int16(raw_avg_speed, signed=False))
442 if data.total_distance is not None:
443 result.extend(DataParser.encode_int24(data.total_distance, signed=False))
444 if data.steps_per_minute is not None:
445 result.extend(DataParser.encode_int16(data.steps_per_minute, signed=False))
446 if data.average_step_rate is not None:
447 result.extend(DataParser.encode_int16(data.average_step_rate, signed=False))
448 if data.stride_count is not None:
449 raw_stride = round(data.stride_count * _STRIDE_COUNT_RESOLUTION)
450 result.extend(DataParser.encode_int16(raw_stride, signed=False))
451 if data.positive_elevation_gain is not None:
452 result.extend(DataParser.encode_int16(data.positive_elevation_gain, signed=False))
453 if data.negative_elevation_gain is not None:
454 result.extend(DataParser.encode_int16(data.negative_elevation_gain, signed=False))
455 if data.inclination is not None:
456 raw_incl = round(data.inclination * _TENTH_RESOLUTION)
457 result.extend(DataParser.encode_int16(raw_incl, signed=True))
458 if data.ramp_setting is not None:
459 raw_ramp = round(data.ramp_setting * _TENTH_RESOLUTION)
460 result.extend(DataParser.encode_int16(raw_ramp, signed=True))
461 if data.resistance_level is not None:
462 raw_resistance = round(data.resistance_level / _RESISTANCE_RESOLUTION)
463 result.extend(DataParser.encode_int8(raw_resistance, signed=False))
464 if data.instantaneous_power is not None:
465 result.extend(DataParser.encode_int16(data.instantaneous_power, signed=True))
466 if data.average_power is not None:
467 result.extend(DataParser.encode_int16(data.average_power, signed=True))
468 if data.total_energy is not None:
469 result.extend(encode_energy_triplet(data.total_energy, data.energy_per_hour, data.energy_per_minute))
470 if data.heart_rate is not None:
471 result.extend(encode_heart_rate(data.heart_rate))
472 if data.metabolic_equivalent is not None:
473 result.extend(encode_metabolic_equivalent(data.metabolic_equivalent))
474 if data.elapsed_time is not None:
475 result.extend(encode_elapsed_time(data.elapsed_time))
476 if data.remaining_time is not None:
477 result.extend(encode_remaining_time(data.remaining_time))
479 return result