Coverage for src / bluetooth_sig / gatt / characteristics / high_intensity_exercise_threshold.py: 88%

51 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:41 +0000

1"""High Intensity Exercise Threshold characteristic (0x2B4D).""" 

2 

3from __future__ import annotations 

4 

5import msgspec 

6 

7from ..context import CharacteristicContext 

8from .base import BaseCharacteristic 

9from .utils import DataParser 

10 

11# Field selector values 

12_FIELD_SELECTOR_ENERGY = 1 

13_FIELD_SELECTOR_MET = 2 

14_FIELD_SELECTOR_HR_PERCENTAGE = 3 

15_FIELD_SELECTOR_HR = 4 

16 

17# Minimum data lengths for each field selector 

18_MIN_LENGTH_ENERGY = 3 # 1 byte selector + 2 bytes uint16 

19_MIN_LENGTH_MET = 2 # 1 byte selector + 1 byte uint8 

20_MIN_LENGTH_HR = 2 # 1 byte selector + 1 byte uint8 

21 

22 

23class HighIntensityExerciseThresholdData(msgspec.Struct): 

24 """High Intensity Exercise Threshold parsed data. 

25 

26 Attributes: 

27 field_selector: Field selector indicating which threshold is present (1, 2, or 3) 

28 threshold_energy_expenditure: Energy expenditure in joules (field_selector=1) 

29 threshold_metabolic_equivalent: Metabolic equivalent in MET (field_selector=2) 

30 threshold_percentage_max_heart_rate: Heart rate percentage (field_selector=3) 

31 """ 

32 

33 field_selector: int 

34 threshold_energy_expenditure: int | None = None 

35 threshold_metabolic_equivalent: float | None = None 

36 threshold_percentage_max_heart_rate: int | None = None 

37 threshold_heart_rate: int | None = None 

38 

39 

40class HighIntensityExerciseThresholdCharacteristic(BaseCharacteristic[HighIntensityExerciseThresholdData]): 

41 """High Intensity Exercise Threshold characteristic (0x2B4D). 

42 

43 org.bluetooth.characteristic.high_intensity_exercise_threshold 

44 

45 High Intensity Exercise Threshold characteristic with conditional fields. 

46 

47 Structure (variable length): 

48 - Field Selector (uint8): 1 byte - determines which threshold field follows 

49 - 0 = No field selected 

50 - 1 = Threshold as Energy Expenditure per Hour (uint16, 2 bytes) 

51 - 2 = Threshold as Metabolic Equivalent (uint8, 1 byte) 

52 - 3 = Threshold as Percentage of Maximum Heart Rate (uint8, 1 byte) 

53 - 4 = Threshold as Heart Rate (uint8, 1 byte) 

54 

55 Total payload: 1-3 bytes 

56 """ 

57 

58 # YAML specifies variable fields based on Field Selector value 

59 min_length: int | None = 1 

60 max_length: int | None = 3 

61 

62 def _decode_value( 

63 self, data: bytearray, ctx: CharacteristicContext | None = None, *, validate: bool = True 

64 ) -> HighIntensityExerciseThresholdData: 

65 """Parse High Intensity Exercise Threshold with conditional fields. 

66 

67 Args: 

68 data: Raw bytes (1-3 bytes) 

69 ctx: Optional context 

70 validate: Whether to validate ranges (default True) 

71 

72 Returns: 

73 HighIntensityExerciseThresholdData with field_selector and optional threshold 

74 """ 

75 field_selector = int(data[0]) 

76 threshold_energy = None 

77 threshold_met = None 

78 threshold_hr_pct = None 

79 threshold_hr = None 

80 

81 # Parse optional threshold field based on selector 

82 if field_selector == _FIELD_SELECTOR_ENERGY and len(data) >= _MIN_LENGTH_ENERGY: 

83 # Energy Expenditure per Hour (uint16, resolution 1000 J) 

84 threshold = DataParser.parse_int16(data, 1, signed=False) 

85 threshold_energy = threshold * 1000 # Convert to joules 

86 elif field_selector == _FIELD_SELECTOR_MET and len(data) >= _MIN_LENGTH_MET: 

87 # Metabolic Equivalent (uint8, resolution 0.1 MET) 

88 threshold = int(data[1]) 

89 threshold_met = threshold * 0.1 

90 elif field_selector == _FIELD_SELECTOR_HR_PERCENTAGE and len(data) >= _MIN_LENGTH_HR: 

91 # Percentage of Maximum Heart Rate (uint8) 

92 threshold_hr_pct = int(data[1]) 

93 elif field_selector == _FIELD_SELECTOR_HR and len(data) >= _MIN_LENGTH_HR: 

94 # Heart Rate (uint8, BPM) 

95 threshold_hr = int(data[1]) 

96 

97 return HighIntensityExerciseThresholdData( 

98 field_selector=field_selector, 

99 threshold_energy_expenditure=threshold_energy, 

100 threshold_metabolic_equivalent=threshold_met, 

101 threshold_percentage_max_heart_rate=threshold_hr_pct, 

102 threshold_heart_rate=threshold_hr, 

103 ) 

104 

105 def _encode_value(self, data: HighIntensityExerciseThresholdData) -> bytearray: 

106 """Encode High Intensity Exercise Threshold. 

107 

108 Args: 

109 data: HighIntensityExerciseThresholdData instance 

110 

111 Returns: 

112 Encoded bytes (1-3 bytes) 

113 """ 

114 result = bytearray([data.field_selector]) 

115 

116 if data.field_selector == _FIELD_SELECTOR_ENERGY and data.threshold_energy_expenditure is not None: 

117 # Convert joules back to 1000 J units 

118 energy_value = int(data.threshold_energy_expenditure / 1000) 

119 result.extend(DataParser.encode_int16(energy_value, signed=False)) 

120 elif data.field_selector == _FIELD_SELECTOR_MET and data.threshold_metabolic_equivalent is not None: 

121 # Convert 0.1 MET units back to uint8 

122 met_value = int(data.threshold_metabolic_equivalent / 0.1) 

123 result.append(met_value) 

124 elif ( 

125 data.field_selector == _FIELD_SELECTOR_HR_PERCENTAGE 

126 and data.threshold_percentage_max_heart_rate is not None 

127 ): 

128 result.append(int(data.threshold_percentage_max_heart_rate)) 

129 elif data.field_selector == _FIELD_SELECTOR_HR and data.threshold_heart_rate is not None: 

130 result.append(int(data.threshold_heart_rate)) 

131 

132 return result