diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index e4d476c1..db7b0165 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -970,6 +970,9 @@ class AirFryerCookModes(StrEnum): VEGGIES = 'veggies' FRENCH_FRIES = 'french_fries' CHICKEN = 'chicken' + BACON = 'bacon' + SHRIMP = 'shrimp' + TOAST = 'toast' class AirFryerFeatures(Features): diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 8518641c..5e1ed55a 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1182,6 +1182,76 @@ class ThermostatMap(DeviceMapTemplate): } ), ), + AirFryerMap( + # Cosori Pro II 5.8 Qt (CS358-AF), US single-basket model. Uses the + # same bypassV2 protocol as TurboBlaze (startCook / endCook / + # getAirfryerStatus), plus preheatCook to continue from a finished + # preheat into the queued cook step. + # + # Everything in this entry was read from a physical US unit + # (configModule WiFiBTOnboarding_AirFryer_CS358-AF_US). Unlike some + # sibling models, readyStart=False starts the program from the cloud + # with no press of the unit's Start button. The unit chooses its own + # preheat duration from the target temperature (3 minutes at 170 F and + # 4 minutes at 350 F were observed) and keeps the cook step in + # stepArray throughout preheat. + class_name='VeSyncTurboBlazeFryer', + module=vesynckitchen, + dev_types=['CS358-AF'], + setup_entry='CS358-AF', + device_alias='Pro II Air Fryer', + model_display='CS358-AF Series', + model_name='Pro II 5.8 Qt. Smart Air Fryer', + temp_unit=TemperatureUnits.FAHRENHEIT, + temperature_step_f=10, + # The unit can be paused from its own Start/Pause button or by + # pulling the basket, but no cloud pause/resume method is known, so + # RESUMABLE is not claimed. + features=[AirFryerFeatures.PREHEAT], + # Mode strings read back from the unit: 'Custom' from a manual program + # started from the cloud, the rest by starting each preset button on + # the unit. The unit reports nothing for a preset until it is started. + # Only the mode strings are recorded; each preset's temperature, time + # and shake reminder can be changed by the owner. + cook_modes={ + AirFryerCookModes.CUSTOM: 'Custom', + AirFryerCookModes.STEAK: 'Steak', + AirFryerCookModes.CHICKEN: 'Chicken', + AirFryerCookModes.SEAFOOD: 'Seafood', + AirFryerCookModes.SHRIMP: 'Shrimp', + AirFryerCookModes.BACON: 'Bacon', + AirFryerCookModes.FROZEN: 'Frozen', + AirFryerCookModes.FRENCH_FRIES: 'French fries', + AirFryerCookModes.VEGGIES: 'Vegetables', + AirFryerCookModes.TOAST: 'Toast', + AirFryerCookModes.BAKE: 'Bake', + }, + default_cook_mode=AirFryerCookModes.CUSTOM, + default_preset=AirFryerPresets.custom, + time_units=TimeUnits.SECONDS, + # The user manual gives 170-400 F / 77-204 C. The Celsius bounds are + # pulled in to the 5 C step grid so both ends are settable. + temperature_range_f=(170, 400), + temperature_range_c=(80, 200), + # All ten statuses were observed on the unit. 'ready' is a staged + # program waiting for the Start button; 'preheatStop' and 'cookStop' + # are what the unit reports after its Start/Pause button is pressed; + # 'keeping' is Keep Warm, which reports an empty mode string. + status_map=MappingProxyType( + { + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'heating': AirFryerCookStatus.HEATING, + 'preheatStop': AirFryerCookStatus.PREHEAT_STOP, + 'preheatEnd': AirFryerCookStatus.PREHEAT_END, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'cookEnd': AirFryerCookStatus.COOK_END, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'keeping': AirFryerCookStatus.COOKING, + } + ), + ), AirFryerMap( # Cosori Dual Blaze US variant (single-chamber model with dual heating # elements). Uses the same bypassV2 protocol as TurboBlaze (startCook / diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index faa8b962..8b432d85 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -26,6 +26,7 @@ from pyvesync.const import ( AIRFRYER_PID_MAP, AirFryerCookStatus, + AirFryerFeatures, AirFryerPresetRecipe, ConnectionStatus, TemperatureUnits, @@ -521,6 +522,21 @@ async def end(self, chamber: int = 1) -> bool: self.state.set_standby() return True + async def cook_from_preheat(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if AirFryerFeatures.PREHEAT not in self.features: + logger.info('Preheat feature not supported on this fryer.') + return False + if self.state.cook_status != AirFryerCookStatus.PREHEAT_END: + logger.debug('Cannot start cook from preheat for %s', self.device_name) + return False + resp = await self.call_bypassv2_api(payload_method='preheatCook') + r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) + if r is None: + return False + self.state.set_state(cook_status=AirFryerCookStatus.COOKING) + return True + async def set_mode_from_recipe(self, recipe: AirFryerPresetRecipe) -> bool: payload_method = 'startCook' data = self._build_cook_request(recipe) diff --git a/src/tests/api/vesynckitchen/CS358-AF.yaml b/src/tests/api/vesynckitchen/CS358-AF.yaml new file mode 100644 index 00000000..d4c43269 --- /dev/null +++ b/src/tests/api/vesynckitchen/CS358-AF.yaml @@ -0,0 +1,26 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CS358-AF-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: CS358-AF-CID + method: bypassV2 + payload: + data: {} + method: getAirfryerStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/call_json_fryers.py b/src/tests/call_json_fryers.py index 1ca53a37..de1e3e68 100644 --- a/src/tests/call_json_fryers.py +++ b/src/tests/call_json_fryers.py @@ -80,6 +80,19 @@ class AirFryerDefaults: preheat_time=AirFryerDefaults.preheat_time_s, ) +# Values the CS358-AF reports for a manual program started from the cloud with +# AirFryerPresets.custom. Captured from a physical US unit. +CS358_RECIPE = AirFryerPresetRecipe( + recipe_name="Manual Cook", + cook_mode="Custom", + recipe_id=1, + recipe_type=3, + target_temp=AirFryerDefaults.cook_temp_f, + temp_unit=AirFryerDefaults.temp_unit, + cook_time=AirFryerDefaults.cook_time_s, + preheat_time=AirFryerDefaults.preheat_time_s, +) + TF101S_RECIPE = AirFryerPresetRecipe( recipe_name="Air Fry", cook_mode="AirFry", @@ -133,6 +146,35 @@ class AirFryerDefaults: "currentTemp": 89, "shakeStatus": 0, }, + "CS358-AF": { + "stepArray": [ + { + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": CS358_RECIPE.cook_mode, + "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds + "shakeTime": 0, + "cookEndTime": 0, + "recipeName": CS358_RECIPE.recipe_name, + "recipeId": CS358_RECIPE.recipe_id, + "recipeType": CS358_RECIPE.recipe_type, + } + ], + "cookMode": "programs", + "tempUnit": AirFryerDefaults.temp_unit.label, + "stepIndex": 0, + "cookStatus": AirFryerDefaults.cook_status.value, + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 1791695863, + "totalTimeRemaining": AirFryerDefaults.cook_last_time_s, + # The physical unit reported 0 here in every state, including cooking. + "currentTemp": 0, + "appointLastTime": 0, + "shakeStatus": 0, + }, "CAF-TF101S": { "statusList": [ { @@ -189,6 +231,22 @@ class AirFryerDefaults: "currentTemp": 43, "shakeStatus": 0, }, + "CS358-AF": { + "stepArray": [], + "cookMode": "normal", + "tempUnit": AirFryerDefaults.temp_unit.label, + "stepIndex": 0, + "cookStatus": "standby", + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 0, + "totalTimeRemaining": 0, + "currentTemp": 0, + "appointLastTime": 0, + "shakeStatus": 0, + }, "CAF-TF101S": { "statusList": [ {"cookStatus": "standby", "chamber": 1}, @@ -205,6 +263,7 @@ class AirFryerDefaults: 'CS158-AF': deepcopy(FunctionResponsesV1), 'CAF-DC601S': deepcopy(FunctionResponsesV2), 'CAF-TF101S': deepcopy(FunctionResponsesV2), + 'CS358-AF': deepcopy(FunctionResponsesV2), } @@ -218,6 +277,9 @@ class AirFryerDefaults: "CAF-TF101S": build_bypass_v2_response( inner_result=deepcopy(AIR_FRYER_COOKING_DETAILS["CAF-TF101S"]), ), + "CS358-AF": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_COOKING_DETAILS["CS358-AF"]), + ), } @@ -231,4 +293,25 @@ class AirFryerDefaults: "CAF-TF101S": build_bypass_v2_response( inner_result=deepcopy(AIR_FRYER_STANDBY_DETAILS["CAF-TF101S"]), ), + "CS358-AF": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_STANDBY_DETAILS["CS358-AF"]), + ), } + + +# CS358-AF once preheat has finished: the cook step is still queued in +# stepArray and the unit waits for preheatCook (or its Start button). +CS358_PREHEAT_END_DETAILS = deepcopy(AIR_FRYER_COOKING_DETAILS["CS358-AF"]) +CS358_PREHEAT_END_DETAILS.update( + { + "cookStatus": "preheatEnd", + "preheatSetTime": 240, + "preheatLastTime": 0, + "preheatTemp": AirFryerDefaults.cook_temp_f, + "totalTimeRemaining": AirFryerDefaults.cook_time_s, + } +) +CS358_PREHEAT_END_DETAILS["stepArray"][0]["cookLastTime"] = AirFryerDefaults.cook_time_s +CS358_PREHEAT_END_RESPONSE = build_bypass_v2_response( + inner_result=deepcopy(CS358_PREHEAT_END_DETAILS), +) diff --git a/src/tests/test_fryer_preheat.py b/src/tests/test_fryer_preheat.py new file mode 100644 index 00000000..d5dc34f6 --- /dev/null +++ b/src/tests/test_fryer_preheat.py @@ -0,0 +1,119 @@ +"""Tests for continuing from preheat into cooking on bypassV2 fryers. + +`VeSyncTurboBlazeFryer.cook_from_preheat` sends `preheatCook`, which was +confirmed on a physical CS358-AF: once preheat has finished the unit keeps the +queued cook step and waits, and `preheatCook` starts that step. +""" + +from copy import deepcopy + +import call_json_fryers +from base_test_cases import TestBase +from defaults import build_bypass_v2_response +from utils import parse_args + +from pyvesync.const import AirFryerCookStatus, AirFryerFeatures, TemperatureUnits + +BYPASS_V2_URL = '/cloud/v2/deviceManaged/bypassV2' + + +class TestFryerCookFromPreheat(TestBase): + """Request and state tests for cook_from_preheat on bypassV2 fryers.""" + + def _preheated_fryer(self): + fryer = self.get_device('air_fryers', 'CS358-AF') + self.mock_api.return_value = (call_json_fryers.CS358_PREHEAT_END_RESPONSE, 200) + self.run_in_loop(fryer.update) + return fryer + + def test_update_reports_preheat_end_with_cook_step(self): + fryer = self._preheated_fryer() + defaults = call_json_fryers.AirFryerDefaults + assert fryer.state.cook_status == AirFryerCookStatus.PREHEAT_END + assert fryer.state.cook_set_temp == defaults.cook_temp_f + assert fryer.state.cook_set_time == defaults.cook_time_s + assert fryer.state.cook_mode == 'Custom' + + def test_cook_from_preheat_sends_preheat_cook(self): + fryer = self._preheated_fryer() + self.mock_api.reset_mock() + self.mock_api.return_value = (build_bypass_v2_response(inner_result={}), 200) + + assert self.run_in_loop(fryer.cook_from_preheat) is True + + assert self.mock_api.call_count == 1 + kwargs = parse_args(self.mock_api) + assert kwargs['url'] == BYPASS_V2_URL + payload = kwargs['json_object']['payload'] + assert payload['method'] == 'preheatCook' + assert payload['data'] == {} + assert fryer.state.cook_status == AirFryerCookStatus.COOKING + + def test_cook_from_preheat_requires_preheat_end(self): + fryer = self.get_device('air_fryers', 'CS358-AF') + self.mock_api.return_value = ( + call_json_fryers.DETAILS_RESPONSES_COOKING['CS358-AF'], + 200, + ) + self.run_in_loop(fryer.update) + self.mock_api.reset_mock() + + assert self.run_in_loop(fryer.cook_from_preheat) is False + assert self.mock_api.call_count == 0 + + def test_cook_from_preheat_requires_preheat_feature(self): + """The Dual Blaze shares the class but has no preheat function.""" + fryer = self.get_device('air_fryers', 'CAF-P583S') + assert AirFryerFeatures.PREHEAT not in fryer.features + # Force the status so only the feature check can stop the call. + fryer.state.cook_status = AirFryerCookStatus.PREHEAT_END + self.mock_api.reset_mock() + + assert self.run_in_loop(fryer.cook_from_preheat) is False + assert self.mock_api.call_count == 0 + + def test_cook_from_preheat_failure_leaves_state(self): + fryer = self._preheated_fryer() + self.mock_api.reset_mock() + self.mock_api.return_value = (None, 200) + + assert self.run_in_loop(fryer.cook_from_preheat) is False + assert fryer.state.cook_status == AirFryerCookStatus.PREHEAT_END + + +class TestCS358TemperatureRange(TestBase): + """The declared range must be settable at both ends in both units.""" + + def test_range_ends_are_settable(self): + fryer = self.get_device('air_fryers', 'CS358-AF') + for unit in (TemperatureUnits.FAHRENHEIT, TemperatureUnits.CELSIUS): + fryer.temp_unit = unit + assert fryer.prepare_temperature(fryer.min_temp) == fryer.min_temp + assert fryer.prepare_temperature(fryer.max_temp) == fryer.max_temp + + +class TestCS358Statuses(TestBase): + """Every status the physical unit reported maps to a library status.""" + + def test_reported_statuses_are_mapped(self): + fryer = self.get_device('air_fryers', 'CS358-AF') + expected = { + 'ready': AirFryerCookStatus.COOK_STOP, + 'heating': AirFryerCookStatus.HEATING, + 'preheatStop': AirFryerCookStatus.PREHEAT_STOP, + 'preheatEnd': AirFryerCookStatus.PREHEAT_END, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'cookEnd': AirFryerCookStatus.COOK_END, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'keeping': AirFryerCookStatus.COOKING, + } + for reported, status in expected.items(): + details = deepcopy(call_json_fryers.CS358_PREHEAT_END_DETAILS) + details['cookStatus'] = reported + self.mock_api.return_value = ( + build_bypass_v2_response(inner_result=details), + 200, + ) + self.run_in_loop(fryer.update) + assert fryer.state.cook_status == status, reported diff --git a/src/tests/test_fryers.py b/src/tests/test_fryers.py index 2cdb4aff..ff66a101 100644 --- a/src/tests/test_fryers.py +++ b/src/tests/test_fryers.py @@ -61,6 +61,13 @@ call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-TF101S"], id="CAF-TF101S.update.standby", ), + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CS358-AF", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CS358-AF"], + id="CS358-AF.update.standby", + ), ] DETAILS_PARAMS_COOKING = [ @@ -85,6 +92,13 @@ call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-TF101S"], id="CAF-TF101S.update.cooking", ), + pytest.param( + const.AirFryerCookStatus.COOKING, + "CS358-AF", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CS358-AF"], + id="CS358-AF.update.cooking", + ), ] @@ -143,6 +157,7 @@ class TestFryers(TestBase): "CS158-AF": [], "CAF-DC601S": [], "CAF-TF101S": [], + "CS358-AF": [], } @pytest.mark.parametrize(