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
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 01:26 +0000
1"""Recipe Parameters characteristic (0x2C27)."""
3from __future__ import annotations
5from enum import IntEnum, IntFlag
7import msgspec
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
19_TEMPERATURE_GRADIENT_MIN = -12.8
20_TEMPERATURE_GRADIENT_MAX = 12.7
23class RecipeParametersFlags(IntFlag):
24 """Recipe parameter field presence and behavior flags."""
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
35class TerminationConditionFlags(IntFlag):
36 """Recipe termination condition bitfield."""
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
45class CookingProcessType(IntEnum):
46 """Recipe cooking process type values from GSS."""
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
69class RecipeParametersData(msgspec.Struct, frozen=True, kw_only=True):
70 """Decoded Recipe Parameters payload."""
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
82class RecipeParametersCharacteristic(BaseCharacteristic[RecipeParametersData]):
83 """Recipe Parameters characteristic (0x2C27).
85 org.bluetooth.characteristic.recipe_parameters
86 """
88 min_length = 5
89 allow_variable_length = True
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))
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
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
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
123 humidity = None
124 if flags & RecipeParametersFlags.HUMIDITY_PRESENT:
125 humidity = HUMIDITY.parse_value(bytearray(data[offset : offset + SIZE_UINT16]))
126 offset += SIZE_UINT16
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)
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 )
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))
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))
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))
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))
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))
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))
190 return result
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")