Coverage for src/bluetooth_sig/gatt/characteristics/recipe_parameters.py: 93%

125 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 01:26 +0000

1"""Recipe Parameters characteristic (0x2C27).""" 

2 

3from __future__ import annotations 

4 

5from enum import IntEnum, IntFlag 

6 

7import msgspec 

8 

9from ..constants import SIZE_UINT8, SIZE_UINT16 

10from ..context import CharacteristicContext 

11from .base import BaseCharacteristic 

12from .cooking_common import ( 

13 COOKING_TEMPERATURE, 

14 HUMIDITY, 

15 validate_flags, 

16) 

17from .utils import DataParser 

18 

19_TEMPERATURE_GRADIENT_MIN = -12.8 

20_TEMPERATURE_GRADIENT_MAX = 12.7 

21 

22 

23class RecipeParametersFlags(IntFlag): 

24 """Recipe parameter field presence and behavior flags.""" 

25 

26 OVERSHOOT_PREVENTION = 1 << 0 

27 LAST_COOKING_STEP = 1 << 1 

28 USER_ACTION_REQUIRED = 1 << 2 

29 TEMPERATURE_PRESENT = 1 << 3 

30 TEMPERATURE_GRADIENT_PRESENT = 1 << 4 

31 HUMIDITY_PRESENT = 1 << 5 

32 TERMINATION_CONDITION_PRESENT = 1 << 6 

33 

34 

35class TerminationConditionFlags(IntFlag): 

36 """Recipe termination condition bitfield.""" 

37 

38 TEMPERATURE_INCREASE = 1 << 0 

39 TEMPERATURE_DECREASE = 1 << 1 

40 HUMIDITY_INCREASE = 1 << 2 

41 HUMIDITY_DECREASE = 1 << 3 

42 LOGICAL_AND = 1 << 15 

43 

44 

45class CookingProcessType(IntEnum): 

46 """Recipe cooking process type values from GSS.""" 

47 

48 NO_COOKING = 0x00 

49 PREHEATING = 0x01 

50 BAKING = 0x02 

51 BOILING = 0x03 

52 BRAISING = 0x04 

53 BROILING = 0x05 

54 COOLING = 0x06 

55 FREEZING = 0x07 

56 FRYING = 0x08 

57 GRILLING = 0x09 

58 MELTING = 0x0A 

59 POACHING = 0x0B 

60 ROASTING = 0x0C 

61 SAUTEING = 0x0D 

62 SIMMERING = 0x0E 

63 SOUS_VIDE = 0x0F 

64 STEAMING = 0x10 

65 STEWING = 0x11 

66 OTHER_UNKNOWN = 0xFF 

67 

68 

69class RecipeParametersData(msgspec.Struct, frozen=True, kw_only=True): 

70 """Decoded Recipe Parameters payload.""" 

71 

72 flags: RecipeParametersFlags 

73 cooking_step_index: int 

74 cooking_process_type: CookingProcessType 

75 duration_seconds: int | None = None 

76 temperature: float | None = None 

77 temperature_gradient: float | None = None 

78 humidity: float | None = None 

79 termination_condition: TerminationConditionFlags | None = None 

80 

81 

82class RecipeParametersCharacteristic(BaseCharacteristic[RecipeParametersData]): 

83 """Recipe Parameters characteristic (0x2C27). 

84 

85 org.bluetooth.characteristic.recipe_parameters 

86 """ 

87 

88 min_length = 5 

89 allow_variable_length = True 

90 

91 def _decode_value( 

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

93 ) -> RecipeParametersData: 

94 flags = RecipeParametersFlags(DataParser.parse_int16(data, 0, signed=False)) 

95 validate_flags(flags, RecipeParametersFlags, "Recipe Parameters Flags") 

96 cooking_step_index = DataParser.parse_int16(data, 2, signed=False) 

97 cooking_process_type = CookingProcessType(DataParser.parse_int8(data, 4, signed=False)) 

98 

99 offset = 5 

100 duration_seconds = None 

101 if cooking_process_type != CookingProcessType.NO_COOKING: 

102 duration_seconds = DataParser.parse_int16(data, offset, signed=False) 

103 offset += SIZE_UINT16 

104 

105 temperature = None 

106 if flags & RecipeParametersFlags.TEMPERATURE_PRESENT: 

107 temperature = COOKING_TEMPERATURE.parse_value(bytearray(data[offset : offset + SIZE_UINT16])) 

108 offset += SIZE_UINT16 

109 

110 temperature_gradient = None 

111 if ( 

112 flags & RecipeParametersFlags.TEMPERATURE_PRESENT 

113 and flags & RecipeParametersFlags.TEMPERATURE_GRADIENT_PRESENT 

114 ): 

115 gradient_raw = DataParser.parse_int8(data, offset, signed=True) 

116 temperature_gradient = gradient_raw * 0.1 

117 # NOTE: custom validation required because this is a nested composite 

118 # field; the automatic YAML range validator only sees RecipeParametersData. 

119 if not _TEMPERATURE_GRADIENT_MIN <= temperature_gradient <= _TEMPERATURE_GRADIENT_MAX: 

