From fbfc4e5547cf00939111628d31c04800b183247c Mon Sep 17 00:00:00 2001 From: arypien Date: Sun, 2 Aug 2026 11:30:50 +0200 Subject: [PATCH 1/4] feat: add CAF-DC111S-AEU air fryer support --- src/pyvesync/device_map.py | 11 ++ src/pyvesync/devices/vesynckitchen.py | 122 +++++++++++++++++++ src/pyvesync/models/fryer_models.py | 31 +++++ src/tests/call_json_fryers.py | 144 ++++++++++++++++++++++ src/tests/test_fryers_dc111.py | 168 ++++++++++++++++++++++++++ 5 files changed, 476 insertions(+) create mode 100644 src/tests/call_json_fryers.py create mode 100644 src/tests/test_fryers_dc111.py diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..ef5d512a 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1093,6 +1093,17 @@ class ThermostatMap(DeviceMapTemplate): air_fryer_modules: list[AirFryerMap] = [ + AirFryerMap( + class_name='VeSyncAirFryerDC111', + module=vesynckitchen, + dev_types=['CAF-DC111S-AEU'], + device_alias='Turbo Tower Pro Air Fryer', + model_display='CAF-DC111S-AEU', + model_name='Turbo Tower Pro Smart Air Fryer', + setup_entry='CAF-DC111S-AEU', + temperature_range_c=(40, 230), + temperature_range_f=(100, 450), + ), AirFryerMap( class_name='VeSyncAirFryer158', module=vesynckitchen, diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index b1e54997..460ed24e 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -35,6 +35,7 @@ from pyvesync.base_devices import FryerState, VeSyncFryer from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus +from pyvesync.utils.device_mixins import BypassV2Mixin from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger @@ -651,3 +652,124 @@ async def _status_api(self, json_cmd: dict) -> bool: self.state.status_request(json_cmd) await self.update() return True + + +class VeSyncAirFryerDC111(BypassV2Mixin, VeSyncFryer): + """Cosori Turbo Tower Pro dual-chamber air fryer.""" + + __slots__ = ( + 'chambers', + 'sync_type', + 'temp_unit', + 'work_chamber', + ) + + def __init__( + self, + details, + manager, + feature_map, + ) -> None: + """Initialize CAF-DC111S-AEU.""" + super().__init__(details, manager, feature_map) + + self.chambers: dict[int, dict] = {} + self.temp_unit: str | None = None + self.sync_type: int | None = None + self.work_chamber: int | None = None + + async def get_details(self) -> None: + """Read status of both chambers.""" + response = await self.call_bypassv2_api( + 'getAirfryerMultiStatus', + data={}, + ) + + if not response or response.get('code') != 0: + return + + outer = response.get('result') or {} + if outer.get('code') not in (None, 0): + return + + result = outer.get('result') or {} + + self.temp_unit = result.get('tempUnit') + self.sync_type = result.get('syncType') + self.work_chamber = result.get('workChamber') + + self.chambers = { + int(item['chamber']): item + for item in result.get('statusList', []) + if item.get('chamber') is not None + } + + async def prepare_program( + self, + chamber: int, + temperature: int, + minutes: int, + mode: str = 'AirFry', + ) -> bool: + """Prepare a cooking program. + + The appliance still requires physical confirmation with the Start button. + """ + if chamber not in (1, 2): + raise ValueError('chamber must be 1 or 2') + + if minutes <= 0: + raise ValueError('minutes must be greater than zero') + + data = { + 'accountId': self.manager.account_id, + 'cookConfigs': [ + { + 'chamber': chamber, + 'cookSetTime': minutes * 60, + 'cookTemp': temperature, + 'mode': mode, + 'recipeId': 14, + 'recipeName': 'Air Fry', + 'recipeType': 3, + 'shakeTime': 0, + } + ], + 'readyStart': True, + 'syncType': 0, + 'tempUnit': 'c', + 'workChamber': chamber, + } + + response = await self.call_bypassv2_api( + 'startMultiCook', + data=data, + ) + + return bool( + response + and response.get('code') == 0 + and (response.get('result') or {}).get('code') == 0 + ) + + async def stop_chamber(self, chamber: int) -> bool: + """End the prepared or running program for one chamber.""" + if chamber not in (1, 2): + raise ValueError('chamber must be 1 or 2') + + response = await self.call_bypassv2_api( + 'endCook', + data={'chamber': chamber}, + ) + + if not response or response.get('code') != 0: + return False + + inner = response.get('result') or {} + code = inner.get('code') + + # 11923000 oznacza, że komora nie ma programu do zatrzymania. + if code == 11923000: + return False + + return code == 0 diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index bf147253..b0397832 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from pyvesync.models.base_models import ResponseBaseModel +from pyvesync.models.bypass_models import BypassV2InnerResult @dataclass @@ -32,3 +33,33 @@ class FryerBaseReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" cookStatus: str + + + +@dataclass +class AirFryerChamberStatus(ResponseBaseModel): + """Status of one CAF-DC111S-AEU cooking chamber.""" + + cookStatus: str + startTime: int + recipeType: int + recipeId: int + recipeName: str + upc: str + holdTime: int + cookSetTime: int + cookTemp: int + mode: str + currentRemainingTime: int + totalTimeRemaining: int + chamber: int + + +@dataclass +class AirFryerMultiStatusResult(BypassV2InnerResult): + """Result returned by getAirfryerMultiStatus.""" + + statusList: list[AirFryerChamberStatus] + tempUnit: str + syncType: int + workChamber: int diff --git a/src/tests/call_json_fryers.py b/src/tests/call_json_fryers.py new file mode 100644 index 00000000..2c9e5356 --- /dev/null +++ b/src/tests/call_json_fryers.py @@ -0,0 +1,144 @@ +"""Mocked API responses for air fryer tests.""" + +from collections import defaultdict +from typing import Any + +DEVICE_TYPE = 'CAF-DC111S-AEU' +SETUP_ENTRY = 'CAF-DC111S-AEU' + +DEVICE_DETAILS = { + 'deviceRegion': 'EU', + 'isOwner': True, + 'authKey': None, + 'deviceName': 'Turbo Tower Pro Smart Air Fryer', + 'deviceImg': '', + 'cid': 'test-dc111-cid', + 'deviceStatus': 'off', + 'connectionStatus': 'online', + 'connectionType': 'WiFi+BTOnboarding+BTNotify', + 'deviceType': DEVICE_TYPE, + 'type': 'SKA', + 'uuid': 'test-dc111-uuid', + 'configModule': 'VS_WFON_CAF-DC111S-AEU_EU', + 'macID': '00:00:00:00:00:00', + 'mode': None, + 'speed': None, + 'currentFirmVersion': None, + 'subDeviceNo': None, + 'subDeviceType': None, + 'deviceFirstSetupTime': '', + 'subDeviceList': None, + 'extension': None, + 'deviceProp': None, +} + + +def bypass_response(result: dict[str, Any] | None = None) -> tuple[dict[str, Any], int]: + """Create a successful bypassV2 response.""" + return ( + { + 'traceId': 'test-trace', + 'code': 0, + 'msg': 'request success', + 'module': None, + 'stacktrace': None, + 'result': { + 'traceId': 'test-trace', + 'code': 0, + **({'result': result} if result is not None else {}), + }, + }, + 200, + ) + + +STATUS_STANDBY = { + 'statusList': [ + { + 'cookStatus': 'standby', + 'startTime': 0, + 'recipeType': 3, + 'recipeId': 14, + 'recipeName': '', + 'upc': '', + 'holdTime': 0, + 'cookSetTime': 0, + 'cookTemp': 0, + 'mode': 'AirFry', + 'currentRemainingTime': 0, + 'totalTimeRemaining': 0, + 'chamber': 1, + }, + { + 'cookStatus': 'standby', + 'startTime': 0, + 'recipeType': 3, + 'recipeId': 14, + 'recipeName': '', + 'upc': '', + 'holdTime': 0, + 'cookSetTime': 0, + 'cookTemp': 0, + 'mode': 'AirFry', + 'currentRemainingTime': 0, + 'totalTimeRemaining': 0, + 'chamber': 2, + }, + ], + 'tempUnit': 'c', + 'syncType': 0, + 'workChamber': 0, +} + + +STATUS_READY = { + 'statusList': [ + { + 'cookStatus': 'ready', + 'startTime': 0, + 'recipeType': 3, + 'recipeId': 14, + 'recipeName': 'Air Fry', + 'upc': '', + 'holdTime': 0, + 'cookSetTime': 300, + 'cookTemp': 180, + 'mode': 'AirFry', + 'currentRemainingTime': 300, + 'totalTimeRemaining': 300, + 'chamber': 1, + }, + { + 'cookStatus': 'standby', + 'startTime': 0, + 'recipeType': 3, + 'recipeId': 14, + 'recipeName': '', + 'upc': '', + 'holdTime': 0, + 'cookSetTime': 0, + 'cookTemp': 0, + 'mode': 'AirFry', + 'currentRemainingTime': 0, + 'totalTimeRemaining': 0, + 'chamber': 2, + }, + ], + 'tempUnit': 'c', + 'syncType': 0, + 'workChamber': 1, +} + + +DETAILS_RESPONSES = { + SETUP_ENTRY: bypass_response(STATUS_STANDBY), +} + + +METHOD_RESPONSES: defaultdict[str, dict[str, tuple[dict[str, Any], int]]] = ( + defaultdict(dict) +) + +METHOD_RESPONSES[SETUP_ENTRY]['get_details'] = bypass_response(STATUS_STANDBY) +METHOD_RESPONSES[SETUP_ENTRY]['prepare_program'] = bypass_response() +METHOD_RESPONSES[SETUP_ENTRY]['stop_chamber'] = bypass_response() diff --git a/src/tests/test_fryers_dc111.py b/src/tests/test_fryers_dc111.py new file mode 100644 index 00000000..3425c5eb --- /dev/null +++ b/src/tests/test_fryers_dc111.py @@ -0,0 +1,168 @@ +"""Tests for CAF-DC111S-AEU air fryer support.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyvesync.device_map import get_device_config +from pyvesync.devices.vesynckitchen import VeSyncAirFryerDC111 +from pyvesync.models.vesync_models import ResponseDeviceDetailsModel +from tests.call_json_fryers import ( + DEVICE_DETAILS, + STATUS_READY, + STATUS_STANDBY, + bypass_response, +) + + +@pytest.fixture +def manager(): + """Return a minimal mocked manager.""" + mocked = AsyncMock() + mocked.account_id = 'test-account' + mocked.token = 'test-token' + mocked.time_zone = 'Europe/Warsaw' + mocked.country_code = 'PL' + mocked.accept_language = 'pl' + mocked.app_version = '5.9.20' + mocked.phone_brand = 'pytest' + mocked.phone_os = 'Android' + mocked.debug_mode = False + mocked.trace_id = 'test-trace' + return mocked + + +@pytest.fixture +def fryer(manager): + """Create the CAF-DC111S-AEU device.""" + details = ResponseDeviceDetailsModel.from_dict(DEVICE_DETAILS) + feature_map = get_device_config('CAF-DC111S-AEU') + + assert feature_map is not None + + return VeSyncAirFryerDC111(details, manager, feature_map) + + +def test_dc111_device_map() -> None: + """The device map selects the new DC111 class.""" + config = get_device_config('CAF-DC111S-AEU') + + assert config is not None + assert config.class_name == 'VeSyncAirFryerDC111' + assert config.setup_entry == 'CAF-DC111S-AEU' + + +@pytest.mark.asyncio +async def test_get_details_reads_both_chambers(fryer) -> None: + """Status response populates both cooking chambers.""" + mocked = AsyncMock( + return_value=bypass_response(STATUS_STANDBY)[0] + ) + + with patch.object( + VeSyncAirFryerDC111, + 'call_bypassv2_api', + new=mocked, + ): + await fryer.get_details() + + assert fryer.temp_unit == 'c' + assert fryer.sync_type == 0 + assert fryer.work_chamber == 0 + assert set(fryer.chambers) == {1, 2} + assert fryer.chambers[1]['cookStatus'] == 'standby' + assert fryer.chambers[2]['cookStatus'] == 'standby' + + +@pytest.mark.asyncio +async def test_get_details_ready_program(fryer) -> None: + """Prepared program is represented as ready.""" + mocked = AsyncMock( + return_value=bypass_response(STATUS_READY)[0] + ) + + with patch.object( + VeSyncAirFryerDC111, + 'call_bypassv2_api', + new=mocked, + ): + await fryer.get_details() + + chamber = fryer.chambers[1] + + assert fryer.work_chamber == 1 + assert chamber['cookStatus'] == 'ready' + assert chamber['cookTemp'] == 180 + assert chamber['cookSetTime'] == 300 + assert chamber['currentRemainingTime'] == 300 + + +@pytest.mark.asyncio +async def test_prepare_program(fryer) -> None: + """Prepare sends startMultiCook with correct chamber data.""" + mocked = AsyncMock(return_value=bypass_response()[0]) + + with patch.object( + VeSyncAirFryerDC111, + 'call_bypassv2_api', + new=mocked, + ): + result = await fryer.prepare_program( + chamber=1, + temperature=180, + minutes=5, + ) + + assert result is True + mocked.assert_awaited_once() + + method = mocked.await_args.args[0] + data = mocked.await_args.kwargs['data'] + + assert method == 'startMultiCook' + assert data['workChamber'] == 1 + assert data['readyStart'] is True + + config = data['cookConfigs'][0] + assert config['chamber'] == 1 + assert config['cookTemp'] == 180 + assert config['cookSetTime'] == 300 + assert config['mode'] == 'AirFry' + + +@pytest.mark.asyncio +async def test_stop_chamber(fryer) -> None: + """End prepared or running program.""" + mocked = AsyncMock(return_value=bypass_response()[0]) + + with patch.object( + VeSyncAirFryerDC111, + 'call_bypassv2_api', + new=mocked, + ): + result = await fryer.stop_chamber(1) + + assert result is True + mocked.assert_awaited_once_with( + 'endCook', + data={'chamber': 1}, + ) + + +@pytest.mark.asyncio +async def test_stop_empty_chamber_returns_false(fryer) -> None: + """Error 11923000 means there was no program to stop.""" + response = bypass_response()[0] + response['result']['code'] = 11923000 + response['result']['msg'] = 'af_iot_bypass_end_cook' + + mocked = AsyncMock(return_value=response) + + with patch.object( + VeSyncAirFryerDC111, + 'call_bypassv2_api', + new=mocked, + ): + result = await fryer.stop_chamber(1) + + assert result is False From 838e3c6b1f3ecdc0655bd0b4a4756fd1549f502f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:40:31 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/device_map.py | 2 +- src/pyvesync/models/fryer_models.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index ef5d512a..edffb777 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1112,7 +1112,7 @@ class ThermostatMap(DeviceMapTemplate): model_display='CS158/159/168/169-AF Series', model_name='Smart/Pro/Pro Gen 2 5.8 Qt. Air Fryer', setup_entry='CS137-AF/CS158-AF', - ) + ), ] """List of ['AirFryerMap'][pyvesync.device_map.AirFryerMap] configuration for air fryer devices.""" diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index b0397832..868b35f6 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -35,7 +35,6 @@ class FryerBaseReturnStatus(ResponseBaseModel): cookStatus: str - @dataclass class AirFryerChamberStatus(ResponseBaseModel): """Status of one CAF-DC111S-AEU cooking chamber.""" From 2eb1a4d9751675e8e678b0b02666403abe2a4f28 Mon Sep 17 00:00:00 2001 From: arypien Date: Sun, 2 Aug 2026 11:45:30 +0200 Subject: [PATCH 3/4] fix: satisfy ruff checks for DC111 support --- src/pyvesync/devices/vesynckitchen.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index 460ed24e..bbce8ed7 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -49,6 +49,8 @@ logger = logging.getLogger(__name__) +AIR_FRYER_NO_ACTIVE_PROGRAM = 11923000 + # Status refresh interval in seconds # API calls outside of interval are automatically refreshed @@ -666,9 +668,9 @@ class VeSyncAirFryerDC111(BypassV2Mixin, VeSyncFryer): def __init__( self, - details, - manager, - feature_map, + details: ResponseDeviceDetailsModel, + manager: VeSync, + feature_map: AirFryerMap, ) -> None: """Initialize CAF-DC111S-AEU.""" super().__init__(details, manager, feature_map) @@ -768,8 +770,8 @@ async def stop_chamber(self, chamber: int) -> bool: inner = response.get('result') or {} code = inner.get('code') - # 11923000 oznacza, że komora nie ma programu do zatrzymania. - if code == 11923000: + # The chamber has no prepared or running program to stop. + if code == AIR_FRYER_NO_ACTIVE_PROGRAM: return False return code == 0 From a89d8ccd08a16f31b5c397bbc3e01e6498accdc9 Mon Sep 17 00:00:00 2001 From: arypien Date: Sun, 2 Aug 2026 14:39:00 +0200 Subject: [PATCH 4/4] feat: add dual-chamber sync and match support --- src/pyvesync/devices/vesynckitchen.py | 52 +++++++++++++++++ src/tests/test_fryers_dc111.py | 83 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index bbce8ed7..b830da69 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -754,6 +754,58 @@ async def prepare_program( and (response.get('result') or {}).get('code') == 0 ) + async def prepare_both_chambers( + self, + chamber_1: dict[str, int | str], + chamber_2: dict[str, int | str], + sync_type: int, + ) -> bool: + """Prepare both cooking chambers. + + sync_type 1 synchronizes finishing times. + sync_type 2 applies matching settings to both chambers. + """ + if sync_type not in (1, 2): + msg = 'sync_type must be 1 or 2' + raise ValueError(msg) + + def cook_config( + chamber: int, + config: dict[str, int | str], + ) -> dict[str, int | str]: + return { + 'chamber': chamber, + 'cookSetTime': int(config['minutes']) * 60, + 'cookTemp': int(config['temperature']), + 'mode': str(config.get('mode', 'AirFry')), + 'recipeId': 14, + 'recipeName': 'Air Fry', + 'recipeType': 3, + 'shakeTime': 0, + } + + data = { + 'accountId': self.manager.account_id, + 'cookConfigs': [ + cook_config(1, chamber_1), + cook_config(2, chamber_2), + ], + 'readyStart': True, + 'syncType': sync_type, + 'tempUnit': 'c', + 'workChamber': 4, + } + + response = await self.call_bypassv2_api( + 'startMultiCook', + data=data, + ) + return bool( + response + and response.get('code') == 0 + and (response.get('result') or {}).get('code') == 0 + ) + async def stop_chamber(self, chamber: int) -> bool: """End the prepared or running program for one chamber.""" if chamber not in (1, 2): diff --git a/src/tests/test_fryers_dc111.py b/src/tests/test_fryers_dc111.py index 3425c5eb..db8b7a31 100644 --- a/src/tests/test_fryers_dc111.py +++ b/src/tests/test_fryers_dc111.py @@ -166,3 +166,86 @@ async def test_stop_empty_chamber_returns_false(fryer) -> None: result = await fryer.stop_chamber(1) assert result is False + + +@pytest.mark.asyncio +async def test_prepare_both_chambers_sync_finish( + fryer: VeSyncAirFryerDC111, +) -> None: + """Prepare both chambers with synchronized finishing times.""" + mocked = AsyncMock(return_value=bypass_response()[0]) + + with patch.object( + VeSyncAirFryerDC111, + "call_bypassv2_api", + new=mocked, + ): + result = await fryer.prepare_both_chambers( + chamber_1={ + "temperature": 180, + "minutes": 10, + "mode": "AirFry", + }, + chamber_2={ + "temperature": 195, + "minutes": 15, + "mode": "AirFry", + }, + sync_type=1, + ) + + assert result is True + + method = mocked.await_args.args[0] + data = mocked.await_args.kwargs["data"] + + assert method == "startMultiCook" + assert data["syncType"] == 1 + assert data["workChamber"] == 4 + assert len(data["cookConfigs"]) == 2 + + chamber_1 = data["cookConfigs"][0] + chamber_2 = data["cookConfigs"][1] + + assert chamber_1["chamber"] == 1 + assert chamber_1["cookSetTime"] == 600 + assert chamber_1["cookTemp"] == 180 + + assert chamber_2["chamber"] == 2 + assert chamber_2["cookSetTime"] == 900 + assert chamber_2["cookTemp"] == 195 + + +@pytest.mark.asyncio +async def test_prepare_both_chambers_match( + fryer: VeSyncAirFryerDC111, +) -> None: + """Prepare both chambers with matching settings.""" + mocked = AsyncMock(return_value=bypass_response()[0]) + matching = { + "temperature": 195, + "minutes": 10, + "mode": "AirFry", + } + + with patch.object( + VeSyncAirFryerDC111, + "call_bypassv2_api", + new=mocked, + ): + result = await fryer.prepare_both_chambers( + chamber_1=matching, + chamber_2=matching, + sync_type=2, + ) + + assert result is True + + data = mocked.await_args.kwargs["data"] + + assert data["syncType"] == 2 + assert data["workChamber"] == 4 + assert data["cookConfigs"][0]["cookTemp"] == 195 + assert data["cookConfigs"][1]["cookTemp"] == 195 + assert data["cookConfigs"][0]["cookSetTime"] == 600 + assert data["cookConfigs"][1]["cookSetTime"] == 600