Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/pyvesync/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,9 @@ class AirFryerCookModes(StrEnum):
VEGGIES = 'veggies'
FRENCH_FRIES = 'french_fries'
CHICKEN = 'chicken'
BACON = 'bacon'
SHRIMP = 'shrimp'
TOAST = 'toast'


class AirFryerFeatures(Features):
Expand Down
70 changes: 70 additions & 0 deletions src/pyvesync/device_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
16 changes: 16 additions & 0 deletions src/pyvesync/devices/vesynckitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from pyvesync.const import (
AIRFRYER_PID_MAP,
AirFryerCookStatus,
AirFryerFeatures,
AirFryerPresetRecipe,
ConnectionStatus,
TemperatureUnits,
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/tests/api/vesynckitchen/CS358-AF.yaml
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions src/tests/call_json_fryers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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},
Expand All @@ -205,6 +263,7 @@ class AirFryerDefaults:
'CS158-AF': deepcopy(FunctionResponsesV1),
'CAF-DC601S': deepcopy(FunctionResponsesV2),
'CAF-TF101S': deepcopy(FunctionResponsesV2),
'CS358-AF': deepcopy(FunctionResponsesV2),
}


Expand All @@ -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"]),
),
}


Expand All @@ -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),
)
119 changes: 119 additions & 0 deletions src/tests/test_fryer_preheat.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading