diff --git a/README.md b/README.md index 5ceff41b..4c2975de 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,7 @@ pip install pyvesync 1. Cosori 3.7 Quart Air Fryer (CS137-AF) 2. Cosori 5.8 Quart Air Fryer (CS158-AF) +3. Cosori Turbo Tower Pro Smart Air Fryer (CAF-DC111S-AEU) ### Thermostats diff --git a/docs/devices/kitchen.md b/docs/devices/kitchen.md index 363f16d1..8f4781db 100644 --- a/docs/devices/kitchen.md +++ b/docs/devices/kitchen.md @@ -1,6 +1,8 @@ # VeSync Air Fryers -Currently the only supported air fryer is the Cosori 3.7 and 5.8 Quart Air Fryer. This device is a smart air fryer that can be monitored and controlled via this library. +The supported air fryers are the Cosori 3.7 and 5.8 Quart Air Fryer and the Cosori Turbo Tower Pro Smart dual-chamber Air Fryer. These devices can be monitored and controlled via this library. + +Programs prepared on the Turbo Tower Pro through the API must still be started with the Start button on the appliance. ::: pyvesync.devices.vesynckitchen options: @@ -21,6 +23,27 @@ Currently the only supported air fryer is the Cosori 3.7 and 5.8 Quart Air Frye filters: - "!^_.*" +::: pyvesync.devices.vesynckitchen.AirFryerDC111State + options: + show_root_heading: true + members_order: source + filters: + - "!^_.*" + +::: pyvesync.devices.vesynckitchen.FryerChamberState + options: + show_root_heading: true + members_order: source + filters: + - "!^_.*" + +::: pyvesync.devices.vesynckitchen.VeSyncAirFryerDC111 + options: + show_root_heading: true + members_order: source + filters: + - "!^_.*" + ::: pyvesync.base_devices.fryer_base.FryerState options: show_root_heading: true diff --git a/docs/supported_devices.md b/docs/supported_devices.md index c80bd7e2..7643cf33 100644 --- a/docs/supported_devices.md +++ b/docs/supported_devices.md @@ -43,6 +43,7 @@ The VeSync API supports a variety of devices. The following is a list of devices 7. Air Fryers - [CS137][pyvesync.devices.vesynckitchen.VeSyncAirFryer158] - 3.7qt Air Fryer - [CS158][pyvesync.devices.vesynckitchen.VeSyncAirFryer158] - 5.8qt Air Fryer + - [CAF-DC111S][pyvesync.devices.vesynckitchen.VeSyncAirFryerDC111] - Turbo Tower Pro Smart dual-chamber Air Fryer 8. Thermostats - [Aura][pyvesync.devices.vesyncthermostat] Thermostat @@ -120,6 +121,7 @@ been verified. | ------ | ----- | ----- | ----- | | Cosori 3.7qt Air Fryer | CS137 | ✔ | ✔ | | Cosori 5.8qt Air Fryer | CS158 | ✔ | ✔ | +| Cosori Turbo Tower Pro Smart Air Fryer | CAF-DC111S | ✔ | ✔ | ### Thermostats diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..edffb777 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, @@ -1101,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/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index b1e54997..6c45d7d9 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -28,13 +28,17 @@ from __future__ import annotations import logging +import math import time +from dataclasses import dataclass from typing import TYPE_CHECKING, TypeVar from typing_extensions import deprecated from pyvesync.base_devices import FryerState, VeSyncFryer from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus +from pyvesync.models.fryer_models import AirFryerMultiStatusResult +from pyvesync.utils.device_mixins import BypassV2Mixin, process_bypassv2_result from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger @@ -48,6 +52,22 @@ logger = logging.getLogger(__name__) +AIR_FRYER_NO_ACTIVE_PROGRAM = 11923000 +"""Inner result code returned by `endCook` when a chamber has nothing to stop.""" + +DC111_RECIPES: dict[str, tuple[int, str]] = { + 'AirFry': (14, 'Air Fry'), +} +"""Turbo Tower Pro cooking modes as {mode: (recipeId, recipeName)}. + +Only modes confirmed on a real device are listed. To add one, start it from the +fryer's panel and read `mode`, `recipe_id` and `recipe_name` from `state.chambers`. +""" +DC111_RECIPE_TYPE = 3 +DC111_BOTH_CHAMBERS = 4 +DC111_SYNC_FINISH = 1 +DC111_SYNC_MATCH = 2 + # Status refresh interval in seconds # API calls outside of interval are automatically refreshed @@ -651,3 +671,333 @@ async def _status_api(self, json_cmd: dict) -> bool: self.state.status_request(json_cmd) await self.update() return True + + +@dataclass +class FryerChamberState: + """State of one Turbo Tower Pro cooking chamber. + + Attributes: + chamber (int): Chamber number, 1 or 2. + cook_status (str): Raw status from the API, e.g. `standby` or `ready`. + mode (str): Cooking mode, e.g. `AirFry`. + recipe_id (int | None): Recipe ID of the program. + recipe_name (str): Recipe name of the program. + cook_set_temp (int | None): Set temperature in the device unit. + cook_set_time (int | None): Set cooking time in minutes. + remaining_time (int): Minutes remaining in the current program. + total_time_remaining (int): Minutes remaining including synced waits. + """ + + chamber: int + cook_status: str = 'standby' + mode: str = '' + recipe_id: int | None = None + recipe_name: str = '' + cook_set_temp: int | None = None + cook_set_time: int | None = None + remaining_time: int = 0 + total_time_remaining: int = 0 + + @property + def is_active(self) -> bool: + """Return True if the chamber has a prepared or running program.""" + return self.cook_status != 'standby' + + +def _minutes(seconds: int) -> int: + """Convert seconds to whole minutes, rounding up.""" + return math.ceil(seconds / 60) + + +class AirFryerDC111State(FryerState): + """State of the Turbo Tower Pro dual-chamber air fryer. + + Per-chamber state is in `chambers`. The `cook_status`, `cook_set_temp`, + `cook_set_time` and `remaining_time` properties report the active chamber, + matching the attributes of the single-chamber air fryers. + + Attributes: + chambers (dict[int, FryerChamberState]): State of chambers 1 and 2. + sync_type (int): 0 single chamber, 1 synced finish, 2 matching settings. + work_chamber (int): Chamber in use, 0 none, 1 or 2, 4 both. + temp_unit (str): `celsius` or `fahrenheit`. + """ + + __slots__ = ('_temp_unit', 'chambers', 'sync_type', 'work_chamber') + + def __init__( + self, + device: VeSyncAirFryerDC111, + details: ResponseDeviceDetailsModel, + feature_map: AirFryerMap, + ) -> None: + """Initialize the Turbo Tower Pro state.""" + super().__init__(device, details, feature_map) + self.chambers: dict[int, FryerChamberState] = { + 1: FryerChamberState(chamber=1), + 2: FryerChamberState(chamber=2), + } + self.sync_type: int = 0 + self.work_chamber: int = 0 + self._temp_unit: str = 'celsius' + + @property + def temp_unit(self) -> str: + """Return temperature unit, `celsius` or `fahrenheit`.""" + return self._temp_unit + + @temp_unit.setter + def temp_unit(self, temp_unit: str) -> None: + """Set temperature unit from the API value.""" + if temp_unit.lower().startswith('f'): + self._temp_unit = 'fahrenheit' + else: + self._temp_unit = 'celsius' + + @property + def active_chamber(self) -> FryerChamberState: + """Return the first chamber with a program, else chamber 1.""" + for chamber in self.chambers.values(): + if chamber.is_active: + return chamber + return self.chambers[1] + + @property + def cook_status(self) -> str: + """Return cook status of the active chamber.""" + return self.active_chamber.cook_status + + @property + def cook_set_temp(self) -> int | None: + """Return set temperature of the active chamber.""" + return self.active_chamber.cook_set_temp + + @property + def cook_set_time(self) -> int | None: + """Return set cooking time in minutes of the active chamber.""" + return self.active_chamber.cook_set_time + + @property + def remaining_time(self) -> int: + """Return minutes remaining in the active chamber.""" + return self.active_chamber.remaining_time + + @property + def current_temp(self) -> int | None: + """Return current temperature, not reported by this model.""" + return None + + @property + def preheat_set_time(self) -> int | None: + """Return preheat time, not supported by this model.""" + return None + + def status_response(self, result: AirFryerMultiStatusResult) -> None: + """Set state from a getAirfryerMultiStatus result.""" + self.temp_unit = result.tempUnit + self.sync_type = result.syncType + self.work_chamber = result.workChamber + for status in result.statusList: + active = status.cookStatus != 'standby' + self.chambers[status.chamber] = FryerChamberState( + chamber=status.chamber, + cook_status=status.cookStatus, + mode=status.mode, + recipe_id=status.recipeId, + recipe_name=status.recipeName, + cook_set_temp=status.cookTemp if active else None, + cook_set_time=_minutes(status.cookSetTime) if active else None, + remaining_time=_minutes(status.currentRemainingTime), + total_time_remaining=_minutes(status.totalTimeRemaining), + ) + + +class VeSyncAirFryerDC111(BypassV2Mixin, VeSyncFryer): + """Cosori Turbo Tower Pro dual-chamber air fryer (CAF-DC111S). + + Programs prepared through the API must still be started with the Start + button on the appliance. + + Attributes: + state (AirFryerDC111State): Air fryer state. + cook_modes (list[str]): Supported cooking modes. + temperature_range (tuple[int, int]): Allowed temperatures in `temp_unit`. + """ + + __slots__ = ('_temperature_range_c', '_temperature_range_f') + + def __init__( + self, + details: ResponseDeviceDetailsModel, + manager: VeSync, + feature_map: AirFryerMap, + ) -> None: + """Initialize the Turbo Tower Pro.""" + super().__init__(details, manager, feature_map) + self.state: AirFryerDC111State = AirFryerDC111State(self, details, feature_map) + self._temperature_range_c = feature_map.temperature_range_c + self._temperature_range_f = feature_map.temperature_range_f + + @property + def temp_unit(self) -> str: + """Return temperature unit, `celsius` or `fahrenheit`.""" + return self.state.temp_unit + + @property + def cook_modes(self) -> list[str]: + """Return supported cooking modes.""" + return list(DC111_RECIPES) + + @property + def temperature_range(self) -> tuple[int, int]: + """Return allowed (min, max) temperature in the device unit.""" + if self.state.temp_unit == 'fahrenheit': + return self._temperature_range_f + return self._temperature_range_c + + async def get_details(self) -> None: + """Read status of both chambers.""" + r_dict = await self.call_bypassv2_api('getAirfryerMultiStatus', data={}) + result = process_bypassv2_result( + self, logger, 'get_details', r_dict, AirFryerMultiStatusResult + ) + if result is None: + return + self.state.status_response(result) + self.state.connection_status = ConnectionStatus.ONLINE + self.state.update_ts() + + def _cook_config( + self, + chamber: int, + temperature: int, + minutes: int, + mode: str, + ) -> dict[str, int | str]: + """Build and validate the cook config for one chamber.""" + if chamber not in (1, 2): + msg = 'chamber must be 1 or 2' + raise ValueError(msg) + if minutes <= 0: + msg = 'minutes must be greater than zero' + raise ValueError(msg) + min_temp, max_temp = self.temperature_range + if not min_temp <= temperature <= max_temp: + msg = ( + f'temperature must be between {min_temp} and {max_temp} ' + f'{self.state.temp_unit}' + ) + raise ValueError(msg) + if mode not in DC111_RECIPES: + msg = f'mode must be one of {self.cook_modes}' + raise ValueError(msg) + recipe_id, recipe_name = DC111_RECIPES[mode] + return { + 'chamber': chamber, + 'cookSetTime': minutes * 60, + 'cookTemp': temperature, + 'mode': mode, + 'recipeId': recipe_id, + 'recipeName': recipe_name, + 'recipeType': DC111_RECIPE_TYPE, + 'shakeTime': 0, + } + + async def _start_multi_cook( + self, + cook_configs: list[dict[str, int | str]], + sync_type: int, + work_chamber: int, + ) -> bool: + """Send startMultiCook with the given chamber configs.""" + data = { + 'accountId': self.manager.account_id, + 'cookConfigs': cook_configs, + 'readyStart': True, + 'syncType': sync_type, + 'tempUnit': 'f' if self.state.temp_unit == 'fahrenheit' else 'c', + 'workChamber': work_chamber, + } + r_dict = await self.call_bypassv2_api('startMultiCook', data=data) + return bool( + r_dict + and r_dict.get('code') == 0 + and (r_dict.get('result') or {}).get('code') == 0 + ) + + async def prepare_program( + self, + chamber: int, + temperature: int, + minutes: int, + mode: str = 'AirFry', + ) -> bool: + """Prepare a cooking program on one chamber. + + The appliance still requires physical confirmation with the Start button. + + Args: + chamber (int): Chamber number, 1 or 2. + temperature (int): Temperature in the device unit (`temp_unit`). + minutes (int): Cooking time in minutes. + mode (str): Cooking mode, one of `cook_modes`. + + Raises: + ValueError: If an argument is out of range. + """ + config = self._cook_config(chamber, temperature, minutes, mode) + return await self._start_multi_cook([config], 0, chamber) + + 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. + + Each chamber dict takes `temperature`, `minutes` and optionally `mode`. + The appliance still requires physical confirmation with the Start button. + + Args: + chamber_1 (dict): Program for chamber 1. + chamber_2 (dict): Program for chamber 2. + sync_type (int): 1 synchronizes finishing times, + 2 applies matching settings to both chambers. + + Raises: + ValueError: If an argument is out of range. + """ + if sync_type not in (DC111_SYNC_FINISH, DC111_SYNC_MATCH): + msg = 'sync_type must be 1 or 2' + raise ValueError(msg) + configs = [ + self._cook_config( + number, + int(config['temperature']), + int(config['minutes']), + str(config.get('mode', 'AirFry')), + ) + for number, config in ((1, chamber_1), (2, chamber_2)) + ] + return await self._start_multi_cook(configs, sync_type, DC111_BOTH_CHAMBERS) + + async def stop_chamber(self, chamber: int) -> bool: + """End the prepared or running program for one chamber. + + Returns False if the chamber had no program to stop. + """ + if chamber not in (1, 2): + msg = 'chamber must be 1 or 2' + raise ValueError(msg) + + r_dict = await self.call_bypassv2_api('endCook', data={'chamber': chamber}) + if not r_dict or r_dict.get('code') != 0: + return False + + code = (r_dict.get('result') or {}).get('code') + if code == AIR_FRYER_NO_ACTIVE_PROGRAM: + logger.debug('Chamber %s of %s has no program', chamber, self.device_name) + return False + return code == 0 diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index bf147253..8549beac 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -2,9 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pyvesync.models.base_models import ResponseBaseModel +from pyvesync.models.bypass_models import BypassV2InnerResult @dataclass @@ -32,3 +33,35 @@ class FryerBaseReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" cookStatus: str + + +@dataclass +class AirFryerChamberStatus(ResponseBaseModel): + """Status of one Turbo Tower Pro (CAF-DC111S) cooking chamber. + + Times are in seconds. + """ + + chamber: int + cookStatus: str = 'standby' + startTime: int = 0 + recipeType: int | None = None + recipeId: int | None = None + recipeName: str = '' + upc: str = '' + holdTime: int = 0 + cookSetTime: int = 0 + cookTemp: int = 0 + mode: str = '' + currentRemainingTime: int = 0 + totalTimeRemaining: int = 0 + + +@dataclass +class AirFryerMultiStatusResult(BypassV2InnerResult): + """Result returned by getAirfryerMultiStatus.""" + + statusList: list[AirFryerChamberStatus] = field(default_factory=list) + tempUnit: str = 'c' + syncType: int = 0 + workChamber: int = 0 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..bf654c89 --- /dev/null +++ b/src/tests/test_fryers_dc111.py @@ -0,0 +1,328 @@ +"""Tests for CAF-DC111S-AEU air fryer support.""" + +import asyncio +import copy +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' + + +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, + ): + asyncio.run(fryer.get_details()) + + assert fryer.temp_unit == 'celsius' + assert fryer.state.sync_type == 0 + assert fryer.state.work_chamber == 0 + assert set(fryer.state.chambers) == {1, 2} + assert fryer.state.chambers[1].cook_status == 'standby' + assert fryer.state.chambers[2].cook_status == 'standby' + assert fryer.state.cook_status == 'standby' + assert fryer.state.cook_set_temp is None + assert fryer.state.cook_set_time is None + + +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, + ): + asyncio.run(fryer.get_details()) + + chamber = fryer.state.chambers[1] + + assert fryer.state.work_chamber == 1 + assert chamber.cook_status == 'ready' + assert chamber.mode == 'AirFry' + assert chamber.cook_set_temp == 180 + assert chamber.cook_set_time == 5 + assert chamber.remaining_time == 5 + + # Top-level attributes report the active chamber, as read by Home Assistant. + assert fryer.state.active_chamber is chamber + assert fryer.state.cook_status == 'ready' + assert fryer.state.cook_set_temp == 180 + assert fryer.state.cook_set_time == 5 + assert fryer.state.current_temp is None + assert fryer.state.preheat_set_time is None + + +def test_active_chamber_is_second_when_first_idle(fryer) -> None: + """A program only in chamber 2 is reported at the top level.""" + status = copy.deepcopy(STATUS_READY) + status['statusList'][0], status['statusList'][1] = ( + {**status['statusList'][1], 'chamber': 1}, + {**status['statusList'][0], 'chamber': 2}, + ) + mocked = AsyncMock(return_value=bypass_response(status)[0]) + + with patch.object(VeSyncAirFryerDC111, 'call_bypassv2_api', new=mocked): + asyncio.run(fryer.get_details()) + + assert fryer.state.active_chamber.chamber == 2 + assert fryer.state.cook_status == 'ready' + + +def test_state_serializes(fryer) -> None: + """Device and state can be dumped to dict and JSON.""" + mocked = AsyncMock(return_value=bypass_response(STATUS_READY)[0]) + + with patch.object(VeSyncAirFryerDC111, 'call_bypassv2_api', new=mocked): + asyncio.run(fryer.get_details()) + + state = fryer.to_dict() + assert state['cook_status'] == 'ready' + assert state['temp_unit'] == 'celsius' + assert 'chambers' in state + assert '"cook_set_temp":180' in fryer.state.to_json() + + +def test_fahrenheit_unit(fryer) -> None: + """Fahrenheit status switches validation range and request unit.""" + status = {**STATUS_STANDBY, 'tempUnit': 'f'} + mocked = AsyncMock(return_value=bypass_response(status)[0]) + + with patch.object(VeSyncAirFryerDC111, 'call_bypassv2_api', new=mocked): + asyncio.run(fryer.get_details()) + assert fryer.temp_unit == 'fahrenheit' + + mocked.return_value = bypass_response()[0] + assert asyncio.run(fryer.prepare_program(1, 400, 10)) is True + + assert mocked.await_args.kwargs['data']['tempUnit'] == 'f' + assert mocked.await_args.kwargs['data']['cookConfigs'][0]['cookTemp'] == 400 + + +@pytest.mark.parametrize( + ('kwargs', 'error'), + [ + ({'chamber': 3, 'temperature': 180, 'minutes': 5}, 'chamber'), + ({'chamber': 1, 'temperature': 180, 'minutes': 0}, 'minutes'), + ({'chamber': 1, 'temperature': 400, 'minutes': 5}, 'temperature'), + ({'chamber': 1, 'temperature': 180, 'minutes': 5, 'mode': 'Roast'}, 'mode'), + ], +) +def test_prepare_program_rejects_invalid(fryer, kwargs, error) -> None: + """Invalid arguments raise before any API call.""" + mocked = AsyncMock() + + with ( + patch.object(VeSyncAirFryerDC111, 'call_bypassv2_api', new=mocked), + pytest.raises(ValueError, match=error), + ): + asyncio.run(fryer.prepare_program(**kwargs)) + + mocked.assert_not_awaited() + + +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 = asyncio.run( + 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' + + +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 = asyncio.run(fryer.stop_chamber(1)) + + assert result is True + mocked.assert_awaited_once_with( + 'endCook', + data={'chamber': 1}, + ) + + +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 = asyncio.run(fryer.stop_chamber(1)) + + assert result is False + + +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 = asyncio.run( + 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 + + +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 = asyncio.run( + 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