"""VeSync Kitchen Devices. The Cosori 3.7 and 5.8 Quart Air Fryer has several methods and properties that can be used to monitor and control the device. To maintain consistency of state, the update() method is called after each of the methods that change the state of the device. There is also an instance attribute that can be set `VeSyncAirFryer158.refresh_interval` that will set the interval in seconds that the state of the air fryer should be updated before a method that changes state is called. This is an additional API call but is necessary to maintain state, especially when trying to `pause` or `resume` the device. Defaults to 60 seconds but can be set via: ```python # Change to 120 seconds before status is updated between calls VeSyncAirFryer158.refresh_interval = 120 # Set status update before every call VeSyncAirFryer158.refresh_interval = 0 # Disable status update before every call VeSyncAirFryer158.refresh_interval = -1 ``` """ from __future__ import annotations import logging import time 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.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync import VeSync from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel T = TypeVar('T') logger = logging.getLogger(__name__) # Status refresh interval in seconds # API calls outside of interval are automatically refreshed # Set VeSyncAirFryer158.refresh_interval to 0 to refresh every call # Set to None or -1 to disable auto-refresh REFRESH_INTERVAL = 60 RECIPE_ID = 1 RECIPE_TYPE = 3 CUSTOM_RECIPE = 'Manual Cook' COOK_MODE = 'custom' class AirFryer158138State(FryerState): """Dataclass for air fryer status. Attributes: active_time (int): Active time of device, defaults to None. connection_status (str): Connection status of device. device (VeSyncBaseDevice): Device object. device_status (str): Device status. features (dict): Features of device. last_update_ts (int): Last update timestamp of device, defaults to None. ready_start (bool): Ready start status of device, defaults to False. preheat (bool): Preheat status of device, defaults to False. cook_status (str): Cooking status of device, defaults to None. current_temp (int): Current temperature of device, defaults to None. cook_set_temp (int): Cooking set temperature of device, defaults to None. last_timestamp (int): Last timestamp of device, defaults to None. preheat_set_time (int): Preheat set time of device, defaults to None. preheat_last_time (int): Preheat last time of device, defaults to None. _temp_unit (str): Temperature unit of device, defaults to None. """ __slots__ = ( '_temp_unit', 'cook_last_time', 'cook_set_temp', 'cook_set_time', 'cook_status', 'current_temp', 'last_timestamp', 'max_temp_c', 'max_temp_f', 'min_temp_c', 'min_temp_f', 'preheat', 'preheat_last_time', 'preheat_set_time', 'ready_start', ) def __init__( self, device: VeSyncAirFryer158, details: ResponseDeviceDetailsModel, feature_map: AirFryerMap, ) -> None: """Init the Air Fryer 158 class.""" super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features self.min_temp_f: int = feature_map.temperature_range_f[0] self.max_temp_f: int = feature_map.temperature_range_f[1] self.min_temp_c: int = feature_map.temperature_range_c[0] self.max_temp_c: int = feature_map.temperature_range_c[1] self.ready_start: bool = False self.preheat: bool = False self.cook_status: str | None = None self.current_temp: int | None = None self.cook_set_temp: int | None = None self.cook_set_time: int | None = None self.cook_last_time: int | None = None self.last_timestamp: int | None = None self.preheat_set_time: int | None = None self.preheat_last_time: int | None = None self._temp_unit: str | None = None @property def is_resumable(self) -> bool: """Return if cook is resumable.""" if self.cook_status in ['cookStop', 'preheatStop']: if self.cook_set_time is not None: return self.cook_set_time > 0 if self.preheat_set_time is not None: return self.preheat_set_time > 0 return False @property def temp_unit(self) -> str | None: """Return temperature unit.""" return self._temp_unit @temp_unit.setter def temp_unit(self, temp_unit: str) -> None: """Set temperature unit.""" if temp_unit.lower() in ['f', 'fahrenheit', 'fahrenheight']: # API TYPO self._temp_unit = 'fahrenheit' elif temp_unit.lower() in ['c', 'celsius']: self._temp_unit = 'celsius' else: msg = f'Invalid temperature unit - {temp_unit}' raise ValueError(msg) @property def preheat_time_remaining(self) -> int: """Return preheat time remaining.""" if self.preheat is False or self.cook_status == 'preheatEnd': return 0 if self.cook_status in ['pullOut', 'preheatStop']: if self.preheat_last_time is None: return 0 return int(self.preheat_last_time) if self.preheat_last_time is not None and self.last_timestamp is not None: return int( max( ( self.preheat_last_time * 60 - (int(time.time()) - self.last_timestamp) ) // 60, 0, ) ) return 0 @property def cook_time_remaining(self) -> int: """Returns the amount of time remaining if cooking.""" if self.preheat is True or self.cook_status == 'cookEnd': return 0 if self.cook_status in ['pullOut', 'cookStop']: if self.cook_last_time is None: return 0 return int(max(self.cook_last_time, 0)) if self.cook_last_time is not None and self.last_timestamp is not None: return int( max( (self.cook_last_time * 60 - (int(time.time()) - self.last_timestamp)) // 60, 0, ) ) return 0 @property def remaining_time(self) -> int: """Return minutes remaining if cooking/heating.""" if self.preheat is True: return self.preheat_time_remaining return self.cook_time_remaining @property def is_running(self) -> bool: """Return if cooking or heating.""" return bool(self.cook_status in ['cooking', 'heating']) and bool( self.remaining_time > 0 ) @property def is_cooking(self) -> bool: """Return if cooking.""" return self.cook_status == 'cooking' and self.remaining_time > 0 @property def is_heating(self) -> bool: """Return if heating.""" return self.cook_status == 'heating' and self.remaining_time > 0 def status_request(self, json_cmd: dict) -> None: # noqa: C901 """Set status from jsonCmd of API call.""" self.last_timestamp = None if not isinstance(json_cmd, dict): return self.preheat = False preheat = json_cmd.get('preheat') cook = json_cmd.get('cookMode') if isinstance(preheat, dict): self.preheat = True if preheat.get('preheatStatus') == 'stop': self.cook_status = 'preheatStop' elif preheat.get('preheatStatus') == 'heating': self.cook_status = 'heating' self.last_timestamp = int(time.time()) self.preheat_set_time = preheat.get( 'preheatSetTime', self.preheat_set_time ) if preheat.get('preheatSetTime') is not None: self.preheat_last_time = preheat.get('preheatSetTime') self.cook_set_temp = preheat.get('targetTemp', self.cook_set_temp) self.cook_set_time = preheat.get('cookSetTime', self.cook_set_time) self.cook_last_time = None elif preheat.get('preheatStatus') == 'end': self.cook_status = 'preheatEnd' self.preheat_last_time = 0 elif isinstance(cook, dict): self.clear_preheat() if cook.get('cookStatus') == 'stop': self.cook_status = 'cookStop' elif cook.get('cookStatus') == 'cooking': self.cook_status = 'cooking' self.last_timestamp = int(time.time()) self.cook_set_time = cook.get('cookSetTime', self.cook_set_time) self.cook_set_temp = cook.get('cookSetTemp', self.cook_set_temp) self.current_temp = cook.get('currentTemp', self.current_temp) self.temp_unit = cook.get( 'tempUnit', self.temp_unit, # type: ignore[assignment] ) elif cook.get('cookStatus') == 'end': self.set_standby() self.cook_status = 'cookEnd' def clear_preheat(self) -> None: """Clear preheat status.""" self.preheat = False self.preheat_set_time = None self.preheat_last_time = None def set_standby(self) -> None: """Clear cooking status.""" self.cook_status = 'standby' self.clear_preheat() self.cook_last_time = None self.current_temp = None self.cook_set_time = None self.cook_set_temp = None self.last_timestamp = None def status_response(self, return_status: dict) -> None: """Set status of Air Fryer Based on API Response.""" self.last_timestamp = None self.preheat = False self.cook_status = return_status.get('cookStatus') if self.cook_status == 'standby': self.set_standby() return # If drawer is pulled out, set standby if resp does not contain other details if self.cook_status == 'pullOut': self.last_timestamp = None if 'currentTemp' not in return_status or 'tempUnit' not in return_status: self.set_standby() self.cook_status = 'pullOut' return if return_status.get('preheatLastTime') is not None or self.cook_status in [ 'heating', 'preheatStop', 'preheatEnd', ]: self.preheat = True self.cook_set_time = return_status.get('cookSetTime', self.cook_set_time) self.cook_last_time = return_status.get('cookLastTime') self.current_temp = return_status.get('curentTemp') self.cook_set_temp = return_status.get( 'targetTemp', return_status.get('cookSetTemp') ) self.temp_unit = return_status.get( 'tempUnit', self.temp_unit, # type: ignore[assignment] ) self.preheat_set_time = return_status.get('preheatSetTime') self.preheat_last_time = return_status.get('preheatLastTime') # Set last_time timestamp if cooking if self.cook_status in ['cooking', 'heating']: self.last_timestamp = int(time.time()) if self.cook_status == 'preheatEnd': self.preheat_last_time = 0 self.cook_last_time = None if self.cook_status == 'cookEnd': self.cook_last_time = 0 # If Cooking, clear preheat status if self.cook_status in ['cooking', 'cookStop', 'cookEnd']: self.clear_preheat() class VeSyncAirFryer158(VeSyncFryer): """Cosori Air Fryer Class. Args: details (ResponseDeviceDetailsModel): Device details. manager (VeSync): Manager class. feature_map (DeviceMapTemplate): Device feature map. Attributes: features (list[str]): List of features. state (AirFryer158138State): Air fryer state. last_update (int): Last update timestamp. refresh_interval (int): Refresh interval in seconds. cook_temps (dict[str, list[int]] | None): Cook temperatures. pid (str): PID for the device. last_response (ResponseInfo): Last response from API call. manager (VeSync): Manager object for API calls. device_name (str): Name of device. device_image (str): URL for device image. cid (str): Device ID. connection_type (str): Connection type of device. device_type (str): Type of device. type (str): Type of device. uuid (str): UUID of device, not always present. config_module (str): Configuration module of device. mac_id (str): MAC ID of device. current_firm_version (str): Current firmware version of device. device_region (str): Region of device. (US, EU, etc.) sub_device_no (int): Sub-device number of device. product_type (str): Product type of device. """ __slots__ = ( 'cook_temps', 'last_update', 'ready_start', 'refresh_interval', ) def __init__( self, details: ResponseDeviceDetailsModel, manager: VeSync, feature_map: AirFryerMap, ) -> None: """Init the VeSync Air Fryer 158 class.""" super().__init__(details, manager, feature_map) self.features: list[str] = feature_map.features self.state: AirFryer158138State = AirFryer158138State(self, details, feature_map) self.last_update: int = int(time.time()) self.refresh_interval = 0 self.ready_start = False self.cook_temps: dict[str, list[int]] | None = None if self.config_module not in AIRFRYER_PID_MAP: msg = ( 'Report this error as an issue - ' f'{self.config_module} not found in PID map for {self}' ) raise VeSyncError(msg) self.pid = AIRFRYER_PID_MAP[self.config_module] self.request_keys = ( 'acceptLanguage', 'accountID', 'appVersion', 'cid', 'configModule', 'deviceRegion', 'phoneBrand', 'phoneOS', 'timeZone', 'token', 'traceId', 'userCountryCode', 'method', 'debugMode', 'uuid', 'pid', ) @deprecated('There is no on/off function for Air Fryers.') async def toggle_switch(self, toggle: bool | None = None) -> bool: """Turn on or off the air fryer.""" return toggle if toggle is not None else not self.is_on def _build_request( self, json_cmd: dict | None = None, method: str | None = None, ) -> dict: """Return body of api calls.""" req_dict = Helpers.get_defaultvalues_attributes(self.request_keys) req_dict.update(Helpers.get_manager_attributes(self.manager, self.request_keys)) req_dict.update(Helpers.get_device_attributes(self, self.request_keys)) req_dict['method'] = method or 'bypass' req_dict['jsonCmd'] = json_cmd or {} return req_dict def _build_status_body(self, cmd_dict: dict) -> dict: """Return body of api calls.""" body = self._build_request() body.update( { 'uuid': self.uuid, 'configModule': self.config_module, 'jsonCmd': cmd_dict, 'pid': self.pid, 'accountID': self.manager.account_id, } ) return body @property def temp_unit(self) -> str | None: """Return temp unit.""" return self.state.temp_unit async def get_details(self) -> None: """Get Air Fryer Status and Details.""" cmd = {'getStatus': 'status'} req_body = self._build_request(json_cmd=cmd) url = '/cloud/v1/deviceManaged/bypass' r_dict, _ = await self.manager.async_call_api(url, 'post', json_object=req_body) resp = Helpers.process_dev_response(logger, 'get_details', self, r_dict) if resp is None: self.state.device_status = DeviceStatus.OFF self.state.connection_status = ConnectionStatus.OFFLINE return return_status = resp.get('result', {}).get('returnStatus') if return_status is None: LibraryLogger.error_device_response_content( logger, self, 'get_details', msg='Return status not found in response', ) return self.state.status_response(return_status) async def check_status(self) -> None: """Update status if REFRESH_INTERVAL has passed.""" seconds_elapsed = int(time.time()) - self.last_update logger.debug('Seconds elapsed between updates: %s', seconds_elapsed) refresh = False if self.refresh_interval is None: refresh = bool(seconds_elapsed > REFRESH_INTERVAL) elif self.refresh_interval == 0: refresh = True elif self.refresh_interval > 0: refresh = bool(seconds_elapsed > self.refresh_interval) if refresh is True: logger.debug('Updating status, %s seconds elapsed', seconds_elapsed) await self.update() async def end(self) -> bool: """End the cooking process.""" await self.check_status() if self.state.preheat is False and self.state.cook_status in [ 'cookStop', 'cooking', ]: cmd = {'cookMode': {'cookStatus': 'end'}} elif self.state.preheat is True and self.state.cook_status in [ 'preheatStop', 'heating', ]: cmd = {'preheat': {'cookStatus': 'end'}} else: logger.debug( 'Cannot end %s as it is not cooking or preheating', self.device_name ) return False status_api = await self._status_api(cmd) if status_api is False: return False self.state.set_standby() return True async def pause(self) -> bool: """Pause the cooking process.""" await self.check_status() if self.state.cook_status not in ['cooking', 'heating']: logger.debug( 'Cannot pause %s as it is not cooking or preheating', self.device_name ) return False if self.state.preheat is True: cmd = {'preheat': {'preheatStatus': 'stop'}} else: cmd = {'cookMode': {'cookStatus': 'stop'}} status_api = await self._status_api(cmd) if status_api is True: if self.state.preheat is True: self.state.cook_status = 'preheatStop' else: self.state.cook_status = 'cookStop' return True return False def _validate_temp(self, set_temp: int) -> bool: """Temperature validation.""" if self.state.temp_unit == 'fahrenheit' and ( set_temp < self.state.min_temp_f or set_temp > self.state.max_temp_f ): logger.debug('Invalid temperature %s for %s', set_temp, self.device_name) return False if self.state.temp_unit == 'celsius' and ( set_temp < self.state.min_temp_c or set_temp > self.state.max_temp_c ): logger.debug('Invalid temperature %s for %s', set_temp, self.device_name) return False return True async def cook(self, set_temp: int, set_time: int) -> bool: """Set cook time and temperature in Minutes.""" await self.check_status() if self._validate_temp(set_temp) is False: return False return await self._set_cook(set_temp, set_time) async def resume(self) -> bool: """Resume paused preheat or cook.""" await self.check_status() if self.state.cook_status not in ['preheatStop', 'cookStop']: logger.debug('Cannot resume %s as it is not paused', self.device_name) return False if self.state.preheat is True: cmd = {'preheat': {'preheatStatus': 'heating'}} else: cmd = {'cookMode': {'cookStatus': 'cooking'}} status_api = await self._status_api(cmd) if status_api is True: if self.state.preheat is True: self.state.cook_status = 'heating' else: self.state.cook_status = 'cooking' return True return False async def set_preheat(self, target_temp: int, cook_time: int) -> bool: """Set preheat mode with cooking time.""" await self.check_status() if self.state.cook_status not in ['standby', 'cookEnd', 'preheatEnd']: logger.debug( 'Cannot set preheat for %s as it is not in standby', self.device_name ) return False if self._validate_temp(target_temp) is False: return False cmd = self._cmd_api_dict cmd['preheatSetTime'] = 5 cmd['preheatStatus'] = 'heating' cmd['targetTemp'] = target_temp cmd['cookSetTime'] = cook_time json_cmd = {'preheat': cmd} return await self._status_api(json_cmd) async def cook_from_preheat(self) -> bool: """Start Cook when preheat has ended.""" await self.check_status() if self.state.preheat is False or self.state.cook_status != 'preheatEnd': logger.debug('Cannot start cook from preheat for %s', self.device_name) return False return await self._set_cook(status='cooking') async def update(self) -> None: """Update the device details.""" await self.get_details() @property def _cmd_api_base(self) -> dict: """Return Base api dictionary for setting status.""" return { 'mode': COOK_MODE, 'accountId': self.manager.account_id, } @property def _cmd_api_dict(self) -> dict: """Return API dictionary for setting status.""" cmd = self._cmd_api_base cmd.update( { 'appointmentTs': 0, 'recipeId': RECIPE_ID, 'readyStart': self.ready_start, 'recipeType': RECIPE_TYPE, 'customRecipe': CUSTOM_RECIPE, } ) return cmd async def _set_cook( self, set_temp: int | None = None, set_time: int | None = None, status: str = 'cooking', ) -> bool: if set_temp is not None and set_time is not None: set_cmd = self._cmd_api_dict set_cmd['cookSetTime'] = set_time set_cmd['cookSetTemp'] = set_temp else: set_cmd = self._cmd_api_base set_cmd['cookStatus'] = status cmd = {'cookMode': set_cmd} return await self._status_api(cmd) async def _status_api(self, json_cmd: dict) -> bool: """Set API status with jsonCmd.""" body = self._build_status_body(json_cmd) url = '/cloud/v1/deviceManaged/bypass' r_dict, _ = await self.manager.async_call_api(url, 'post', json_object=body) resp = Helpers.process_dev_response(logger, 'set_status', self, r_dict) if resp is None: return False self.last_update = int(time.time()) self.state.status_request(json_cmd) await self.update() return True