120 raise ValueError("temperature_gradient is outside the SIG range") 

121 offset += SIZE_UINT8 

122 

123 humidity = None 

124 if flags & RecipeParametersFlags.HUMIDITY_PRESENT: 

125 humidity = HUMIDITY.parse_value(bytearray(data[offset : offset + SIZE_UINT16])) 

126 offset += SIZE_UINT16 

127 

128 termination_condition = None 

129 if flags & RecipeParametersFlags.TERMINATION_CONDITION_PRESENT: 

130 termination_condition = TerminationConditionFlags(DataParser.parse_int16(data, offset, signed=False)) 

131 # NOTE: custom validation required because this nested bitfield's RFU 

132 # and mutual-exclusion rules are not applied by the top-level YAML parser. 

133 validate_flags(termination_condition, TerminationConditionFlags, "Termination Condition") 

134 _validate_termination_condition(termination_condition) 

135 

136 return RecipeParametersData( 

137 flags=flags, 

138 cooking_step_index=cooking_step_index, 

139 cooking_process_type=cooking_process_type, 

140 duration_seconds=duration_seconds, 

141 temperature=temperature, 

142 temperature_gradient=temperature_gradient, 

143 humidity=humidity, 

144 termination_condition=termination_condition, 

145 ) 

146 

147 def _encode_value(self, data: RecipeParametersData) -> bytearray: 

148 validate_flags(data.flags, RecipeParametersFlags, "Recipe Parameters Flags") 

149 result = bytearray() 

150 result.extend(DataParser.encode_int16(int(data.flags), signed=False)) 

151 result.extend(DataParser.encode_int16(data.cooking_step_index, signed=False)) 

152 result.extend(DataParser.encode_int8(int(data.cooking_process_type), signed=False)) 

153 

154 if data.cooking_process_type != CookingProcessType.NO_COOKING: 

155 if data.duration_seconds is None: 

156 raise ValueError("duration_seconds is required when cooking_process_type is not 0") 

157 result.extend(DataParser.encode_int16(data.duration_seconds, signed=False)) 

158 

159 if data.flags & RecipeParametersFlags.TEMPERATURE_PRESENT: 

160 if data.temperature is None: 

161 raise ValueError("temperature is required when TEMPERATURE_PRESENT is set") 

162 result.extend(COOKING_TEMPERATURE.build_value(data.temperature)) 

163 

164 if ( 

165 data.flags & RecipeParametersFlags.TEMPERATURE_PRESENT 

166 and data.flags & RecipeParametersFlags.TEMPERATURE_GRADIENT_PRESENT 

167 ): 

168 if data.temperature_gradient is None: 

169 raise ValueError("temperature_gradient is required when TEMPERATURE_GRADIENT_PRESENT is set") 

170 # NOTE: mirrors decode-time nested-field validation; automatic YAML 

171 # range validation does not inspect fields inside RecipeParametersData. 

172 if not _TEMPERATURE_GRADIENT_MIN <= data.temperature_gradient <= _TEMPERATURE_GRADIENT_MAX: 

173 raise ValueError("temperature_gradient is outside the SIG range") 

174 result.extend(DataParser.encode_int8(round(data.temperature_gradient * 10.0), signed=True)) 

175 

176 if data.flags & RecipeParametersFlags.HUMIDITY_PRESENT: 

177 if data.humidity is None: 

178 raise ValueError("humidity is required when HUMIDITY_PRESENT is set") 

179 result.extend(HUMIDITY.build_value(data.humidity)) 

180 

181 if data.flags & RecipeParametersFlags.TERMINATION_CONDITION_PRESENT: 

182 if data.termination_condition is None: 

183 raise ValueError("termination_condition is required when TERMINATION_CONDITION_PRESENT is set") 

184 # NOTE: mirrors decode-time nested-bitfield validation; automatic YAML 

185 # validation does not inspect fields inside RecipeParametersData. 

186 validate_flags(data.termination_condition, TerminationConditionFlags, "Termination Condition") 

187 _validate_termination_condition(data.termination_condition) 

188 result.extend(DataParser.encode_int16(int(data.termination_condition), signed=False)) 

189 

190 return result 

191 

192 

193def _validate_termination_condition(flags: TerminationConditionFlags) -> None: 

194 """Validate mutually exclusive termination condition pairs from CWS.""" 

195 if ( 

196 flags & TerminationConditionFlags.TEMPERATURE_INCREASE 

197 and flags & TerminationConditionFlags.TEMPERATURE_DECREASE 

198 ): 

199 raise ValueError("temperature increase and decrease termination conditions are mutually exclusive") 

200 if flags & TerminationConditionFlags.HUMIDITY_INCREASE and flags & TerminationConditionFlags.HUMIDITY_DECREASE: 

201 raise ValueError("humidity increase and decrease termination conditions are mutually exclusive") 

202 if flags & TerminationConditionFlags.TEMPERATURE_INCREASE and flags & TerminationConditionFlags.HUMIDITY_DECREASE: 

203 raise ValueError("humidity decrease condition can only be set when temperature increase is cleared")