diff --git a/README.md b/README.md index 5ceff41b..0631261b 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,7 @@ pip install pyvesync ### Cosori Air Fryers 1. Cosori 3.7 Quart Air Fryer (CS137-AF) +1. Cosori 4.0 Quart Air Fryer (CAF-LI401S) 2. Cosori 5.8 Quart Air Fryer (CS158-AF) ### Thermostats diff --git a/docs/devices/kitchen.md b/docs/devices/kitchen.md index 363f16d1..32eff7ec 100644 --- a/docs/devices/kitchen.md +++ b/docs/devices/kitchen.md @@ -1,6 +1,6 @@ # 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. +Supported air fryers include the Cosori 3.7, 4.0 and 5.8 Quart models. These devices are smart air fryers that can be monitored and controlled via this library. ::: pyvesync.devices.vesynckitchen options: diff --git a/docs/supported_devices.md b/docs/supported_devices.md index c80bd7e2..3a8a26c7 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-LI401S][pyvesync.devices.vesynckitchen.VeSyncAirFryer401] - 4.0qt Air Fryer 8. Thermostats - [Aura][pyvesync.devices.vesyncthermostat] Thermostat @@ -119,6 +120,7 @@ been verified. | Device Name | Device Type | Temperature Control | Timer | | ------ | ----- | ----- | ----- | | Cosori 3.7qt Air Fryer | CS137 | ✔ | ✔ | +| Cosori 4.0qt Air Fryer | CAF-LI401S | ✔ | ✔ | | Cosori 5.8qt Air Fryer | CS158 | ✔ | ✔ | ### Thermostats diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 172b04c3..4c6cbbdb 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1101,7 +1101,18 @@ 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', - ) + ), + AirFryerMap( + class_name='VeSyncAirFryer401', + module=vesynckitchen, + dev_types=['CAF-LI401S'], + device_alias='Lite Smart Air Fryer', + model_display='CAF-LI401S Series', + model_name='Lite 4.0-Quart Smart Air Fryer', + setup_entry='CAF-LI401S', + temperature_range_f=(170, 450), + temperature_range_c=(75, 230), + ), ] """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..3e70fda5 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -29,12 +29,15 @@ import logging import time +from math import ceil 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 FryerV2Details +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 @@ -59,6 +62,8 @@ RECIPE_TYPE = 3 CUSTOM_RECIPE = 'Manual Cook' COOK_MODE = 'custom' +LI401_CUSTOM_MODE = 'Custom' +LI401_CUSTOM_RECIPE_ID = 11 class AirFryer158138State(FryerState): @@ -102,7 +107,7 @@ class AirFryer158138State(FryerState): def __init__( self, - device: VeSyncAirFryer158, + device: VeSyncFryer, details: ResponseDeviceDetailsModel, feature_map: AirFryerMap, ) -> None: @@ -328,6 +333,259 @@ def status_response(self, return_status: dict) -> None: self.clear_preheat() +class AirFryer401State(AirFryer158138State): + """State for the CAF-LI401S air fryer. + + The V2 API reports time values in seconds. They are converted to rounded-up + minutes so this state remains compatible with the existing fryer interface. + """ + + __slots__ = ('cook_mode', 'recipe') + + def __init__( + self, + device: VeSyncFryer, + details: ResponseDeviceDetailsModel, + feature_map: AirFryerMap, + ) -> None: + """Initialize CAF-LI401S state.""" + super().__init__(device, details, feature_map) + self.cook_mode: str | None = None + self.recipe: str | None = None + + @staticmethod + def _seconds_to_minutes(value: int | None) -> int | None: + """Convert API seconds to whole minutes without hiding remaining time.""" + if value is None: + return None + return ceil(value / 60) + + def status_response_v2(self, status: FryerV2Details) -> None: + """Update state from a bypass V2 air-fryer status response.""" + self.temp_unit = status.tempUnit + if status.cookStatus == 'standby' or not status.stepArray: + self.set_standby() + self.current_temp = status.currentTemp + return + + if status.stepIndex < 0 or status.stepIndex >= len(status.stepArray): + logger.warning( + 'Invalid cooking step index %s for %s', + status.stepIndex, + self.device.device_name, + ) + self.set_standby() + return + + step = status.stepArray[status.stepIndex] + self.cook_mode = step.mode + self.recipe = step.recipeName + return_status = { + 'cookStatus': status.cookStatus, + 'curentTemp': status.currentTemp, + 'cookSetTemp': step.cookTemp, + 'cookSetTime': self._seconds_to_minutes(step.cookSetTime), + 'cookLastTime': self._seconds_to_minutes(step.cookLastTime), + 'tempUnit': status.tempUnit, + 'preheatSetTime': self._seconds_to_minutes(status.preheatSetTime), + 'preheatLastTime': self._seconds_to_minutes(status.preheatLastTime), + } + self.status_response(return_status) + + def set_standby(self) -> None: + """Clear CAF-LI401S cooking state.""" + super().set_standby() + self.cook_mode = None + self.recipe = None + + +class VeSyncAirFryer401(BypassV2Mixin, VeSyncFryer): + """Cosori Lite 4.0-Quart Smart Air Fryer (CAF-LI401S).""" + + __slots__ = () + + def __init__( + self, + details: ResponseDeviceDetailsModel, + manager: VeSync, + feature_map: AirFryerMap, + ) -> None: + """Initialize the CAF-LI401S air fryer.""" + super().__init__(details, manager, feature_map) + self.state: AirFryer401State = AirFryer401State(self, details, feature_map) + + async def get_details(self) -> None: + """Get current cooking status and details.""" + response = await self.call_bypassv2_api('getAirfryerStatus') + status = process_bypassv2_result( + self, + logger, + 'get_details', + response, + FryerV2Details, + ) + if status is None: + self.state.device_status = DeviceStatus.OFF + return + + self.state.status_response_v2(status) + self.state.connection_status = ConnectionStatus.ONLINE + self.state.device_status = ( + DeviceStatus.RUNNING if self.state.is_running else DeviceStatus.OFF + ) + + @property + def temp_unit(self) -> str | None: + """Return the configured temperature unit.""" + return self.state.temp_unit + + def _validate_cook_settings(self, set_temp: int, set_time: int) -> bool: + """Validate a cooking temperature and time in minutes.""" + if not self._validate_cook_time(set_time): + return False + if self.temp_unit == 'fahrenheit': + minimum, maximum = self.state.min_temp_f, self.state.max_temp_f + elif self.temp_unit == 'celsius': + minimum, maximum = self.state.min_temp_c, self.state.max_temp_c + else: + logger.warning('Temperature unit is unavailable for %s', self.device_name) + return False + if set_temp < minimum or set_temp > maximum: + logger.warning( + 'Cook temperature must be between %s and %s %s', + minimum, + maximum, + self.temp_unit, + ) + return False + return True + + @staticmethod + def _validate_cook_time(set_time: int) -> bool: + """Validate a cooking time in minutes.""" + if set_time < 1 or set_time > 60: + logger.warning('Cook time must be between 1 and 60 minutes') + return False + return True + + def _control_succeeded(self, method: str, response: dict | None) -> bool: + """Validate both the cloud and device-level response codes.""" + inner_result = response.get('result') if isinstance(response, dict) else None + if isinstance(inner_result, dict) and inner_result.get('code') != 0: + logger.warning( + '%s failed for %s with device code %s', + method, + self.device_name, + inner_result.get('code'), + ) + return False + processed = Helpers.process_dev_response(logger, method, self, response) + if processed is None: + return False + result = processed.get('result') + if not isinstance(result, dict) or result.get('code') != 0: + logger.warning( + '%s failed for %s with device code %s', + method, + self.device_name, + result.get('code') if isinstance(result, dict) else None, + ) + return False + return True + + async def cook(self, set_temp: int, set_time: int) -> bool: + """Stage a manual program with custom temperature and time. + + The CAF-LI401S requires its physical start button to begin heating. This + method configures the program remotely and leaves the fryer in ``ready``. + + Args: + set_temp: Cooking temperature in the fryer's configured unit. + set_time: Cooking time in minutes (1-60). + """ + if self.state.cook_status is None or self.temp_unit is None: + await self.update() + if not self._validate_cook_settings(set_temp, set_time): + return False + + if self.state.cook_status in ['ready', 'cookStop', 'pullOut', 'cookEnd']: + if not await self.end(): + return False + elif self.state.cook_status != 'standby': + logger.warning( + 'Cannot configure %s while status is %s', + self.device_name, + self.state.cook_status, + ) + return False + + data = { + 'accountId': self.manager.account_id, + 'cookTempDECP': 0, + 'hasPreheat': 0, + 'hasWarm': False, + 'imageUrl': '', + 'mode': LI401_CUSTOM_MODE, + 'readyStart': True, + 'recipeId': LI401_CUSTOM_RECIPE_ID, + 'recipeName': LI401_CUSTOM_MODE, + 'recipeType': 3, + 'startAct': { + 'appointingTime': 0, + 'cookSetTime': set_time * 60, + 'cookTemp': set_temp, + 'cookTempDECP': 0, + 'imageUrl': '', + 'level': 0, + 'preheatTemp': 0, + 'shakeTime': 0, + 'targetTemp': 0, + }, + 'tempUnit': 'f' if self.temp_unit == 'fahrenheit' else 'c', + } + response = await self.call_bypassv2_api('startCook', data) + if not self._control_succeeded('cook', response): + return False + await self.update() + return True + + async def set_cook_time(self, set_time: int) -> bool: + """Set the remaining cooking time in minutes. + + This control is only accepted while the CAF-LI401S is actively cooking. + + Args: + set_time: New remaining cooking time in minutes (1-60). + """ + if not self._validate_cook_time(set_time): + return False + if self.state.cook_status != 'cooking': + await self.update() + if self.state.cook_status != 'cooking': + logger.warning( + 'Cannot set cooking time for %s while status is %s', + self.device_name, + self.state.cook_status, + ) + return False + + data = {'cookSetTime': set_time * 60, 'hasLinkage': False} + response = await self.call_bypassv2_api('setTimeOrTemp', data) + if not self._control_succeeded('set_cook_time', response): + return False + await self.update() + return True + + async def end(self) -> bool: + """End or clear the current cooking program.""" + response = await self.call_bypassv2_api('endCook') + if not self._control_succeeded('end', response): + return False + self.state.set_standby() + await self.update() + return True + + class VeSyncAirFryer158(VeSyncFryer): """Cosori Air Fryer Class. diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index bf147253..dfab116f 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,37 @@ class FryerBaseReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" cookStatus: str + + +@dataclass +class FryerV2StepItem(ResponseBaseModel): + """Cooking step returned by newer VeSync air fryers.""" + + cookSetTime: int + cookTemp: int + mode: str + cookLastTime: int + shakeTime: int = 0 + cookEndTime: int = 0 + recipeName: str = '' + recipeId: int = 0 + recipeType: int = 0 + + +@dataclass +class FryerV2Details(BypassV2InnerResult): + """Status returned by newer single-basket VeSync air fryers.""" + + stepArray: list[FryerV2StepItem] + cookMode: str + tempUnit: str + stepIndex: int + cookStatus: str + preheatSetTime: int + preheatLastTime: int + preheatEndTime: int + preheatTemp: int + startTime: int + totalTimeRemaining: int + currentTemp: int + shakeStatus: int diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 63ffc436..98bc887e 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -20,7 +20,6 @@ REGION_API_MAP, STATUS_OK, ConnectionStatus, - ProductTypes, ) from pyvesync.device_container import DeviceContainer from pyvesync.models.vesync_models import ( @@ -599,20 +598,15 @@ async def check_firmware(self) -> bool: ) body = Helpers.get_manager_attributes(self, body_fields) - # Add cid list of devices that are online and not an air fryer + # Add cid list of online devices body['cidList'] = [ device.cid for device in self.devices - if ( - device.product_type != ProductTypes.AIR_FRYER - and device.state.connection_status == ConnectionStatus.ONLINE - ) + if device.state.connection_status == ConnectionStatus.ONLINE ] if len(body['cidList']) == 0: - logger.warning( - 'No online devices (excluding air fryers) to check firmware for' - ) + logger.warning('No online devices to check firmware for') return False resp_dict, _ = await self.async_call_api( diff --git a/src/tests/test_fryers.py b/src/tests/test_fryers.py new file mode 100644 index 00000000..315ffd37 --- /dev/null +++ b/src/tests/test_fryers.py @@ -0,0 +1,366 @@ +"""Tests for VeSync air fryers.""" + +from pyvesync.const import ConnectionStatus, DeviceStatus +from pyvesync.device_map import get_air_fryer +from pyvesync.devices.vesynckitchen import VeSyncAirFryer401 +from pyvesync.models.vesync_models import ResponseDeviceDetailsModel + +from base_test_cases import TestBase + + +class TestAirFryer401(TestBase): + """Test CAF-LI401S discovery and status updates.""" + + firmware_response = ( + { + 'traceId': 'firmware-trace-id', + 'code': 0, + 'msg': 'request success', + 'result': { + 'cidFwInfoList': [ + { + 'deviceCid': 'fryer-cid', + 'deviceName': 'Kitchen fryer', + 'code': 0, + 'msg': None, + 'firmUpdateInfos': [ + { + 'currentVersion': '1.0.13', + 'latestVersion': '1.0.13', + 'releaseNotes': 'MCU release notes', + 'pluginName': 'mcuFw', + 'isMainFw': False, + }, + { + 'currentVersion': '2.0.03', + 'latestVersion': '2.0.03', + 'releaseNotes': 'Main firmware release notes', + 'pluginName': 'mainFw', + 'isMainFw': True, + }, + ], + } + ] + }, + }, + 200, + ) + + @staticmethod + def _control_response(code: int = 0) -> tuple[dict, int]: + """Return a bypass V2 control response.""" + return ( + { + 'code': 0, + 'msg': 'request success', + 'result': {'code': code, 'msg': None, 'result': None}, + }, + 200, + ) + + @staticmethod + def _status_response( + cook_status: str = 'standby', + *, + cook_temp: int = 180, + cook_time: int = 60, + temp_unit: str = 'c', + ) -> tuple[dict, int]: + """Return a bypass V2 status response.""" + steps = [] + if cook_status != 'standby': + steps.append( + { + 'cookSetTime': cook_time, + 'cookTemp': cook_temp, + 'mode': 'Custom', + 'cookLastTime': cook_time, + 'recipeName': 'Custom', + 'recipeId': 11, + 'recipeType': 3, + } + ) + return ( + { + 'code': 0, + 'msg': 'request success', + 'result': { + 'code': 0, + 'result': { + 'stepArray': steps, + 'cookMode': 'normal', + 'tempUnit': temp_unit, + 'stepIndex': 0, + 'cookStatus': cook_status, + 'preheatSetTime': 0, + 'preheatLastTime': 0, + 'preheatEndTime': 0, + 'preheatTemp': 0, + 'startTime': 0, + 'totalTimeRemaining': cook_time, + 'currentTemp': 25, + 'shakeStatus': 0, + }, + }, + }, + 200, + ) + + @staticmethod + def _details() -> ResponseDeviceDetailsModel: + return ResponseDeviceDetailsModel.from_dict( + { + 'deviceRegion': 'EU', + 'isOwner': True, + 'deviceName': 'Kitchen fryer', + 'deviceImg': '', + 'cid': 'fryer-cid', + 'deviceStatus': 'off', + 'connectionStatus': 'online', + 'connectionType': 'WiFi+BTOnboarding+BTNotify', + 'deviceType': 'CAF-LI401S', + 'type': 'SKA', + 'uuid': 'fryer-uuid', + 'configModule': 'VS_WFON_AFR_CAF-LI401S_EU', + 'macID': '', + } + ) + + def _fryer(self) -> VeSyncAirFryer401: + feature_map = get_air_fryer('CAF-LI401S') + assert feature_map is not None + fryer = VeSyncAirFryer401(self._details(), self.manager, feature_map) + assert fryer.state.min_temp_c == 75 + assert fryer.state.max_temp_c == 230 + return fryer + + def test_device_map(self) -> None: + """CAF-LI401S resolves to its V2 fryer class.""" + feature_map = get_air_fryer('CAF-LI401S') + assert feature_map is not None + assert feature_map.class_name == 'VeSyncAirFryer401' + self.manager.devices.add_device_from_model(self._details(), self.manager) + assert len(self.manager.devices.air_fryers) == 1 + assert isinstance(self.manager.devices.air_fryers[0], VeSyncAirFryer401) + + def test_standby_status(self) -> None: + """A standby response updates the fryer without cooking state.""" + status_response = ( + { + 'code': 0, + 'msg': 'request success', + 'result': { + 'code': 0, + 'result': { + 'stepArray': [], + 'cookMode': 'normal', + 'tempUnit': 'c', + 'stepIndex': 0, + 'cookStatus': 'standby', + 'preheatSetTime': 0, + 'preheatLastTime': 0, + 'preheatEndTime': 0, + 'preheatTemp': 0, + 'startTime': 0, + 'totalTimeRemaining': 0, + 'currentTemp': 58, + 'shakeStatus': 0, + }, + }, + }, + 200, + ) + self.mock_api.return_value = status_response + fryer = self._fryer() + self.run_in_loop(fryer.update) + + assert fryer.state.cook_status == 'standby' + assert fryer.state.temp_unit == 'celsius' + assert fryer.state.current_temp == 58 + assert fryer.state.device_status == DeviceStatus.OFF + assert fryer.state.connection_status == ConnectionStatus.ONLINE + request = self.mock_api.call_args.args[2] + assert request.payload.method == 'getAirfryerStatus' + assert request.deviceId == 'fryer-cid' + + def test_standard_firmware_check(self) -> None: + """The manager firmware API includes air fryers.""" + self.manager.devices.add_device_from_model(self._details(), self.manager) + fryer = self.manager.devices.air_fryers[0] + self.mock_api.return_value = self.firmware_response + + assert self.run_in_loop(self.manager.check_firmware) is True + + assert fryer.current_firm_version == '2.0.03' + assert fryer.latest_firm_version == '2.0.03' + firmware_call = self.mock_api.call_args + assert firmware_call.args[0].endswith('/getFirmwareUpdateInfoList') + assert firmware_call.kwargs['json_object'].cidList == ['fryer-cid'] + + def test_cooking_status_converts_seconds_to_minutes(self) -> None: + """V2 second values are exposed through the legacy minute interface.""" + status_response = ( + { + 'code': 0, + 'msg': 'request success', + 'result': { + 'code': 0, + 'result': { + 'stepArray': [ + { + 'cookSetTime': 600, + 'cookTemp': 200, + 'mode': 'AirFry', + 'cookLastTime': 481, + } + ], + 'cookMode': 'normal', + 'tempUnit': 'c', + 'stepIndex': 0, + 'cookStatus': 'cooking', + 'preheatSetTime': 0, + 'preheatLastTime': 0, + 'preheatEndTime': 0, + 'preheatTemp': 0, + 'startTime': 1, + 'totalTimeRemaining': 481, + 'currentTemp': 180, + 'shakeStatus': 0, + }, + }, + }, + 200, + ) + self.mock_api.return_value = status_response + fryer = self._fryer() + self.run_in_loop(fryer.update) + + assert fryer.state.cook_status == 'cooking' + assert fryer.state.cook_set_time == 10 + assert fryer.state.cook_last_time == 9 + assert fryer.state.cook_set_temp == 200 + assert fryer.state.current_temp == 180 + assert fryer.state.temp_unit == 'celsius' + assert fryer.state.device_status == DeviceStatus.RUNNING + + def test_cook_stages_program_with_physical_start_required(self) -> None: + """Cook sends the LI401S preset payload and leaves it ready to start.""" + fryer = self._fryer() + fryer.state.temp_unit = 'celsius' + fryer.state.cook_status = 'standby' + self.mock_api.side_effect = [ + self._control_response(), + self._status_response('ready', cook_temp=180, cook_time=60), + ] + + assert self.run_in_loop(fryer.cook, 180, 1) is True + + start_request = self.mock_api.call_args_list[0].args[2] + assert start_request.payload.method == 'startCook' + assert start_request.payload.data['mode'] == 'Custom' + assert start_request.payload.data['recipeId'] == 11 + assert start_request.payload.data['readyStart'] is True + assert start_request.payload.data['tempUnit'] == 'c' + assert start_request.payload.data['startAct']['cookSetTime'] == 60 + assert start_request.payload.data['startAct']['cookTemp'] == 180 + assert fryer.state.cook_status == 'ready' + assert fryer.state.cook_mode == 'Custom' + assert fryer.state.recipe == 'Custom' + + def test_cook_rejects_invalid_settings(self) -> None: + """Cook rejects values outside the device's documented ranges.""" + fryer = self._fryer() + fryer.state.temp_unit = 'celsius' + fryer.state.cook_status = 'standby' + + assert self.run_in_loop(fryer.cook, 74, 10) is False + assert self.run_in_loop(fryer.cook, 180, 0) is False + self.mock_api.assert_not_called() + + def test_cook_uses_reported_fahrenheit_unit(self) -> None: + """US fryers can stage a cook using their reported Fahrenheit unit.""" + fryer = self._fryer() + fryer.state.temp_unit = 'fahrenheit' + fryer.state.cook_status = 'standby' + self.mock_api.side_effect = [ + self._control_response(), + self._status_response( + 'ready', cook_temp=350, cook_time=60, temp_unit='f' + ), + ] + + assert self.run_in_loop(fryer.cook, 350, 1) is True + request = self.mock_api.call_args_list[0].args[2] + assert request.payload.data['tempUnit'] == 'f' + assert request.payload.data['startAct']['cookTemp'] == 350 + + def test_cook_checks_inner_device_code(self) -> None: + """An inner bypass error is not mistaken for a successful command.""" + fryer = self._fryer() + fryer.state.temp_unit = 'celsius' + fryer.state.cook_status = 'standby' + self.mock_api.return_value = self._control_response(11000000) + + assert self.run_in_loop(fryer.cook, 180, 1) is False + assert self.mock_api.call_count == 1 + + def test_end_clears_ready_program(self) -> None: + """End clears a staged cook and refreshes standby state.""" + fryer = self._fryer() + fryer.state.temp_unit = 'celsius' + fryer.state.cook_status = 'ready' + fryer.state.cook_mode = 'Custom' + fryer.state.recipe = 'Custom' + self.mock_api.side_effect = [ + self._control_response(), + self._status_response(), + ] + + assert self.run_in_loop(fryer.end) is True + + end_request = self.mock_api.call_args_list[0].args[2] + assert end_request.payload.method == 'endCook' + assert fryer.state.cook_status == 'standby' + assert fryer.state.cook_mode is None + assert fryer.state.recipe is None + + def test_set_cook_time_updates_active_cook(self) -> None: + """The remaining cook time can be changed while actively cooking.""" + fryer = self._fryer() + fryer.state.temp_unit = 'celsius' + fryer.state.cook_status = 'cooking' + self.mock_api.side_effect = [ + self._control_response(), + self._status_response('cooking', cook_temp=75, cook_time=120), + ] + + assert self.run_in_loop(fryer.set_cook_time, 2) is True + + set_time_request = self.mock_api.call_args_list[0].args[2] + assert set_time_request.payload.method == 'setTimeOrTemp' + assert set_time_request.payload.data == { + 'cookSetTime': 120, + 'hasLinkage': False, + } + assert fryer.state.cook_set_time == 2 + assert fryer.state.cook_last_time == 2 + + def test_set_cook_time_rejects_invalid_time(self) -> None: + """Cook time adjustment uses the device's one-to-sixty-minute range.""" + fryer = self._fryer() + fryer.state.cook_status = 'cooking' + + assert self.run_in_loop(fryer.set_cook_time, 0) is False + assert self.run_in_loop(fryer.set_cook_time, 61) is False + self.mock_api.assert_not_called() + + def test_set_cook_time_requires_active_cook(self) -> None: + """The device only accepts time adjustment while actively cooking.""" + fryer = self._fryer() + fryer.state.cook_status = 'standby' + self.mock_api.return_value = self._status_response() + + assert self.run_in_loop(fryer.set_cook_time, 2) is False + assert self.mock_api.call_count == 1 + request = self.mock_api.call_args.args[2] + assert request.payload.method == 'getAirfryerStatus'