From cf1227e103f7e386272611a01f5f7a68c7db24f2 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 18 Jan 2026 19:23:12 -0500 Subject: [PATCH 01/19] Refactor air fryer modules --- ruff.toml | 2 +- src/pyvesync/base_devices/fryer_base.py | 315 ++++++++- src/pyvesync/const.py | 208 +++++- src/pyvesync/device_map.py | 45 +- src/pyvesync/devices/vesynckitchen.py | 881 ++++++++++++------------ src/pyvesync/models/fryer_models.py | 157 ++++- src/pyvesync/utils/helpers.py | 10 + 7 files changed, 1158 insertions(+), 460 deletions(-) diff --git a/ruff.toml b/ruff.toml index b82d11c..7eca042 100644 --- a/ruff.toml +++ b/ruff.toml @@ -50,7 +50,7 @@ ignore = [ "EXE002", # Use of exec - IGNORE "DTZ005", # Use of datetime.now() without tz - IGNORE # "Q000", # Quotes - # "ERA001", # Commented out code + "ERA001", # Commented out code ] diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 875b142..58e54f5 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -3,9 +3,18 @@ from __future__ import annotations import logging +import time from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice +from pyvesync.const import ( + AIRFRYER_PID_MAP, + AirFryerCookStatus, + AirFryerFeatures, + AirFryerPresetRecipe, + TemperatureUnits, + TimeUnits, +) if TYPE_CHECKING: from pyvesync import VeSync @@ -19,11 +28,24 @@ class FryerState(DeviceState): """State class for Air Fryer devices. - Note: This class is a placeholder for future functionality and does not currently - implement any specific features or attributes. + Time units are in seconds by default. They are automatically converted + from the API response. """ - __slots__ = () + __slots__ = ( + '_time_conv', + 'cook_last_time', + 'cook_mode', + 'cook_set_temp', + 'cook_set_time', + 'cook_status', + 'current_temp', + 'last_timestamp', + 'preheat_last_time', + 'preheat_set_time', + 'ready_start', + 'time_units', + ) def __init__( self, @@ -42,12 +64,180 @@ def __init__( super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features + self.time_units: TimeUnits = feature_map.time_units + self.ready_start: bool = False + self.cook_status: str | None = None + self.cook_mode: 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._time_conv: float = ( + 60 if feature_map.time_units == TimeUnits.MINUTES else 1 + ) + + @property + def is_in_preheat_mode(self) -> bool: + """Return True if the fryer has preheat feature.""" + return self.cook_status in [ + AirFryerCookStatus.HEATING, + AirFryerCookStatus.PREHEAT_STOP, + ] or ( + self.cook_status == AirFryerCookStatus.PULL_OUT + and self.preheat_set_time is not None + ) + + @property + def is_in_cook_mode(self) -> bool: + """Return True if the fryer is in cook mode.""" + return self.cook_status in [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.COOK_STOP, + ] or ( + self.cook_status == AirFryerCookStatus.PULL_OUT + and self.cook_set_time is not None + ) + + @property + def is_cooking(self) -> bool: + """Return True if the fryer is currently cooking (not preheating).""" + return self.cook_status == AirFryerCookStatus.COOKING + + @property + def is_preheating(self) -> bool: + """Return True if the fryer is currently preheating.""" + return self.cook_status == AirFryerCookStatus.HEATING + + @property + def is_running(self) -> bool: + """Return True if the fryer is running (cooking or preheating).""" + return self.is_cooking or self.is_preheating + + @property + def can_resume(self) -> bool: + """Return True if the fryer can resume cooking.""" + return self.cook_status in [ + AirFryerCookStatus.PREHEAT_STOP, + AirFryerCookStatus.COOK_STOP, + ] + + @property + def preheat_time_remaining(self) -> int | None: + """Return the remaining preheat time in seconds.""" + if not self.is_in_preheat_mode: + return None + if self.cook_status in [ + AirFryerCookStatus.PREHEAT_STOP, + AirFryerCookStatus.PULL_OUT, + ]: + return self.preheat_last_time + if self.preheat_last_time is not None and self.last_timestamp is not None: + return max( + 0, + self.preheat_last_time + - int((self.last_timestamp - time.time()) * self._time_conv), + ) + return None + + @property + def cook_time_remaining(self) -> int | None: + """Return the remaining cook time in seconds.""" + if not self.is_in_cook_mode: + return None + if self.cook_status in [ + AirFryerCookStatus.PULL_OUT, + AirFryerCookStatus.COOK_STOP, + ]: + return self.cook_last_time + if self.cook_last_time is not None and self.last_timestamp is not None: + return max( + 0, + self.cook_last_time + - int((self.last_timestamp - time.time()) * self._time_conv), + ) + return None + + def _clear_preheat(self) -> None: + """Clear preheat status.""" + self.preheat_set_time = None + self.preheat_last_time = None + + def set_standby(self) -> None: + """Set the fryer state to standby and clear all state attributes. + + This is to be called by device classes before updating the state from + the API response to prevent stale data. The get_details API responses + do not include all keys in every response depending on the status. + """ + self.cook_status = AirFryerCookStatus.STANDBY + self.current_temp = None + self.cook_set_temp = None + self.cook_set_time = None + self.cook_last_time = None + self.last_timestamp = None + self._clear_preheat() + + def set_state( # noqa: PLR0913 + self, + *, + cook_status: str, + cook_time: int | None = None, + cook_temp: int | None = None, + temp_unit: str | None = None, + cook_mode: str | None = None, + preheat_time: int | None = None, + current_temp: int | None = None, + ) -> None: + """Set the cook state parameters. + + Args: + cook_status (str): The cooking status. + cook_time (int | None): The cooking time in seconds. + cook_temp (int | None): The cooking temperature. + temp_unit (str | None): The temperature units (F or C). + cook_mode (str | None): The cooking mode. + preheat_time (int | None): The preheating time in seconds. + current_temp (int | None): The current temperature. + """ + if cook_status == AirFryerCookStatus.STANDBY: + self.set_standby() + return + self.cook_status = cook_status + self.cook_set_time = cook_time + self.cook_set_temp = cook_temp + self.cook_mode = cook_mode + self.current_temp = current_temp + if temp_unit is not None: + self.device.temp_unit = temp_unit + if preheat_time is not None: + self.preheat_set_time = preheat_time + if cook_status in [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.HEATING, + ]: + self.last_timestamp = int(time.time()) + else: + self.last_timestamp = None class VeSyncFryer(VeSyncBaseDevice): """Base class for VeSync Air Fryer devices.""" - __slots__ = () + __slots__ = ( + '_temp_unit', + 'cook_modes', + 'default_preset', + 'max_temp_c', + 'max_temp_f', + 'min_temp_c', + 'min_temp_f', + 'state_chamber_1', + 'state_chamber_2', + 'sync_chambers', + ) def __init__( self, @@ -66,3 +256,120 @@ def __init__( This is a bare class as there is only one supported air fryer model. """ super().__init__(details, manager, feature_map) + self.cook_modes: dict[str, str] = feature_map.cook_modes + self.pid: str | None = AIRFRYER_PID_MAP.get(details.deviceType, None) + self.default_preset: AirFryerPresetRecipe = feature_map.default_preset + self.state_chamber_1: FryerState = FryerState(self, details, feature_map) + self.state_chamber_2: FryerState = FryerState(self, details, feature_map) + self.sync_chambers: bool = False + self._temp_unit: TemperatureUnits | None = None + 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] + + # Use single state attribute if not dual chamber fryer for compatibility + if AirFryerFeatures.DUAL_CHAMBER not in self.features: + self.state = self.state_chamber_1 + + @property + def temp_unit(self) -> TemperatureUnits | None: + """Return the temperature unit (F or C).""" + return self._temp_unit + + @temp_unit.setter + def temp_unit(self, value: str) -> None: + """Set the temperature unit. + + Args: + value (str): The temperature unit (F or C). + """ + self._temp_unit = TemperatureUnits.from_string(value) + + async def end(self, chamber: int = 1) -> bool: + """End the current cooking or preheating session. + + Arguments: + chamber (int): The chamber number to end for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber + logger.info('end not configured for this fryer.') + return False + + async def stop(self, chamber: int = 1) -> bool: + """Stop (Pause) the current cooking or preheating session. + + Arguments: + chamber (int): The chamber number to stop for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber + logger.info('stop not configured for this fryer.') + return False + + async def resume(self, chamber: int = 1) -> bool: + """Resume a paused cooking or preheating session. + + Arguments: + chamber (int): The chamber number to resume for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber + logger.info('resume not configured for this fryer.') + return False + + async def set_cook_mode( + self, + cook_time: int, + cook_temp: int, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + """Set the cooking mode. + + Args: + cook_time (int): The cooking time in seconds. + cook_temp (int): The cooking temperature. + cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. + chamber (int): The chamber number to set cooking for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del cook_time, cook_temp, cook_mode, chamber + logger.warning('set_cook_mode method not implemented for base fryer class.') + return False + + async def set_preheat_mode( + self, + target_temp: int, + preheat_time: int, + cook_time: int, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + """Set the preheating mode. + + Args: + target_temp (int): The target temperature for preheating. + preheat_time (int): The preheating time in seconds. + cook_time (int): The cooking time in seconds after preheating. + cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. + chamber (int): The chamber number to set preheating for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del target_temp, preheat_time, cook_time, cook_mode, chamber + if AirFryerFeatures.PREHEAT not in self.features: + logger.warning('set_preheat_mode method not supported for this fryer.') + return False + logger.warning('set_preheat_mode method not implemented for base fryer class.') + return False diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index a0571b4..d070e08 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -28,6 +28,7 @@ import platform import uuid +from dataclasses import dataclass from enum import Enum, IntEnum, StrEnum from random import randint from types import MappingProxyType @@ -75,6 +76,20 @@ KELVIN_MAX = 6500 +class TimeUnits(StrEnum): + """Time units for VeSync devices. + + Attributes: + MINUTES: Time in minutes. + SECONDS: Time in seconds. + HOURS: Time in hours. + """ + + MINUTES = 'minutes' + SECONDS = 'seconds' + HOURS = 'hours' + + class ProductLines(StrEnum): """High level product line.""" @@ -303,6 +318,52 @@ def from_bool(cls, value: bool | None) -> ConnectionStatus: return cls.ONLINE if value else cls.OFFLINE +class TemperatureUnits(StrEnum): + """Temperature units for VeSync devices. + + Attributes: + CELSIUS: Temperature in Celsius. + FAHRENHEIT: Temperature in Fahrenheit. + """ + + CELSIUS = 'c' + FAHRENHEIT = 'f' + + @property + def code(self) -> str: + """Return the code for the temperature unit.""" + return self.value + + @property + def label(self) -> str: + """Return the label for the temperature unit.""" + return self.name.lower() + + @classmethod + def from_string(cls, value: str) -> TemperatureUnits: + """Convert string value to corresponding TemperatureUnit.""" + if value.lower() == 'c' or value.lower() == 'celsius': + return cls.CELSIUS + if value.lower() == 'f' or value.lower() == 'fahrenheit': + return cls.FAHRENHEIT + exc_msg = f'Invalid temperature unit: {value} value' + raise ValueError(exc_msg) + + @classmethod + def to_celsius(cls, value: float, unit: TemperatureUnits) -> float: + """Convert temperature to Celsius.""" + if unit == cls.FAHRENHEIT: + return (value - 32) * 5.0 / 9.0 + return value + + @classmethod + def to_fahrenheit(cls, value: float, unit: TemperatureUnits) -> float: + """Convert temperature to Fahrenheit.""" + if unit == cls.CELSIUS: + return (value * 9.0 / 5.0) + 32 + return value + + class NightlightModes(StrEnum): """Nightlight modes. @@ -690,6 +751,144 @@ class FanModes(StrEnum): """PID's for VeSync Air Fryers based on ConfigModule.""" +CUSTOM_RECIPE_ID = 1 +CUSTOM_RECIPE_TYPE = 3 +CUSTOM_RECIPE_NAME = 'Manual Cook' +CUSTOM_COOK_MODE = 'custom' + + +@dataclass +class AirFryerPresetRecipe: + """Preset recipe for VeSync Air Fryers. + + Attributes: + recipe_id (int): Recipe ID. + recipe_type (int): Recipe type. + name (str): Recipe name. + """ + + cook_mode: str + recipe_id: int + recipe_type: int + target_temp: int + temp_unit: str + cook_time: int + + +class AirFryerPresets: + """Preset recipes for VeSync Air Fryers. + + Attributes: + custom (AirFryerPresetRecipe): Custom preset recipe. + """ + custom: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Custom', + recipe_id=1, + recipe_type=3, + target_temp=350, + temp_unit='f', + cook_time=20, + ) + air_fry: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='AirFry', + recipe_id=4, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=25, + ) + + +AIRFRYER_PRESET_MAP = { + 'custom': AirFryerPresetRecipe( + cook_mode='Custom', + recipe_id=1, + recipe_type=3, + target_temp=350, + temp_unit='f', + cook_time=20, + ), + 'airfry': AirFryerPresetRecipe( + cook_mode='AirFry', + recipe_id=4, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=25, + ), + +} + + +class AirFryerCookModes(StrEnum): + """Cooking modes for VeSync Air Fryers. + + Attributes: + CUSTOM: Custom cooking mode. + FRY: Fry cooking mode. + ROAST: Roast cooking mode. + BAKE: Bake cooking mode. + REHEAT: Reheat cooking mode. + DEHYDRATE: Dehydrate cooking mode. + """ + + CUSTOM = 'custom' + ROAST = 'roast' + BAKE = 'bake' + REHEAT = 'reheat' + DEHYDRATE = 'dehydrate' + FROZEN = 'frozen' + PROOF = 'proof' + BROIL = 'broil' + WARM = 'warm' + AIRFRY = 'airfry' + DRY = 'dry' + PREHEAT = 'preheat' + + +class AirFryerFeatures(Features): + """VeSync Air Fryer features. + + Attributes: + ONOFF: Device on/off status. + TEMP: Temperature status. + TIME: Time status. + COOK_MODE: Cooking mode status. + PRESET_RECIPE: Preset recipe status. + CUSTOM_RECIPE: Custom recipe status. + PAUSE: Pause status. + """ + + DUAL_BLAZE = 'dual_blaze' + PREHEAT = 'preheat' + DUAL_CHAMBER = 'dual_chamber' + RESUMABLE = 'resumable' + + +class AirFryerCookStatus(StrEnum): + """Cooking status for VeSync Air Fryers. + + Attributes: + COOKING: Device is cooking. + PAUSED: Device is paused. + COMPLETED: Cooking is completed. + UNKNOWN: Cooking status is unknown. + """ + + COOKING = 'cooking' + COOK_STOP = 'cook_stop' + COOK_END = 'cook_end' + PULL_OUT = 'pull_out' + PAUSED = 'paused' + COMPLETED = 'completed' + HEATING = 'heating' + STOPPED = 'stopped' + UNKNOWN = 'unknown' + STANDBY = 'standby' + PREHEAT_END = 'preheat_end' + PREHEAT_STOP = 'preheat_stop' + + # Thermostat Constants @@ -833,15 +1032,6 @@ class ThermostatConst: WorkStatus = ThermostatWorkStatusCodes -# ------------------- AIR FRYER CONST ------------------ # - - -CUSTOM_RECIPE_ID = 1 -CUSTOM_RECIPE_TYPE = 3 -CUSTOM_RECIPE_NAME = 'Manual Cook' -CUSTOM_COOK_MODE = 'custom' - - # ------------------- OUTLET CONST ------------------ # diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 320c298..0ca2836 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -65,6 +65,10 @@ from typing import Union from pyvesync.const import ( + AirFryerCookModes, + AirFryerFeatures, + AirFryerPresetRecipe, + AirFryerPresets, BulbFeatures, ColorMode, EnergyIntervals, @@ -86,6 +90,7 @@ ThermostatHoldOptions, ThermostatRoutineTypes, ThermostatWorkModes, + TimeUnits, ) from pyvesync.devices import ( vesyncbulb, @@ -135,8 +140,8 @@ class DeviceMapTemplate: setup_entry: str model_display: str model_name: str - device_alias: str | None = None - features: list[str] = field(default_factory=list) + features: list[str] + device_alias: str @dataclass(kw_only=True) @@ -241,11 +246,11 @@ class FanMap(DeviceMapTemplate): set_mode_method (str): Method to set the mode for the device. """ + modes: dict[str, str] + fan_levels: list[int] product_line: str = ProductLines.WIFI_AIR product_type: str = ProductTypes.FAN module: ModuleType = vesyncfan - fan_levels: list[int] = field(default_factory=list) - modes: dict[str, str] = field(default_factory=dict) sleep_preferences: list[str] = field(default_factory=list) set_mode_method: str = '' @@ -271,9 +276,9 @@ class HumidifierMap(DeviceMapTemplate): warm_mist_levels (list[int | str]): List of warm mist levels for the device. """ + mist_modes: dict[str, str] + mist_levels: list[int] product_line: str = ProductLines.WIFI_AIR - mist_modes: dict[str, str] = field(default_factory=dict) - mist_levels: list[int] = field(default_factory=list) product_type: str = ProductTypes.HUMIDIFIER module: ModuleType = vesynchumidifier target_minmax: tuple[int, int] = (30, 80) @@ -302,11 +307,11 @@ class PurifierMap(DeviceMapTemplate): auto_preferences (list[str]): List of auto preferences for the device. """ + fan_levels: list[int] + modes: list[str] product_line: str = ProductLines.WIFI_AIR product_type: str = ProductTypes.PURIFIER module: ModuleType = vesyncpurifier - fan_levels: list[int] = field(default_factory=list) - modes: list[str] = field(default_factory=list) nightlight_modes: list[str] = field(default_factory=list) auto_preferences: list[str] = field(default_factory=list) @@ -330,11 +335,16 @@ class AirFryerMap(DeviceMapTemplate): module (ModuleType): Module for the device. """ + time_units: TimeUnits = TimeUnits.MINUTES temperature_range_f: tuple[int, int] = (200, 400) temperature_range_c: tuple[int, int] = (75, 200) + temperature_step_f: int = 10 product_line: str = ProductLines.WIFI_KITCHEN product_type: str = ProductTypes.AIR_FRYER module: ModuleType = vesynckitchen + default_preset: AirFryerPresetRecipe = AirFryerPresets.custom + cook_modes: dict[str, str] = field(default_factory=dict) + default_cook_mode: str = AirFryerCookModes.AIRFRY @dataclass(kw_only=True) @@ -373,6 +383,7 @@ class ThermostatMap(DeviceMapTemplate): ThermostatMap( dev_types=['LTM-A401S-WUS'], class_name='VeSyncAuraThermostat', + features=[], fan_modes=[ ThermostatFanModes.AUTO, ThermostatFanModes.CIRCULATE, @@ -407,6 +418,7 @@ class ThermostatMap(DeviceMapTemplate): ], setup_entry='LTM-A401S-WUS', model_display='LTM-A401S Series', + device_alias='Aura Thermostat', model_name='Aura Thermostat', ) ] @@ -418,6 +430,7 @@ class ThermostatMap(DeviceMapTemplate): class_name='VeSyncOutlet7A', features=[OutletFeatures.ENERGY_MONITOR], model_name='WiFi Outlet US/CA', + device_alias='Round 7A WiFi Outlet', model_display='ESW01-USA Series', setup_entry='wifi-switch-1.3', ), @@ -427,6 +440,7 @@ class ThermostatMap(DeviceMapTemplate): features=[], model_name='10A WiFi Outlet USA', model_display='ESW10-USA Series', + device_alias='10A Round WiFi Outlet', setup_entry='ESW10-USA', ), OutletMap( @@ -435,6 +449,7 @@ class ThermostatMap(DeviceMapTemplate): features=[OutletFeatures.ENERGY_MONITOR], model_name='ESW03 10A WiFi Outlet', model_display='ESW01/03 USA/EU', + device_alias='10A Round WiFi Outlet', setup_entry='ESW03', ), OutletMap( @@ -444,6 +459,7 @@ class ThermostatMap(DeviceMapTemplate): nightlight_modes=[NightlightModes.ON, NightlightModes.OFF, NightlightModes.AUTO], model_name='15A WiFi Outlet US/CA', model_display='ESW15-USA Series', + device_alias='15A Rectangular WiFi Outlet', setup_entry='ESW15-USA', ), OutletMap( @@ -452,6 +468,7 @@ class ThermostatMap(DeviceMapTemplate): features=[OutletFeatures.ENERGY_MONITOR], model_name='Outdoor Plug', model_display='ESO15-TB Series', + device_alias='Outdoor Smart Plug', setup_entry='ESO15-TB', ), OutletMap( @@ -1075,8 +1092,16 @@ class ThermostatMap(DeviceMapTemplate): device_alias='Air Fryer', 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', - ) + setup_entry='CS158-AF', + temperature_step_f=10, + features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], + cook_modes={ + AirFryerCookModes.AIRFRY: 'custom', + AirFryerCookModes.PREHEAT: 'preheat', + }, + default_preset=AirFryerPresets.custom, + default_cook_mode=AirFryerCookModes.CUSTOM + ), ] """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 b1e5499..7f7623c 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -34,7 +34,9 @@ from typing_extensions import deprecated from pyvesync.base_devices import FryerState, VeSyncFryer -from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus +from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus, TemperatureUnits, AirFryerCookModes, AirFryerCookStatus, AirFryerFeatures, AirFryerPresets +from pyvesync.models.fryer_models import Fryer158CookingReturnStatus, Fryer158RequestModel, Fryer158Result +from pyvesync.utils.device_mixins import BypassV1Mixin, process_bypassv1_result from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger @@ -61,274 +63,268 @@ 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): +# 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__ = () + +# 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 self.cook_status in ('cooking', 'heating') and 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: +# """Set status from jsonCmd of API call.""" +# self.last_timestamp = None +# if not isinstance(json_cmd, dict): +# return +# self.preheat = False + +# preheat_cmd = json_cmd.get('preheat') +# if isinstance(preheat_cmd, dict): +# self.preheat = True +# preheat_status = preheat_cmd.get('preheatStatus') +# if preheat_status == 'stop': +# self.cook_status = 'preheatStop' +# return +# if preheat_status == 'heating': +# self.cook_status = 'heating' +# self.last_timestamp = int(time.time()) +# self.preheat_set_time = preheat_cmd.get( +# 'preheatSetTime', self.preheat_set_time +# ) +# preheat_set_time = preheat_cmd.get('preheatSetTime') +# if preheat_set_time is not None: +# self.preheat_last_time = preheat_set_time +# self.cook_set_temp = preheat_cmd.get('targetTemp', self.cook_set_temp) +# self.cook_set_time = preheat_cmd.get('cookSetTime', self.cook_set_time) +# self.cook_last_time = None +# return +# if preheat_status == 'end': +# self.cook_status = 'preheatEnd' +# self.preheat_last_time = 0 +# return + +# cook_cmd = json_cmd.get('cookMode') +# if not isinstance(cook_cmd, dict): +# return + +# self.clear_preheat() +# cook_status = cook_cmd.get('cookStatus') +# if cook_status == 'stop': +# self.cook_status = 'cookStop' +# return +# if cook_status == 'cooking': +# self.cook_status = 'cooking' +# self.last_timestamp = int(time.time()) +# self.cook_set_time = cook_cmd.get('cookSetTime', self.cook_set_time) +# self.cook_set_temp = cook_cmd.get('cookSetTemp', self.cook_set_temp) +# self.current_temp = cook_cmd.get('currentTemp', self.current_temp) +# self.temp_unit = cook_cmd.get( +# 'tempUnit', +# self.temp_unit, # type: ignore[assignment] +# ) +# return +# if cook_status == '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(BypassV1Mixin, VeSyncFryer): """Cosori Air Fryer Class. Args: @@ -338,7 +334,7 @@ class VeSyncAirFryer158(VeSyncFryer): Attributes: features (list[str]): List of features. - state (AirFryer158138State): Air fryer state. + state (FryerState): 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. @@ -361,12 +357,13 @@ class VeSyncAirFryer158(VeSyncFryer): """ __slots__ = ( - 'cook_temps', 'last_update', 'ready_start', 'refresh_interval', ) + request_keys: tuple[str, ...] = (*BypassV1Mixin.request_keys, 'pid') + def __init__( self, details: ResponseDeviceDetailsModel, @@ -376,171 +373,207 @@ def __init__( """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 + self.ready_start = True + self.state: FryerState = FryerState(self, details, feature_map) 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}' + f'{self.config_module} not found in PID map for {self.device_type}' ) 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', - ) + # 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( + # 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 + + def _build_cook_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 + cook_time: int, + cook_temp: int, + cook_status: str = 'cooking', + ) -> dict[str, int | str | bool]: + """Internal command to build cookMode API command.""" + cook_mode: dict[str, int | str | bool] = {} + cook_mode['accountId'] = self.manager.account_id + cook_mode['appointmentTs'] = 0 + cook_mode['cookSetTemp'] = cook_temp + cook_mode['cookSetTime'] = cook_time + cook_mode['cookStatus'] = cook_status + cook_mode['customRecipe'] = 'Manual' + cook_mode['mode'] = self.default_preset.cook_mode + cook_mode['readyStart'] = True + cook_mode['recipeId'] = self.default_preset.recipe_id + cook_mode['recipeType'] = self.default_preset.recipe_type + if self.temp_unit is not None: + cook_mode['tempUnit'] = self.temp_unit.label + else: + cook_mode['tempUnit'] = 'fahrenheit' + return cook_mode 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) + resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=cmd) + + resp_model = process_bypassv1_result( + self, + logger, + 'get_details', + resp, + Fryer158Result, + ) + + if resp_model is None or resp_model.returnStatus is None: + logger.debug('No returnStatus in get_details response for %s', self.device_name) + self.state.set_standby() + return None + + return_status = resp_model.returnStatus + return self.state.set_state( + cook_status=return_status.cookStatus, + cook_time=return_status.cookSetTime, + cook_temp=return_status.cookSetTemp, + temp_unit=return_status.tempUnit, + cook_mode=return_status.mode, + preheat_time=return_status.preheatSetTime, + current_temp=return_status.currentTemp, + ) + + async def end_cook(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + cmd = {'cookMode': {'cookStatus': 'end'}} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict={'jsonCmd': cmd} + ) 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 - ) + logger.debug('No response from end command for %s', self.device_name) return False + self.state.set_standby() + return True - status_api = await self._status_api(cmd) - if status_api is False: + async def end_preheat(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + cmd = {'preheat': {'preheatStatus': 'end'}} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict={'jsonCmd': cmd} + ) + if resp is None: + logger.debug('No response from end preheat command for %s', self.device_name) 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']: + async def end(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.is_in_cook_mode is True: + cmd = {'cookMode': {'cookStatus': 'end'}} + if self.state.is_in_preheat_mode is True: + cmd = {'preheat': {'preheatStatus': 'end'}} + else: logger.debug( - 'Cannot pause %s as it is not cooking or preheating', self.device_name + 'Cannot end %s as it is not cooking or preheating', self.device_name ) return False - if self.state.preheat is True: + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=json_cmd) + if resp is not None: + self.state.set_standby() + return True + logger.warning('Error ending for %s', self.device_name) + return False + + async def stop(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'stop'}} - else: + if self.state.is_in_cook_mode is True: 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' + else: + logger.debug( + 'Cannot stop %s as it is not cooking or preheating', self.device_name + ) + return False + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict=json_cmd + ) + if resp is not None: + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOK_STOP return True + logger.warning('Error stopping for %s', self.device_name) 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 resume(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.is_in_preheat_mode is True: + cmd = {'preheat': {'preheatStatus': 'heating'}} + if self.state.is_in_cook_mode is True: + cmd = {'cookMode': {'cookStatus': 'cooking'}} + else: + logger.debug( + 'Cannot resume %s as it is not cooking or preheating', self.device_name + ) + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict=json_cmd + ) + if resp is not None: + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.HEATING + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOKING + return True + logger.warning('Error resuming for %s', self.device_name) + return False async def cook(self, set_temp: int, set_time: int) -> bool: """Set cook time and temperature in Minutes.""" @@ -549,24 +582,24 @@ async def cook(self, set_temp: int, set_time: int) -> bool: 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 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.is_in_preheat_mode 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.is_in_preheat_mode 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.""" @@ -589,7 +622,7 @@ async def set_preheat(self, target_temp: int, cook_time: int) -> bool: 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': + if self.state.is_in_preheat_mode 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') diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index bf14725..cc30dec 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -2,33 +2,166 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Annotated -from pyvesync.models.base_models import ResponseBaseModel +from mashumaro.types import Discriminator + +from pyvesync.models.base_models import RequestBaseModel, ResponseBaseModel +from pyvesync.models.bypass_models import BypassV1Result, RequestBypassV1 + + +@dataclass +class Fryer158RequestModel(RequestBypassV1): + """Request model for air fryer commands.""" + + pid: str # type: ignore[misc] # bug in mypy invalid argument ordering + jsonCmd: dict # type: ignore[misc] # bug in mypy invalid argument ordering + deviceId: str = field(default_factory=str) + configModel: str = field(default_factory=lambda: '') + + def __post_serialize__(self, d: dict) -> dict: + """Remove empty strings before serialization.""" + for attrs in ['deviceId', 'configModel']: + d.pop(attrs, None) + return d @dataclass -class ResultFryerDetails(ResponseBaseModel): +class Fryer158Result(BypassV1Result): """Result model for air fryer details.""" - returnStatus: FryerCookingReturnStatus | FryerBaseReturnStatus | None = None + returnStatus: Fryer158CookingReturnStatus @dataclass -class FryerCookingReturnStatus(ResponseBaseModel): +class Fryer158CookingReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" - currentTemp: int - cookSetTemp: int + cookStatus: str + currentTemp: int | None = None + cookSetTemp: int | None = None + mode: str | None = None + cookSetTime: int | None = None + cookLastTime: int | None = None + tempUnit: str | None = None + preheatLastTime: int | None = None + preheatSetTime: int | None = None + targetTemp: int | None = None + + +@dataclass +class Fryer158CookRequest(RequestBaseModel): + """Base request model for air fryer cooking commands.""" + cookMode: Annotated[Fryer158CookModeBase, Discriminator(include_subtypes=True)] + + +@dataclass +class Fryer158PreheatRequest(RequestBaseModel): + """Base request model for air fryer preheat commands.""" + preheat: Annotated[Fryer158PreheatModeBase, Discriminator(include_subtypes=True)] + + +@dataclass +class Fryer158CookModeBase(RequestBaseModel): + """Base model for air fryer cooking modes.""" + + +@dataclass +class Fryer158CookModeFromPreheat(Fryer158CookModeBase): + """Model for continuing a cooking mode.""" + cookStatus: str + accountId: str mode: str - cookSetTime: int - cookLastTime: int + + +@dataclass +class Fryer158CookModeChange(Fryer158CookModeBase): + """Model for stopping a cooking mode.""" cookStatus: str + + +@dataclass +class Fryer158CookModeStart(Fryer158CookModeBase): + """Model for starting a cooking mode.""" + cookStatus: str + accountId: str + mode: str tempUnit: str + readyStart: bool + cookSetTime: int + cookSetTemp: int + appointmentTs: int = 0 + customRecipe: str = 'Manual Cooking' + recipeId: int = 1 + recipeType: int = 3 @dataclass -class FryerBaseReturnStatus(ResponseBaseModel): - """Result returnStatus model for air fryer status.""" +class Fryer158PreheatModeBase(RequestBaseModel): + """Base model for air fryer preheat modes.""" - cookStatus: str + +@dataclass +class Fryer158PreheatModeChange(Fryer158PreheatModeBase): + """Model for continuing a preheat mode.""" + preheatStatus: str + + +@dataclass +class Fryer158PreheatModeStart(Fryer158PreheatModeBase): + """Model for starting a preheat mode.""" + preheatStatus: str + accountId: str + mode: str + tempUnit: str + readyStart: bool + preheatSetTime: int + targetTemp: int + cookSetTime: int + customRecipe: str = 'Manual' + recipeId: int = 1 + recipeType: int = 3 + + +# a = { +# 'cookMode': { +# 'accountId': '1221391', +# 'appointmentTs': 0, +# 'cookSetTemp': 350, +# 'cookSetTime': 15, +# 'cookStatus': 'cooking', +# 'customRecipe': 'Manual Cooking', +# 'mode': 'custom', +# 'readyStart': True, +# 'recipeId': 1, +# 'recipeType': 3, +# 'tempUnit': 'fahrenheit', +# }, +# 'preheat': { +# 'customRecipe': 'Manual', +# 'readyStart': False, +# 'cookSetTime': 15, +# 'tempUnit': 'fahrenheit', +# 'mode': 'custom', +# 'accountId': '1221391', +# 'targetTemp': 350, +# 'preheatSetTime': 4, +# 'preheatStatus': 'heating', +# 'recipeId': 1, +# 'recipeType': 3, +# }, +# 'cookMode': { +# 'accountId': '1221391', +# 'cookSetTemp': 400, +# 'recipeId': 1, +# 'mode': 'custom', +# 'readyStart': False, +# 'appointmentTs': 0, +# 'cookStatus': 'cooking', +# 'customRecipe': 'Manual', +# 'cookSetTime': 15, +# 'tempUnit': 'fahrenheit', +# 'recipeType': 3, +# }, +# } diff --git a/src/pyvesync/utils/helpers.py b/src/pyvesync/utils/helpers.py index 8994851..47065e6 100644 --- a/src/pyvesync/utils/helpers.py +++ b/src/pyvesync/utils/helpers.py @@ -123,6 +123,16 @@ def temperature_celsius_to_fahrenheit(celsius: float) -> float: """Convert Celsius to Fahrenheit.""" return celsius * 9.0 / 5.0 + 32 + @staticmethod + def minutes_to_seconds(minutes: int) -> int: + """Convert minutes to seconds.""" + return minutes * 60 + + @staticmethod + def seconds_to_minutes(seconds: int) -> int: + """Convert seconds to minutes.""" + return seconds // 60 + class Helpers: """VeSync Helper Functions.""" From 06859154be62298381ed70afc007ca5bfb50b8e3 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 18 Jan 2026 23:35:06 -0500 Subject: [PATCH 02/19] add DC601S --- src/pyvesync/base_devices/fryer_base.py | 141 +++-- src/pyvesync/const.py | 33 +- src/pyvesync/device_map.py | 23 +- src/pyvesync/devices/vesynckitchen.py | 780 +++++++++--------------- src/pyvesync/models/fryer_models.py | 65 +- src/pyvesync/vesync.py | 2 + src/tests/call_json_air_fryers.py | 128 ++++ 7 files changed, 628 insertions(+), 544 deletions(-) create mode 100644 src/tests/call_json_air_fryers.py diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 58e54f5..ab78619 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -152,6 +152,7 @@ def cook_time_remaining(self) -> int | None: AirFryerCookStatus.COOK_STOP, ]: return self.cook_last_time + if self.cook_last_time is not None and self.last_timestamp is not None: return max( 0, @@ -180,15 +181,17 @@ def set_standby(self) -> None: self.last_timestamp = None self._clear_preheat() - def set_state( # noqa: PLR0913 + def set_state( # noqa: PLR0913, C901 self, *, cook_status: str, cook_time: int | None = None, + cook_last_time: int | None = None, cook_temp: int | None = None, temp_unit: str | None = None, cook_mode: str | None = None, preheat_time: int | None = None, + preheat_last_time: int | None = None, current_temp: int | None = None, ) -> None: """Set the cook state parameters. @@ -196,31 +199,42 @@ def set_state( # noqa: PLR0913 Args: cook_status (str): The cooking status. cook_time (int | None): The cooking time in seconds. + cook_last_time (int | None): The last cooking time in seconds. cook_temp (int | None): The cooking temperature. temp_unit (str | None): The temperature units (F or C). cook_mode (str | None): The cooking mode. preheat_time (int | None): The preheating time in seconds. + preheat_last_time (int | None): The remaining preheat time in seconds. current_temp (int | None): The current temperature. """ if cook_status == AirFryerCookStatus.STANDBY: self.set_standby() return - self.cook_status = cook_status - self.cook_set_time = cook_time - self.cook_set_temp = cook_temp - self.cook_mode = cook_mode - self.current_temp = current_temp + + self.preheat_set_time = preheat_time + self.preheat_last_time = preheat_last_time + + if cook_status is not None: + self.cook_status = AirFryerCookStatus(cook_status) + if cook_time is not None: + self.cook_set_time = cook_time + if cook_temp is not None: + self.cook_set_temp = cook_temp + if cook_mode is not None: + self.cook_mode = cook_mode + if current_temp is not None: + self.current_temp = current_temp if temp_unit is not None: - self.device.temp_unit = temp_unit + self.device.temp_unit = TemperatureUnits.from_string(temp_unit) if preheat_time is not None: self.preheat_set_time = preheat_time + if cook_last_time is not None: + self.cook_last_time = cook_last_time if cook_status in [ AirFryerCookStatus.COOKING, AirFryerCookStatus.HEATING, ]: self.last_timestamp = int(time.time()) - else: - self.last_timestamp = None class VeSyncFryer(VeSyncBaseDevice): @@ -237,6 +251,8 @@ class VeSyncFryer(VeSyncBaseDevice): 'state_chamber_1', 'state_chamber_2', 'sync_chambers', + 'temperature_interval', + 'time_units', ) def __init__( @@ -262,30 +278,77 @@ def __init__( self.state_chamber_1: FryerState = FryerState(self, details, feature_map) self.state_chamber_2: FryerState = FryerState(self, details, feature_map) self.sync_chambers: bool = False - self._temp_unit: TemperatureUnits | None = None 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.temperature_interval: int = feature_map.temperature_step_f + self.time_units: TimeUnits = feature_map.time_units + + # attempt to set temp unit from country code before first update + self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS + if self.manager.measure_unit and self.manager.measure_unit.lower() == 'imperial': + self._temp_unit = TemperatureUnits.FAHRENHEIT # Use single state attribute if not dual chamber fryer for compatibility if AirFryerFeatures.DUAL_CHAMBER not in self.features: self.state = self.state_chamber_1 @property - def temp_unit(self) -> TemperatureUnits | None: + def temp_unit(self) -> TemperatureUnits: """Return the temperature unit (F or C).""" return self._temp_unit @temp_unit.setter - def temp_unit(self, value: str) -> None: + def temp_unit(self, value: TemperatureUnits) -> None: """Set the temperature unit. Args: - value (str): The temperature unit (F or C). + value (TemperatureUnits): The temperature unit (F or C). """ self._temp_unit = TemperatureUnits.from_string(value) + def validate_temperature(self, temperature: int) -> bool: + """Validate the temperature is within the allowed range. + + Args: + temperature (int): The temperature to validate. + + Returns: + bool: True if the temperature is valid, False otherwise. + """ + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + return self.min_temp_f <= temperature <= self.max_temp_f + return self.min_temp_c <= temperature <= self.max_temp_c + + def round_temperature(self, temperature: int) -> int: + """Round the temperature to the nearest valid step. + + Args: + temperature (int): The temperature to round. + + Returns: + int: The rounded temperature. + """ + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + step: float = self.temperature_interval + return int(round(temperature / step) * step) + step = self.temperature_interval * 5 / 9 + return int(round(temperature / step) * step) + + def convert_time(self, time_in_seconds: int) -> int: + """Convert time in seconds to the device's time units. + + Args: + time_in_seconds (int): The time in seconds. + + Returns: + int: The time converted to the device's time units. + """ + if self.time_units == TimeUnits.MINUTES: + return int(time_in_seconds / 60) + return time_in_seconds + async def end(self, chamber: int = 1) -> bool: """End the current cooking or preheating session. @@ -309,7 +372,7 @@ async def stop(self, chamber: int = 1) -> bool: bool: True if the command was successful, False otherwise. """ del chamber - logger.info('stop not configured for this fryer.') + logger.info('stop not supported by this fryer.') return False async def resume(self, chamber: int = 1) -> bool: @@ -322,14 +385,15 @@ async def resume(self, chamber: int = 1) -> bool: bool: True if the command was successful, False otherwise. """ del chamber - logger.info('resume not configured for this fryer.') + logger.info('resume not supported by this fryer.') return False - async def set_cook_mode( + async def set_mode( self, cook_time: int, cook_temp: int, - cook_mode: str | None = None, + *, + preheat_time: int | None = None, chamber: int = 1, ) -> bool: """Set the cooking mode. @@ -337,39 +401,46 @@ async def set_cook_mode( Args: cook_time (int): The cooking time in seconds. cook_temp (int): The cooking temperature. - cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. + preheat_time (int | None): The preheating time in seconds, if any. chamber (int): The chamber number to set cooking for. Default is 1. Returns: bool: True if the command was successful, False otherwise. """ - del cook_time, cook_temp, cook_mode, chamber - logger.warning('set_cook_mode method not implemented for base fryer class.') + del cook_time, cook_temp, chamber, preheat_time + logger.warning('set_mode method not implemented for base fryer class.') return False - async def set_preheat_mode( + async def set_mode_from_recipe( self, - target_temp: int, - preheat_time: int, - cook_time: int, - cook_mode: str | None = None, - chamber: int = 1, + recipe: AirFryerPresetRecipe, ) -> bool: - """Set the preheating mode. + """Set the cooking mode from a preset recipe. Args: - target_temp (int): The target temperature for preheating. - preheat_time (int): The preheating time in seconds. - cook_time (int): The cooking time in seconds after preheating. - cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. - chamber (int): The chamber number to set preheating for. Default is 1. + recipe (AirFryerPresetRecipe): The preset recipe to use. Returns: bool: True if the command was successful, False otherwise. """ - del target_temp, preheat_time, cook_time, cook_mode, chamber + del recipe + logger.warning( + 'set_mode_from_recipe method not implemented for base fryer class.' + ) + return False + + async def cook_from_preheat(self, chamber: int = 1) -> bool: + """Start cooking after preheating, cookStatus must be preheatEnd. + + Args: + chamber (int): The chamber number to start cooking for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber if AirFryerFeatures.PREHEAT not in self.features: - logger.warning('set_preheat_mode method not supported for this fryer.') + logger.info('Preheat feature not supported on this fryer.') return False - logger.warning('set_preheat_mode method not implemented for base fryer class.') + logger.info('cook_from_preheat not configured for this fryer.') return False diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index d070e08..fdd7cb6 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -331,7 +331,7 @@ class TemperatureUnits(StrEnum): @property def code(self) -> str: - """Return the code for the temperature unit.""" + """Return the code for the temperature unit 'f' or 'c'.""" return self.value @property @@ -761,18 +761,27 @@ class FanModes(StrEnum): class AirFryerPresetRecipe: """Preset recipe for VeSync Air Fryers. + Set preheat_time to enable preheat mode. + Attributes: recipe_id (int): Recipe ID. recipe_type (int): Recipe type. - name (str): Recipe name. + recipe_name (str): Recipe name. + cook_mode (str): Cooking mode. + target_temp (int): Target temperature. + temp_unit (str): Temperature unit ('f' or 'c'). + cook_time (int): Cooking time in seconds. + preheat_time (int | None): Preheating time in seconds, if any. """ + recipe_name: str cook_mode: str recipe_id: int recipe_type: int target_temp: int temp_unit: str cook_time: int + preheat_time: int | None = None class AirFryerPresets: @@ -783,25 +792,28 @@ class AirFryerPresets: """ custom: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='Custom', + recipe_name='Manual Cook', recipe_id=1, recipe_type=3, target_temp=350, temp_unit='f', - cook_time=20, + cook_time=10*60, ) air_fry: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='AirFry', - recipe_id=4, + recipe_name='AirFry', + recipe_id=14, recipe_type=3, target_temp=400, temp_unit='f', - cook_time=25, + cook_time=10*60, ) AIRFRYER_PRESET_MAP = { 'custom': AirFryerPresetRecipe( cook_mode='Custom', + recipe_name='Manual Cook', recipe_id=1, recipe_type=3, target_temp=350, @@ -810,6 +822,7 @@ class AirFryerPresets: ), 'airfry': AirFryerPresetRecipe( cook_mode='AirFry', + recipe_name='AirFry', recipe_id=4, recipe_type=3, target_temp=400, @@ -876,17 +889,17 @@ class AirFryerCookStatus(StrEnum): """ COOKING = 'cooking' - COOK_STOP = 'cook_stop' - COOK_END = 'cook_end' - PULL_OUT = 'pull_out' + COOK_STOP = 'cookStop' + COOK_END = 'cookEnd' + PULL_OUT = 'pullOut' PAUSED = 'paused' COMPLETED = 'completed' HEATING = 'heating' STOPPED = 'stopped' UNKNOWN = 'unknown' STANDBY = 'standby' - PREHEAT_END = 'preheat_end' - PREHEAT_STOP = 'preheat_stop' + PREHEAT_END = 'preheatEnd' + PREHEAT_STOP = 'preheatStop' # Thermostat Constants diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 0ca2836..f294dfe 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1097,11 +1097,30 @@ class ThermostatMap(DeviceMapTemplate): features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], cook_modes={ AirFryerCookModes.AIRFRY: 'custom', - AirFryerCookModes.PREHEAT: 'preheat', }, default_preset=AirFryerPresets.custom, - default_cook_mode=AirFryerCookModes.CUSTOM + default_cook_mode=AirFryerCookModes.CUSTOM, + time_units=TimeUnits.MINUTES, ), + AirFryerMap( + class_name='VeSyncTurboBlazeFryer', + module=vesynckitchen, + dev_types=['CAF-DC601S-WUSR', 'CAF-DC601S-WUS'], + setup_entry='CAF-DC601S', + device_alias='TurboBlaze Air Fryer', + model_display='CAF-DC601S Series', + model_name='TurboBlaze 6 Qt. Air Fryer', + temperature_step_f=5, + features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], + cook_modes={ + AirFryerCookModes.AIRFRY: 'AirFry', + }, + default_cook_mode=AirFryerCookModes.AIRFRY, + default_preset=AirFryerPresets.air_fry, + time_units=TimeUnits.SECONDS, + temperature_range_f=(90, 450), + temperature_range_c=(30, 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 7f7623c..fb57788 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -12,34 +12,33 @@ 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 dataclasses import replace 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, TemperatureUnits, AirFryerCookModes, AirFryerCookStatus, AirFryerFeatures, AirFryerPresets -from pyvesync.models.fryer_models import Fryer158CookingReturnStatus, Fryer158RequestModel, Fryer158Result -from pyvesync.utils.device_mixins import BypassV1Mixin, process_bypassv1_result +from pyvesync.const import ( + AIRFRYER_PID_MAP, + AirFryerCookStatus, + AirFryerPresetRecipe, +) +from pyvesync.models import fryer_models as models +from pyvesync.utils.device_mixins import ( + BypassV1Mixin, + BypassV2Mixin, + process_bypassv1_result, + process_bypassv2_result, +) from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers -from pyvesync.utils.logs import LibraryLogger + +# from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync import VeSync @@ -51,279 +50,6 @@ 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__ = () - -# 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 self.cook_status in ('cooking', 'heating') and 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: -# """Set status from jsonCmd of API call.""" -# self.last_timestamp = None -# if not isinstance(json_cmd, dict): -# return -# self.preheat = False - -# preheat_cmd = json_cmd.get('preheat') -# if isinstance(preheat_cmd, dict): -# self.preheat = True -# preheat_status = preheat_cmd.get('preheatStatus') -# if preheat_status == 'stop': -# self.cook_status = 'preheatStop' -# return -# if preheat_status == 'heating': -# self.cook_status = 'heating' -# self.last_timestamp = int(time.time()) -# self.preheat_set_time = preheat_cmd.get( -# 'preheatSetTime', self.preheat_set_time -# ) -# preheat_set_time = preheat_cmd.get('preheatSetTime') -# if preheat_set_time is not None: -# self.preheat_last_time = preheat_set_time -# self.cook_set_temp = preheat_cmd.get('targetTemp', self.cook_set_temp) -# self.cook_set_time = preheat_cmd.get('cookSetTime', self.cook_set_time) -# self.cook_last_time = None -# return -# if preheat_status == 'end': -# self.cook_status = 'preheatEnd' -# self.preheat_last_time = 0 -# return - -# cook_cmd = json_cmd.get('cookMode') -# if not isinstance(cook_cmd, dict): -# return - -# self.clear_preheat() -# cook_status = cook_cmd.get('cookStatus') -# if cook_status == 'stop': -# self.cook_status = 'cookStop' -# return -# if cook_status == 'cooking': -# self.cook_status = 'cooking' -# self.last_timestamp = int(time.time()) -# self.cook_set_time = cook_cmd.get('cookSetTime', self.cook_set_time) -# self.cook_set_temp = cook_cmd.get('cookSetTemp', self.cook_set_temp) -# self.current_temp = cook_cmd.get('currentTemp', self.current_temp) -# self.temp_unit = cook_cmd.get( -# 'tempUnit', -# self.temp_unit, # type: ignore[assignment] -# ) -# return -# if cook_status == '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(BypassV1Mixin, VeSyncFryer): """Cosori Air Fryer Class. @@ -382,95 +108,84 @@ def __init__( ) 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 + def _build_base_request( + self, cook_set_time: int, recipe: AirFryerPresetRecipe | None = None + ) -> dict[str, int | str | bool]: + """Build base cook or preheat request body. + + This allows a custom recipe to be passed, but defaults to manual + cooking. The cook_set_time argument is required and will override + the default time in the recipe. + """ + cook_base: dict[str, int | str | bool] = {} + cook_base['cookSetTime'] = cook_set_time + if recipe is None: + cook_base['recipeId'] = self.default_preset.recipe_id + cook_base['customRecipe'] = self.default_preset.recipe_name + cook_base['mode'] = self.default_preset.cook_mode + cook_base['recipeType'] = self.default_preset.recipe_type + else: + cook_base['recipeId'] = recipe.recipe_id + cook_base['customRecipe'] = recipe.recipe_name + cook_base['mode'] = recipe.cook_mode + cook_base['recipeType'] = recipe.recipe_type + + cook_base['accountId'] = self.manager.account_id + if self.temp_unit is not None: + cook_base['tempUnit'] = self.temp_unit.label + else: + cook_base['tempUnit'] = 'fahrenheit' + cook_base['readyStart'] = True + return cook_base def _build_cook_request( self, cook_time: int, cook_temp: int, - cook_status: str = 'cooking', + recipe: AirFryerPresetRecipe | None = None, ) -> dict[str, int | str | bool]: """Internal command to build cookMode API command.""" - cook_mode: dict[str, int | str | bool] = {} - cook_mode['accountId'] = self.manager.account_id + cook_mode = self._build_base_request(cook_time, recipe) cook_mode['appointmentTs'] = 0 cook_mode['cookSetTemp'] = cook_temp - cook_mode['cookSetTime'] = cook_time - cook_mode['cookStatus'] = cook_status - cook_mode['customRecipe'] = 'Manual' - cook_mode['mode'] = self.default_preset.cook_mode - cook_mode['readyStart'] = True - cook_mode['recipeId'] = self.default_preset.recipe_id - cook_mode['recipeType'] = self.default_preset.recipe_type - if self.temp_unit is not None: - cook_mode['tempUnit'] = self.temp_unit.label - else: - cook_mode['tempUnit'] = 'fahrenheit' + cook_mode['cookStatus'] = AirFryerCookStatus.COOKING.value return cook_mode + def _build_preheat_request( + self, + cook_time: int, + cook_temp: int, + recipe: AirFryerPresetRecipe | None = None, + ) -> dict[str, int | str | bool]: + """Internal command to build preheat API command.""" + preheat_mode = self._build_base_request(cook_time, recipe) + preheat_mode['targetTemp'] = cook_temp + preheat_mode['preheatSetTime'] = cook_time + preheat_mode['preheatStatus'] = AirFryerCookStatus.HEATING.value + return preheat_mode + async def get_details(self) -> None: cmd = {'getStatus': 'status'} - resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=cmd) + resp = await self.call_bypassv1_api(models.Fryer158RequestModel, update_dict=cmd) resp_model = process_bypassv1_result( self, logger, 'get_details', resp, - Fryer158Result, + models.Fryer158Result, ) if resp_model is None or resp_model.returnStatus is None: - logger.debug('No returnStatus in get_details response for %s', self.device_name) + logger.debug( + 'No returnStatus in get_details response for %s', self.device_name + ) self.state.set_standby() return None @@ -478,37 +193,15 @@ async def get_details(self) -> None: return self.state.set_state( cook_status=return_status.cookStatus, cook_time=return_status.cookSetTime, + cook_last_time=return_status.cookLastTime, cook_temp=return_status.cookSetTemp, temp_unit=return_status.tempUnit, cook_mode=return_status.mode, preheat_time=return_status.preheatSetTime, + preheat_last_time=return_status.preheatLastTime, current_temp=return_status.currentTemp, ) - async def end_cook(self, chamber: int = 1) -> bool: - del chamber # chamber not used for this air fryer - cmd = {'cookMode': {'cookStatus': 'end'}} - resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict={'jsonCmd': cmd} - ) - if resp is None: - logger.debug('No response from end command for %s', self.device_name) - return False - self.state.set_standby() - return True - - async def end_preheat(self, chamber: int = 1) -> bool: - del chamber # chamber not used for this air fryer - cmd = {'preheat': {'preheatStatus': 'end'}} - resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict={'jsonCmd': cmd} - ) - if resp is None: - logger.debug('No response from end preheat command for %s', self.device_name) - return False - self.state.set_standby() - return True - async def end(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_cook_mode is True: @@ -521,12 +214,14 @@ async def end(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=json_cmd) - if resp is not None: - self.state.set_standby() - return True - logger.warning('Error ending for %s', self.device_name) - return False + resp = await self.call_bypassv1_api( + models.Fryer158RequestModel, update_dict=json_cmd + ) + r = Helpers.process_dev_response(logger, 'end', self, resp) + if r is None: + return False + self.state.set_standby() + return True async def stop(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer @@ -541,146 +236,239 @@ async def stop(self, chamber: int = 1) -> bool: return False json_cmd = {'jsonCmd': cmd} resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict=json_cmd + models.Fryer158RequestModel, update_dict=json_cmd ) - if resp is not None: - if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP - if self.state.is_in_cook_mode is True: - self.state.cook_status = AirFryerCookStatus.COOK_STOP - return True - logger.warning('Error stopping for %s', self.device_name) - return False + r = Helpers.process_dev_response(logger, 'stop', self, resp) + if r is None: + return False + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOK_STOP + return True async def resume(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'heating'}} - if self.state.is_in_cook_mode is True: + elif self.state.is_in_cook_mode is True: cmd = {'cookMode': {'cookStatus': 'cooking'}} else: logger.debug( - 'Cannot resume %s as it is not cooking or preheating', self.device_name + 'Cannot resume %s as it is not cooking or preheating', self.device_name ) + return False json_cmd = {'jsonCmd': cmd} resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict=json_cmd + models.Fryer158RequestModel, update_dict=json_cmd ) - if resp is not None: - if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.HEATING - if self.state.is_in_cook_mode is True: - self.state.cook_status = AirFryerCookStatus.COOKING - return True - logger.warning('Error resuming for %s', self.device_name) - return False - - 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: + r = Helpers.process_dev_response(logger, 'resume', self, resp) + if r is None: 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.is_in_preheat_mode 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.is_in_preheat_mode 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 + + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.HEATING + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOKING + return True + + async def set_mode_from_recipe( + self, + recipe: AirFryerPresetRecipe, + *, + chamber: int = 1, + ) -> bool: + del chamber # chamber not used for this air fryer + if recipe.preheat_time is not None and recipe.preheat_time > 0: + cook_status = AirFryerCookStatus.HEATING + preheat_req = self._build_preheat_request( + cook_time=recipe.preheat_time, cook_temp=recipe.target_temp, recipe=recipe ) + cmd = {'preheat': preheat_req} + else: + cook_status = AirFryerCookStatus.COOKING + cook_req = self._build_cook_request( + cook_time=recipe.cook_time, cook_temp=recipe.target_temp, recipe=recipe + ) + cmd = {'cookMode': cook_req} + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + models.Fryer158RequestModel, update_dict=json_cmd + ) + r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) + if r is None: return False - if self._validate_temp(target_temp) is False: + self.state.set_state( + cook_status=cook_status, + cook_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + preheat_time=recipe.preheat_time, + ) + return True + + async def set_mode( + self, + cook_time: int, + cook_temp: int, + *, + preheat_time: int | None = None, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + if self.validate_temperature(cook_temp) is False: + logger.warning('Invalid cook temperature for %s', self.device_name) 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.is_in_preheat_mode is False or self.state.cook_status != 'preheatEnd': + cook_temp = self.round_temperature(cook_temp) + cook_time = self.convert_time(cook_time) + preset_recipe = replace(self.default_preset) + preset_recipe.cook_time = cook_time + preset_recipe.target_temp = cook_temp + if cook_mode is not None: + preset_recipe.cook_mode = cook_mode + if preheat_time is not None: + preset_recipe.preheat_time = self.convert_time(preheat_time) + return await self.set_mode_from_recipe(preset_recipe, chamber=chamber) + + async def cook_from_preheat(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.cook_status != AirFryerCookStatus.PREHEAT_END: 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, + cmd = { + 'cookMode': { + 'mode': self.state.cook_mode, + 'accountId': self.manager.account_id, + 'cookStatus': 'cooking', } + } + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + models.Fryer158RequestModel, update_dict=json_cmd ) - return cmd + r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) + if r is None: + return False + self.state.set_state(cook_status=AirFryerCookStatus.COOKING) + return True + + +class VeSyncTurboBlazeFryer(BypassV2Mixin, VeSyncFryer): + """VeSync TurboBlaze Air Fryer Class.""" - async def _set_cook( + __slots__ = () + + def __init__( 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 + details: ResponseDeviceDetailsModel, + manager: VeSync, + feature_map: AirFryerMap, + ) -> None: + """Init the VeSync TurboBlaze Air Fryer class.""" + super().__init__(details, manager, feature_map) - 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: + # Single chamber fryer state + self.state: FryerState = FryerState(self, details, feature_map) + + def _build_cook_request( + self, recipe: AirFryerPresetRecipe + ) -> models.FryerTurboBlazeRequestData: + cook_req: dict[str, int | str | bool | dict] = {} + cook_req['accountId'] = self.manager.account_id + if recipe.preheat_time is not None and recipe.preheat_time > 0: + cook_req['hasPreheat'] = int(True) + cook_req['hasWarm'] = False + cook_req['mode'] = recipe.cook_mode + cook_req['readyStart'] = True + cook_req['recipeId'] = recipe.recipe_id + cook_req['recipeName'] = recipe.recipe_name + cook_req['recipeType'] = recipe.recipe_type + cook_req['tempUnit'] = self.temp_unit.code + cook_req['startAct'] = { + 'cookSetTime': recipe.cook_time, + 'cookTemp': recipe.target_temp, + 'preheatTemp': recipe.target_temp if recipe.preheat_time else 0, + 'shakeTime': 0, + } + return models.FryerTurboBlazeRequestData.from_dict(cook_req) + + async def get_details(self) -> None: + + resp = await self.call_bypassv2_api(payload_method='getAirfyerStatus') + resp_model = process_bypassv2_result( + self, + logger, + 'get_details', + resp, + models.FryerTurboBlazeDetailResult, + ) + + if ( + resp_model is None + or resp_model.cookStatus == AirFryerCookStatus.STANDBY.value + or not resp_model.stepArray + ): + self.state.set_standby() + return + + cook_step = resp_model.stepArray[resp_model.stepIndex] + + self.state.set_state( + cook_status=resp_model.cookStatus, + cook_time=cook_step.cookSetTime, + cook_last_time=cook_step.cookLastTime, + cook_temp=cook_step.cookTemp, + temp_unit=resp_model.tempUnit, + cook_mode=cook_step.mode, + preheat_time=resp_model.preheatSetTime, + preheat_last_time=resp_model.preheatLastTime, + current_temp=resp_model.currentTemp, + ) + + async def end(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + payload_method = 'endCook' + resp = await self.call_bypassv2_api(payload_method=payload_method) + r = Helpers.process_dev_response(logger, 'end', self, resp) + if r is None: return False + self.state.set_standby() + return True - self.last_update = int(time.time()) - self.state.status_request(json_cmd) - await self.update() + async def set_mode_from_recipe(self, recipe: AirFryerPresetRecipe) -> bool: + payload_method = 'startCook' + data = self._build_cook_request(recipe) + resp = await self.call_bypassv2_api( + payload_method=payload_method, + data=data.to_dict(), + ) + r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) + if r is None: + self.state.set_standby() + return False + self.state.set_state( + cook_status=AirFryerCookStatus.COOKING, + cook_time=recipe.cook_time, + cook_last_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + preheat_time=recipe.preheat_time if recipe.preheat_time else None, + preheat_last_time=recipe.preheat_time if recipe.preheat_time else None, + ) return True + + async def set_mode( + self, + cook_time: int, + cook_temp: int, + *, + preheat_time: int | None = None, + chamber: int = 1, + ) -> bool: + del chamber # chamber not used for this air fryer + recipe = replace(self.default_preset) + recipe.cook_time = self.convert_time(cook_time) + recipe.target_temp = self.round_temperature(cook_temp) + if preheat_time is not None: + recipe.preheat_time = self.convert_time(preheat_time) + return await self.set_mode_from_recipe(recipe) diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index cc30dec..d86871d 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -8,7 +8,11 @@ from mashumaro.types import Discriminator from pyvesync.models.base_models import RequestBaseModel, ResponseBaseModel -from pyvesync.models.bypass_models import BypassV1Result, RequestBypassV1 +from pyvesync.models.bypass_models import ( + BypassV1Result, + BypassV2InnerResult, + RequestBypassV1, +) @dataclass @@ -124,6 +128,65 @@ class Fryer158PreheatModeStart(Fryer158PreheatModeBase): recipeType: int = 3 +@dataclass +class FryerTurboBlazeDetailResult(BypassV2InnerResult): + """Result model for TurboBlaze air fryer details.""" + + stepArray: list[FryerTurboBlazeStepItem] + cookMode: str + tempUnit: str + stepIndex: int + cookStatus: str + preheatSetTime: int + preheatLastTime: int + preheatEndTime: int + preheatTemp: int + startTime: int + totalTimeRemaining: int + currentTemp: int + shakeStatus: int + + +@dataclass +class FryerTurboBlazeStepItem(ResponseBaseModel): + """Data model for TurboBlaze air fryer cooking steps.""" + + cookSetTime: int + cookTemp: int + mode: str + cookLastTime: int + shakeTime: int + cookEndTime: int + recipeName: str + recipeId: int + recipeType: int + + +@dataclass +class FryerTurboBlazeRequestData(RequestBaseModel): + """Request model for TurboBlaze air fryer cooking commands.""" + + accountId: str + hasPreheat: int + hasWarm: bool + readyStart: bool + recipeId: int + recipeName: str + recipeType: int + tempUnit: str + startAct: list[FryerTurboBlazeStartActItem] + + +@dataclass +class FryerTurboBlazeStartActItem(RequestBaseModel): + """Data model for TurboBlaze air fryer startAct items.""" + + cookSetTime: int + cookTemp: int + preheatTemp: int = 0 + shakeTime: int = 0 + + # a = { # 'cookMode': { # 'accountId': '1221391', diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 3fda881..68cca90 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -62,6 +62,7 @@ class VeSync: # pylint: disable=function-redefined 'enabled', 'in_process', 'language', + 'measure_unit', 'session', 'time_zone', ) @@ -142,6 +143,7 @@ class is instantiated, call `await manager.login()` to log in to VeSync servers, self.language: str = 'en' self.enabled = False self.in_process = False + self.measure_unit: str | None = None self._device_container: DeviceContainer = DeviceContainer() # Initialize authentication manager diff --git a/src/tests/call_json_air_fryers.py b/src/tests/call_json_air_fryers.py new file mode 100644 index 0000000..db1a51e --- /dev/null +++ b/src/tests/call_json_air_fryers.py @@ -0,0 +1,128 @@ +""" +Air Fryer Device API Responses + +AIR_FRYER variable is a list of device types + +DETAILS_RESPONSES variable is a dictionary of responses from the API +for get_details() methods. The keys are the device types and the +values are the responses. The responses are tuples of (response, status) + +METHOD_RESPONSES variable is a defaultdict of responses from the API. This is +the FunctionResponse variable from the utils module in the tests dir. +The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). + +The values of METHOD_RESPONSES can be a function that takes a single argument or +a static value. The value is checked if callable at runtime and if so, it is called +with the provided argument. If not callable, the value is returned as is. + +METHOD_RESPONSES = { + 'CS158-AF': defaultdict( + lambda: ({"code": 0, "msg": "success"}, 200)) + ) +} + +# For a function to handle the response +def status_response(request_body=None): + # do work with request_body + return request_body, 200 + +METHOD_RESPONSES['CS158-AF']['set_cook_mode'] = status_response + +# To change the default value for a device type + +METHOD_RESPONSES['CS158-AF'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) + +""" + +from copy import deepcopy +from pyvesync.device_map import air_fryer_modules +from pyvesync.const import ( + DeviceStatus, + ConnectionStatus, + TemperatureUnits, + AirFryerCookStatus, +) +from defaults import ( + TestDefaults, + FunctionResponsesV2, + FunctionResponsesV1, + build_bypass_v1_response, + build_bypass_v2_response, +) + + +class AirFryerDefaults: + temp_unit = TemperatureUnits.FAHRENHEIT + cook_time_f = 10 + cook_temp_f = 350 + cook_mode = "custom" + current_temp_f = 150 + cook_status = AirFryerCookStatus.COOKING + recipe = "Manual" + + +AIR_FRYER_COOKING_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { + "CS158-AF": { + "returnStatus": { + "curentTemp": AirFryerDefaults.current_temp_f, + "cookSetTemp": AirFryerDefaults.cook_temp_f, + "mode": AirFryerDefaults.cook_mode, + "cookSetTime": AirFryerDefaults.cook_time_f, + "cookLastTime": AirFryerDefaults.cook_time_f - 2, + "cookStatus": AirFryerDefaults.cook_status.value, + "tempUnit": AirFryerDefaults.temp_unit.label, + "accountId": TestDefaults.account_id, + "customRecipe": AirFryerDefaults.recipe, + } + }, + "CAF-DC601S": { + "traceId": "1767318172645", + "code": 0, + "result": { + "stepArray": [ + { + "cookSetTime": 1200, + "cookTemp": 330, + "mode": "Bake", + "cookLastTime": 1176, + "shakeTime": 0, + "cookEndTime": 0, + "recipeName": "Bake", + "recipeId": 9, + "recipeType": 3, + } + ], + "cookMode": "normal", + "tempUnit": "f", + "stepIndex": 0, + "cookStatus": "cooking", + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 1767318116, + "totalTimeRemaining": 1176, + "currentTemp": 89, + "shakeStatus": 0, + }, + }, +} + +AIR_FRYER_STANDYBY_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { + "CAF-DC601S": { + "stepArray": [], + "cookMode": "normal", + "tempUnit": "f", + "stepIndex": 0, + "cookStatus": "standby", + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 0, + "totalTimeRemaining": 0, + "currentTemp": 43, + "shakeStatus": 0, + }, + "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, +} From f60806f4ddd6cb1fbe1527765aea6406de1708bc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:42:47 +0000 Subject: [PATCH 03/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/base_devices/fryer_base.py | 4 +--- src/pyvesync/const.py | 6 +++--- src/pyvesync/device_map.py | 2 +- src/pyvesync/devices/vesynckitchen.py | 13 ++++++------- src/pyvesync/models/fryer_models.py | 7 +++++++ 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index ab78619..7c6e766 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -75,9 +75,7 @@ def __init__( self.last_timestamp: int | None = None self.preheat_set_time: int | None = None self.preheat_last_time: int | None = None - self._time_conv: float = ( - 60 if feature_map.time_units == TimeUnits.MINUTES else 1 - ) + self._time_conv: float = 60 if feature_map.time_units == TimeUnits.MINUTES else 1 @property def is_in_preheat_mode(self) -> bool: diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index fdd7cb6..8d04c20 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -790,6 +790,7 @@ class AirFryerPresets: Attributes: custom (AirFryerPresetRecipe): Custom preset recipe. """ + custom: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='Custom', recipe_name='Manual Cook', @@ -797,7 +798,7 @@ class AirFryerPresets: recipe_type=3, target_temp=350, temp_unit='f', - cook_time=10*60, + cook_time=10 * 60, ) air_fry: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='AirFry', @@ -806,7 +807,7 @@ class AirFryerPresets: recipe_type=3, target_temp=400, temp_unit='f', - cook_time=10*60, + cook_time=10 * 60, ) @@ -829,7 +830,6 @@ class AirFryerPresets: temp_unit='f', cook_time=25, ), - } diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index f294dfe..23efc1b 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1120,7 +1120,7 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(90, 450), temperature_range_c=(30, 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 fb57788..98fd6a2 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -299,12 +299,12 @@ async def set_mode_from_recipe( if r is None: return False self.state.set_state( - cook_status=cook_status, - cook_time=recipe.cook_time, - cook_temp=recipe.target_temp, - cook_mode=recipe.cook_mode, - preheat_time=recipe.preheat_time, - ) + cook_status=cook_status, + cook_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + preheat_time=recipe.preheat_time, + ) return True async def set_mode( @@ -393,7 +393,6 @@ def _build_cook_request( return models.FryerTurboBlazeRequestData.from_dict(cook_req) async def get_details(self) -> None: - resp = await self.call_bypassv2_api(payload_method='getAirfyerStatus') resp_model = process_bypassv2_result( self, diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index d86871d..ab0c966 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -57,12 +57,14 @@ class Fryer158CookingReturnStatus(ResponseBaseModel): @dataclass class Fryer158CookRequest(RequestBaseModel): """Base request model for air fryer cooking commands.""" + cookMode: Annotated[Fryer158CookModeBase, Discriminator(include_subtypes=True)] @dataclass class Fryer158PreheatRequest(RequestBaseModel): """Base request model for air fryer preheat commands.""" + preheat: Annotated[Fryer158PreheatModeBase, Discriminator(include_subtypes=True)] @@ -74,6 +76,7 @@ class Fryer158CookModeBase(RequestBaseModel): @dataclass class Fryer158CookModeFromPreheat(Fryer158CookModeBase): """Model for continuing a cooking mode.""" + cookStatus: str accountId: str mode: str @@ -82,12 +85,14 @@ class Fryer158CookModeFromPreheat(Fryer158CookModeBase): @dataclass class Fryer158CookModeChange(Fryer158CookModeBase): """Model for stopping a cooking mode.""" + cookStatus: str @dataclass class Fryer158CookModeStart(Fryer158CookModeBase): """Model for starting a cooking mode.""" + cookStatus: str accountId: str mode: str @@ -109,12 +114,14 @@ class Fryer158PreheatModeBase(RequestBaseModel): @dataclass class Fryer158PreheatModeChange(Fryer158PreheatModeBase): """Model for continuing a preheat mode.""" + preheatStatus: str @dataclass class Fryer158PreheatModeStart(Fryer158PreheatModeBase): """Model for starting a preheat mode.""" + preheatStatus: str accountId: str mode: str From 44c9d29dcf04f85da88725f0e6b764d3a17ece4b Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 2 Mar 2026 21:26:02 -0500 Subject: [PATCH 04/19] Commit before rewrite --- .gitignore | 4 +- src/pyvesync/auth.py | 1 + src/pyvesync/base_devices/fryer_base.py | 355 ++++++++++++++---- src/pyvesync/const.py | 25 +- src/pyvesync/device_map.py | 26 +- src/pyvesync/devices/vesynckitchen.py | 206 +++++++--- src/pyvesync/models/fryer_models.py | 48 ++- src/tests/call_json.py | 6 +- ...json_air_fryers.py => call_json_fryers.py} | 72 +++- src/tests/test_fryers.py | 268 +++++++++++++ 10 files changed, 858 insertions(+), 153 deletions(-) rename src/tests/{call_json_air_fryers.py => call_json_fryers.py} (63%) create mode 100644 src/tests/test_fryers.py diff --git a/.gitignore b/.gitignore index 2a8c0e3..f1bfd12 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ testing_scripts/api_test_editor.py testing_scripts/yamltest.yaml testing_scripts/yamltest copy.yaml creds.json -docstest/* \ No newline at end of file +docstest/* +/.claude +.mcp.json diff --git a/src/pyvesync/auth.py b/src/pyvesync/auth.py index 5104faf..899ebc9 100644 --- a/src/pyvesync/auth.py +++ b/src/pyvesync/auth.py @@ -236,6 +236,7 @@ async def save_credentials_to_file(self, file_path: str | Path | None = None) -> 'token': self._token, 'account_id': self._account_id, 'country_code': self._country_code, + 'current_region': self.current_region, } try: data = orjson.dumps(credentials).decode('utf-8') diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 7c6e766..9c401f1 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -1,14 +1,18 @@ -"""Air Purifier Base Class.""" +"""Air Fryer Base Class.""" from __future__ import annotations import logging -import time +from datetime import datetime, timezone from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice from pyvesync.const import ( AIRFRYER_PID_MAP, + COOK_STATUSES, + PREHEAT_STATUSES, + RESUMABLE_STATUSES, + RUNNING_STATUSES, AirFryerCookStatus, AirFryerFeatures, AirFryerPresetRecipe, @@ -28,22 +32,61 @@ class FryerState(DeviceState): """State class for Air Fryer devices. - Time units are in seconds by default. They are automatically converted - from the API response. + Each FryerState instance represents a single cooking chamber. For single + chamber fryers, ``state`` is set to ``state_chamber_1``. For dual chamber + fryers, ``state_chamber_1`` and ``state_chamber_2`` are used independently. + + Time units are in SECONDS. They are automatically converted from the API + response via the device's ``convert_time_for_state`` method. The + ``last_timestamp`` attribute is set when the fryer enters a running state + (cooking or heating) and is used to calculate remaining time. + + Args: + device (VeSyncFryer): The device object. + details (ResponseDeviceDetailsModel): The device details. + feature_map (AirFryerMap): The feature map for the device. + + Attributes: + active_time (int): Active time of device, defaults to None. + connection_status (str): Connection status of device. + device (VeSyncFryer): Device object. + device_status (str): Device status. + features (dict): Features of device. + time_units (TimeUnits): The time units used by the device. + cook_status (AirFryerCookStatus | None): The current cooking status. + cook_mode (str | None): The current cooking mode. + current_temp (int | None): The current temperature of the fryer. + cook_set_temp (int | None): The set cooking temperature. + cook_set_time (int | None): The set cooking time in seconds. + cook_last_time (int | None): The remaining cooking time in seconds. + last_timestamp (datetime | None): The timestamp of the last status update. + preheat_set_time (int | None): The set preheating time in seconds. + preheat_last_time (int | None): The remaining preheating time in seconds. + recipe (str | None): The current recipe or cooking mode. + ready_start (bool): Whether the fryer is ready to start cooking. + + Note: + Use convenience state methods like ``set_standby``, ``set_cook_stop_state``, + ``set_preheat_stop_state``, and ``set_preheat_resume_state`` to set the + state attributes when changing cooking states. The ``set_state`` method + can be used to set all state attributes at once when updating from the API + response. The ``last_timestamp`` attribute will be automatically updated + when setting a running state (cooking or heating). """ __slots__ = ( - '_time_conv', + '_cook_status', 'cook_last_time', 'cook_mode', 'cook_set_temp', 'cook_set_time', - 'cook_status', 'current_temp', 'last_timestamp', 'preheat_last_time', 'preheat_set_time', 'ready_start', + 'recipe', + 'sync_chambers', 'time_units', ) @@ -53,37 +96,29 @@ def __init__( details: ResponseDeviceDetailsModel, feature_map: AirFryerMap, ) -> None: - """Initialize FryerState. - - Args: - device (VeSyncFryer): The device object. - details (ResponseDeviceDetailsModel): The device details. - feature_map (AirFryerMap): The feature map for the device. - - """ + """Initialize FryerState.""" super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features self.time_units: TimeUnits = feature_map.time_units - self.ready_start: bool = False - self.cook_status: str | None = None - self.cook_mode: str | None = None + self.sync_chambers: bool = False + # Cooking state attributes + self._cook_status: AirFryerCookStatus | None = None self.current_temp: int | None = None + self.cook_mode: str | 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._time_conv: float = 60 if feature_map.time_units == TimeUnits.MINUTES else 1 + self.last_timestamp: datetime | None = None + self.recipe: str | None = None + self.ready_start: bool = False @property def is_in_preheat_mode(self) -> bool: """Return True if the fryer has preheat feature.""" - return self.cook_status in [ - AirFryerCookStatus.HEATING, - AirFryerCookStatus.PREHEAT_STOP, - ] or ( + return self.cook_status in PREHEAT_STATUSES or ( self.cook_status == AirFryerCookStatus.PULL_OUT and self.preheat_set_time is not None ) @@ -91,22 +126,19 @@ def is_in_preheat_mode(self) -> bool: @property def is_in_cook_mode(self) -> bool: """Return True if the fryer is in cook mode.""" - return self.cook_status in [ - AirFryerCookStatus.COOKING, - AirFryerCookStatus.COOK_STOP, - ] or ( + return self.cook_status in COOK_STATUSES or ( self.cook_status == AirFryerCookStatus.PULL_OUT - and self.cook_set_time is not None + and self.preheat_last_time is None ) @property def is_cooking(self) -> bool: - """Return True if the fryer is currently cooking (not preheating).""" + """Return True if the fryer is currently running in cook mode.""" return self.cook_status == AirFryerCookStatus.COOKING @property def is_preheating(self) -> bool: - """Return True if the fryer is currently preheating.""" + """Return True if the fryer is currently running in preheat mode.""" return self.cook_status == AirFryerCookStatus.HEATING @property @@ -117,10 +149,25 @@ def is_running(self) -> bool: @property def can_resume(self) -> bool: """Return True if the fryer can resume cooking.""" - return self.cook_status in [ - AirFryerCookStatus.PREHEAT_STOP, - AirFryerCookStatus.COOK_STOP, - ] + return self.cook_status in RESUMABLE_STATUSES + + @property + def cook_status(self) -> AirFryerCookStatus | None: + """Return the current cooking status.""" + return self._cook_status + + @cook_status.setter + def cook_status(self, value: AirFryerCookStatus | None) -> None: + """Set the current cooking status. + + Automatically updates ``last_timestamp`` when setting a running state. + + Args: + value (AirFryerCookStatus | None): The cooking status to set. + """ + if value in RUNNING_STATUSES: + self.last_timestamp = datetime.now(tz=timezone.utc) + self._cook_status = value @property def preheat_time_remaining(self) -> int | None: @@ -136,7 +183,7 @@ def preheat_time_remaining(self) -> int | None: return max( 0, self.preheat_last_time - - int((self.last_timestamp - time.time()) * self._time_conv), + - int((datetime.now(timezone.utc) - self.last_timestamp).total_seconds()), ) return None @@ -155,7 +202,7 @@ def cook_time_remaining(self) -> int | None: return max( 0, self.cook_last_time - - int((self.last_timestamp - time.time()) * self._time_conv), + - int((datetime.now(timezone.utc) - self.last_timestamp).total_seconds()), ) return None @@ -177,45 +224,165 @@ def set_standby(self) -> None: self.cook_set_time = None self.cook_last_time = None self.last_timestamp = None + self.recipe = None + self._clear_preheat() + + def set_cook_stop_state(self, cook_set_time: int | None = None) -> None: + """Set the fryer state to cook stopped. + + Args: + cook_set_time (int | None): The cooking time in seconds. + """ + self._clear_preheat() + self.cook_status = AirFryerCookStatus.COOK_STOP + if cook_set_time is not None: + self.cook_set_time = cook_set_time + self.cook_last_time = self.cook_time_remaining + self.last_timestamp = None + + def set_preheat_stop_state(self, preheat_set_time: int | None = None) -> None: + """Set the fryer state to preheat stopped.""" + self.cook_status = AirFryerCookStatus.PREHEAT_STOP + if preheat_set_time is not None: + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time + ) + self.preheat_last_time = self.preheat_time_remaining + self.last_timestamp = None + + def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None: + """Set the fryer state to preheat resumed.""" + self.cook_status = AirFryerCookStatus.HEATING + if preheat_set_time is not None: + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time + ) + self.preheat_last_time = self.preheat_time_remaining + self.last_timestamp = datetime.now(timezone.utc) + + def set_cooking_state( + self, *, recipe: str, cook_set_time: int, cook_temp: int, cook_mode: str + ) -> None: + """Set the fryer state to cooking. + + Args: + recipe (str): The recipe name or cooking mode. + cook_set_time (int): The cooking time in device units. + cook_temp (int): The cooking temperature. + cook_mode (str): The cooking mode. + """ self._clear_preheat() + self.cook_status = AirFryerCookStatus.COOKING + self.recipe = recipe + self.cook_set_time = self.device.convert_time_for_state(cook_set_time) + self.cook_last_time = self.cook_set_time + self.cook_set_temp = cook_temp + self.cook_mode = cook_mode + + def set_preheating_state( + self, + *, + recipe: str, + cook_temp: int, + cook_mode: str, + preheat_set_time: int | None = None, + ) -> None: + """Set the fryer state to preheating. + + Args: + recipe (str): The recipe name or cooking mode. + preheat_set_time (int | None): The preheating time in device units. + cook_temp (int): The cooking temperature. + cook_mode (str): The cooking mode. + """ + self.cook_status = AirFryerCookStatus.HEATING + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time or 300 + ) + self.preheat_last_time = self.preheat_set_time + self.cook_set_temp = cook_temp + self.cook_mode = cook_mode + self.recipe = recipe - def set_state( # noqa: PLR0913, C901 + def set_state( # noqa: PLR0912, PLR0913, C901 self, *, - cook_status: str, + cook_status: AirFryerCookStatus, cook_time: int | None = None, cook_last_time: int | None = None, cook_temp: int | None = None, temp_unit: str | None = None, cook_mode: str | None = None, - preheat_time: int | None = None, + preheat_set_time: int | None = None, preheat_last_time: int | None = None, current_temp: int | None = None, + recipe: str | None = None, ) -> None: - """Set the cook state parameters. + """Set the cook state parameters from an API response or action. + + All parameters that are part of the current cook state must be passed. + This should be primarily used when updating from the API response to + ensure all state attributes are updated together. If updating state + from a user action, use the specific state methods like ``set_standby``, + ``set_cook_stop_state``, ``set_cooking_state``, etc. + + Time values are expected in device units and will be converted to + seconds automatically. Args: - cook_status (str): The cooking status. - cook_time (int | None): The cooking time in seconds. - cook_last_time (int | None): The last cooking time in seconds. + cook_status (AirFryerCookStatus): The cooking status. + cook_time (int | None): The cooking time in device units. + cook_last_time (int | None): The remaining cooking time in device units. cook_temp (int | None): The cooking temperature. temp_unit (str | None): The temperature units (F or C). cook_mode (str | None): The cooking mode. - preheat_time (int | None): The preheating time in seconds. - preheat_last_time (int | None): The remaining preheat time in seconds. + preheat_set_time (int | None): The preheating time in device units. + preheat_last_time (int | None): The remaining preheat time in device units. current_temp (int | None): The current temperature. + recipe (str | None): The recipe name or cooking mode. """ + # Stop/standby states delegate to helpers and return early if cook_status == AirFryerCookStatus.STANDBY: self.set_standby() return - self.preheat_set_time = preheat_time - self.preheat_last_time = preheat_last_time + if cook_status == AirFryerCookStatus.COOK_STOP: + self.set_cook_stop_state( + cook_set_time=self.device.convert_time_for_state(cook_time) + if cook_time is not None + else None + ) + return + + if cook_status == AirFryerCookStatus.PREHEAT_STOP: + self.set_preheat_stop_state(preheat_set_time=preheat_set_time) + return + + # Set cook status (setter auto-updates last_timestamp for running statuses) + self.cook_status = cook_status + + # Handle preheat attributes + if preheat_set_time is not None: + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time + ) + self.preheat_last_time = ( + self.device.convert_time_for_state(preheat_last_time) + if preheat_last_time is not None + else self.preheat_set_time + ) + elif cook_status not in PREHEAT_STATUSES: + self._clear_preheat() - if cook_status is not None: - self.cook_status = AirFryerCookStatus(cook_status) + # Handle cook time attributes if cook_time is not None: - self.cook_set_time = cook_time + self.cook_set_time = self.device.convert_time_for_state(cook_time) + if cook_last_time is not None: + self.cook_last_time = self.device.convert_time_for_state(cook_last_time) + elif cook_status == AirFryerCookStatus.HEATING: + self.cook_last_time = None + + # Set remaining attributes if cook_temp is not None: self.cook_set_temp = cook_temp if cook_mode is not None: @@ -224,19 +391,53 @@ def set_state( # noqa: PLR0913, C901 self.current_temp = current_temp if temp_unit is not None: self.device.temp_unit = TemperatureUnits.from_string(temp_unit) - if preheat_time is not None: - self.preheat_set_time = preheat_time - if cook_last_time is not None: - self.cook_last_time = cook_last_time - if cook_status in [ - AirFryerCookStatus.COOKING, - AirFryerCookStatus.HEATING, - ]: - self.last_timestamp = int(time.time()) + if recipe is not None: + self.recipe = recipe class VeSyncFryer(VeSyncBaseDevice): - """Base class for VeSync Air Fryer devices.""" + """Base class for VeSync Air Fryer devices. + + Args: + details (ResponseDeviceDetailsModel): The device details. + manager (VeSync): The VeSync manager. + feature_map (AirFryerMap): The feature map for the device. + + Attributes: + state (FryerState): Device state object. For single chamber fryers, + this is set to ``state_chamber_1``. + 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. + latest_firm_version (str | None): Latest firmware version of device. + device_region (str): Region of device. (US, EU, etc.) + pid (str): Product ID of device, pulled by some devices on update. + sub_device_no (int): Sub-device number of device. + product_type (str): Product type of device. + features (dict): Features of device. + status_map (MappingProxyType): Mapping of API status strings to + AirFryerCookStatus enums. + cook_modes (dict[str, str]): The available cooking modes and their API values. + default_preset (AirFryerPresetRecipe): The default preset recipe for the fryer. + min_temp_f (int): The minimum temperature in Fahrenheit. + max_temp_f (int): The maximum temperature in Fahrenheit. + min_temp_c (int): The minimum temperature in Celsius. + max_temp_c (int): The maximum temperature in Celsius. + state_chamber_1 (FryerState): The state object for chamber 1. + state_chamber_2 (FryerState): The state object for chamber 2 (if dual chamber). + sync_chambers (bool): Whether the chambers are synced for cooking. + temperature_interval (int): The available temperature step interval. + time_units (TimeUnits): The time units used by the device (seconds or minutes). + """ __slots__ = ( '_temp_unit', @@ -248,6 +449,7 @@ class VeSyncFryer(VeSyncBaseDevice): 'min_temp_f', 'state_chamber_1', 'state_chamber_2', + 'status_map', 'sync_chambers', 'temperature_interval', 'time_units', @@ -259,19 +461,10 @@ def __init__( manager: VeSync, feature_map: AirFryerMap, ) -> None: - """Initialize VeSyncFryer. - - Args: - details (ResponseDeviceDetailsModel): The device details. - manager (VeSync): The VeSync manager. - feature_map (AirFryerMap): The feature map for the device. - - Note: - This is a bare class as there is only one supported air fryer model. - """ + """Initialize VeSyncFryer.""" super().__init__(details, manager, feature_map) self.cook_modes: dict[str, str] = feature_map.cook_modes - self.pid: str | None = AIRFRYER_PID_MAP.get(details.deviceType, None) + self.pid: str | None = AIRFRYER_PID_MAP.get(details.configModule, None) self.default_preset: AirFryerPresetRecipe = feature_map.default_preset self.state_chamber_1: FryerState = FryerState(self, details, feature_map) self.state_chamber_2: FryerState = FryerState(self, details, feature_map) @@ -282,6 +475,7 @@ def __init__( self.max_temp_c: int = feature_map.temperature_range_c[1] self.temperature_interval: int = feature_map.temperature_step_f self.time_units: TimeUnits = feature_map.time_units + self.status_map = feature_map.status_map # attempt to set temp unit from country code before first update self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS @@ -334,7 +528,7 @@ def round_temperature(self, temperature: int) -> int: step = self.temperature_interval * 5 / 9 return int(round(temperature / step) * step) - def convert_time(self, time_in_seconds: int) -> int: + def convert_time_for_api(self, time_in_seconds: int) -> int: """Convert time in seconds to the device's time units. Args: @@ -347,6 +541,19 @@ def convert_time(self, time_in_seconds: int) -> int: return int(time_in_seconds / 60) return time_in_seconds + def convert_time_for_state(self, time_in_device_units: int) -> int: + """Convert time in device's time units to seconds. + + Args: + time_in_device_units (int): The time in device's time units. + + Returns: + int: The time converted to seconds. + """ + if self.time_units == TimeUnits.MINUTES: + return time_in_device_units * 60 + return time_in_device_units + async def end(self, chamber: int = 1) -> bool: """End the current cooking or preheating session. @@ -420,6 +627,10 @@ async def set_mode_from_recipe( Returns: bool: True if the command was successful, False otherwise. + + Note: + See [AirFryerPresetRecipe][pyvesync.const.AirFryerPresetRecipe] for + more details on how to create a preset recipe. """ del recipe logger.warning( diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index 8d04c20..09b2a7e 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -892,16 +892,37 @@ class AirFryerCookStatus(StrEnum): COOK_STOP = 'cookStop' COOK_END = 'cookEnd' PULL_OUT = 'pullOut' - PAUSED = 'paused' COMPLETED = 'completed' HEATING = 'heating' - STOPPED = 'stopped' UNKNOWN = 'unknown' STANDBY = 'standby' PREHEAT_END = 'preheatEnd' PREHEAT_STOP = 'preheatStop' +PREHEAT_STATUSES = [ + AirFryerCookStatus.PREHEAT_END, + AirFryerCookStatus.PREHEAT_STOP, + AirFryerCookStatus.HEATING, +] + +COOK_STATUSES = [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.COOK_STOP, + AirFryerCookStatus.COOK_END, +] + +RESUMABLE_STATUSES = [ + AirFryerCookStatus.COOK_STOP, + AirFryerCookStatus.PREHEAT_STOP, +] + +RUNNING_STATUSES = [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.HEATING, +] + + # Thermostat Constants diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 23efc1b..c97df98 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -61,11 +61,12 @@ from dataclasses import dataclass, field from itertools import chain -from types import ModuleType +from types import MappingProxyType, ModuleType from typing import Union from pyvesync.const import ( AirFryerCookModes, + AirFryerCookStatus, AirFryerFeatures, AirFryerPresetRecipe, AirFryerPresets, @@ -342,9 +343,14 @@ class AirFryerMap(DeviceMapTemplate): product_line: str = ProductLines.WIFI_KITCHEN product_type: str = ProductTypes.AIR_FRYER module: ModuleType = vesynckitchen - default_preset: AirFryerPresetRecipe = AirFryerPresets.custom + default_preset: AirFryerPresetRecipe = field( + default_factory=lambda: AirFryerPresets.custom + ) cook_modes: dict[str, str] = field(default_factory=dict) default_cook_mode: str = AirFryerCookModes.AIRFRY + status_map: MappingProxyType[str, AirFryerCookStatus] = field( + default_factory=lambda: MappingProxyType({}) + ) @dataclass(kw_only=True) @@ -1101,6 +1107,14 @@ class ThermostatMap(DeviceMapTemplate): default_preset=AirFryerPresets.custom, default_cook_mode=AirFryerCookModes.CUSTOM, time_units=TimeUnits.MINUTES, + status_map=MappingProxyType({ + 'heating': AirFryerCookStatus.HEATING, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'heatStop': AirFryerCookStatus.PREHEAT_STOP, + 'heatEnd': AirFryerCookStatus.PREHEAT_END, + 'standby': AirFryerCookStatus.STANDBY, + }) ), AirFryerMap( class_name='VeSyncTurboBlazeFryer', @@ -1120,6 +1134,14 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(90, 450), temperature_range_c=(30, 230), + status_map=MappingProxyType({ + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'heating': AirFryerCookStatus.HEATING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'cookEnd': AirFryerCookStatus.COOK_END, + }), ), ] """List of ['AirFryerMap'][pyvesync.device_map.AirFryerMap] configuration diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index 98fd6a2..b631d44 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -18,19 +18,20 @@ import logging from dataclasses import replace -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from typing_extensions import deprecated -from pyvesync.base_devices import FryerState, VeSyncFryer +from pyvesync.base_devices import VeSyncFryer from pyvesync.const import ( AIRFRYER_PID_MAP, AirFryerCookStatus, AirFryerPresetRecipe, + ConnectionStatus, ) from pyvesync.models import fryer_models as models from pyvesync.utils.device_mixins import ( - BypassV1Mixin, + BYPASS_V1_PATH, BypassV2Mixin, process_bypassv1_result, process_bypassv2_result, @@ -45,12 +46,10 @@ from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel -T = TypeVar('T') - logger = logging.getLogger(__name__) -class VeSyncAirFryer158(BypassV1Mixin, VeSyncFryer): +class VeSyncAirFryer158(VeSyncFryer): """Cosori Air Fryer Class. Args: @@ -88,7 +87,22 @@ class VeSyncAirFryer158(BypassV1Mixin, VeSyncFryer): 'refresh_interval', ) - request_keys: tuple[str, ...] = (*BypassV1Mixin.request_keys, 'pid') + request_keys: tuple[str, ...] = ( + 'acceptLanguage', + 'appVersion', + 'phoneBrand', + 'phoneOS', + 'accountID', + 'cid', + 'configModule', + 'debugMode', + 'traceId', + 'timeZone', + 'token', + 'userCountryCode', + 'uuid', + 'pid', + ) def __init__( self, @@ -100,7 +114,6 @@ def __init__( super().__init__(details, manager, feature_map) self.features: list[str] = feature_map.features self.ready_start = True - self.state: FryerState = FryerState(self, details, feature_map) if self.config_module not in AIRFRYER_PID_MAP: msg = ( 'Report this error as an issue - ' @@ -114,6 +127,61 @@ 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_158_request( + self, + request_model: type[models.Fryer158RequestModel], + update_dict: dict | None = None, + method: str = 'bypass', + ) -> models.Fryer158RequestModel: + """Build API request body for the Bypass V1 endpoint. + + Args: + request_model (type[models.Fryer158RequestModel]): The request model to use. + update_dict (dict | None): Additional keys to add on. + method (str): The method to use in the outer body, defaults to bypass. + + Returns: + models.Fryer158RequestModel: The request body for the Bypass V1 endpoint, + the correct model is determined from the models.Fryer158RequestModel + discriminator. + """ + body = Helpers.get_defaultvalues_attributes(self.request_keys).copy() + body.update(Helpers.get_manager_attributes(self.manager, self.request_keys)) + body.update(Helpers.get_device_attributes(self, self.request_keys)) + body['method'] = method + body.update(update_dict or {}) + return request_model.from_dict(body) + + async def call_158_api( + self, + request_model: type[models.Fryer158RequestModel], + update_dict: dict | None = None, + method: str = 'bypass', + endpoint: str = 'bypass', + ) -> dict | None: + """Send Cosori 158 APIrequest. + + This uses the `_build_158_request` method to send API requests + to the Cosori 158 API. + + Args: + request_model (type[models.Fryer158RequestModel]): The request model to use. + update_dict (dict | None): Additional keys to add on. + method (str): The method to use in the outer body. + endpoint (str | None): The last part of the url path, defaults to + `bypass`, e.g. `/cloud/v1/deviceManaged/bypass`. + + Returns: + bytes: The response from the API request. + """ + request = self._build_158_request(request_model, update_dict, method) + url_path = BYPASS_V1_PATH + endpoint + resp_dict, _ = await self.manager.async_call_api( + url_path, 'post', request, Helpers.req_header_bypass() + ) + + return resp_dict + def _build_base_request( self, cook_set_time: int, recipe: AirFryerPresetRecipe | None = None ) -> dict[str, int | str | bool]: @@ -154,7 +222,7 @@ def _build_cook_request( cook_mode = self._build_base_request(cook_time, recipe) cook_mode['appointmentTs'] = 0 cook_mode['cookSetTemp'] = cook_temp - cook_mode['cookStatus'] = AirFryerCookStatus.COOKING.value + cook_mode['cookStatus'] = self.status_map[AirFryerCookStatus.COOKING] return cook_mode def _build_preheat_request( @@ -167,12 +235,50 @@ def _build_preheat_request( preheat_mode = self._build_base_request(cook_time, recipe) preheat_mode['targetTemp'] = cook_temp preheat_mode['preheatSetTime'] = cook_time - preheat_mode['preheatStatus'] = AirFryerCookStatus.HEATING.value + preheat_mode['preheatStatus'] = self.status_map[AirFryerCookStatus.HEATING] return preheat_mode +# "jsonCmd": { +# "preheat": { +# "tempUnit": "fahrenheit", +# "accountId": "1221391", +# "mode": "steak", +# "recipeType": 3, +# "readyStart": false, +# "preheatStatus": "heating", +# "recipeId": 2, +# "customRecipe": "Steak", +# "cookSetTime": 10, +# "preheatSetTime": 5, +# "targetTemp": 400 +# } +# }, +# "cookMode": { +# "cookSetTime": 15, +# "cookSetTemp": 350, +# "appointmentTs": 0, +# "recipeId": 1, +# "readyStart": false, +# "recipeType": 3, +# "customRecipe": "Manual", +# "mode": "custom", +# "accountId": "1221391", +# "cookStatus": "cooking", +# "tempUnit": "fahrenheit" +# } + async def get_details(self) -> None: cmd = {'getStatus': 'status'} - resp = await self.call_bypassv1_api(models.Fryer158RequestModel, update_dict=cmd) + jsoncmd = {'jsonCmd': cmd} + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=jsoncmd) + + if not isinstance(resp, dict) or 'result' not in resp: + logger.debug( + 'Invalid response for get_details for %s: %s', self.device_name, resp + ) + self.state.connection_status = ConnectionStatus.OFFLINE + self.state.set_standby() + return None resp_model = process_bypassv1_result( self, @@ -190,23 +296,32 @@ async def get_details(self) -> None: return None return_status = resp_model.returnStatus + if return_status.cookStatus not in self.status_map: + logger.warning( + 'Unknown cook status %s for %s', + return_status.cookStatus, + self.device_name, + ) + self.state.set_standby() + return None return self.state.set_state( - cook_status=return_status.cookStatus, + cook_status=self.status_map[return_status.cookStatus], cook_time=return_status.cookSetTime, cook_last_time=return_status.cookLastTime, cook_temp=return_status.cookSetTemp, temp_unit=return_status.tempUnit, cook_mode=return_status.mode, - preheat_time=return_status.preheatSetTime, + preheat_set_time=return_status.preheatSetTime, preheat_last_time=return_status.preheatLastTime, current_temp=return_status.currentTemp, + recipe=return_status.customRecipe, ) async def end(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_cook_mode is True: cmd = {'cookMode': {'cookStatus': 'end'}} - if self.state.is_in_preheat_mode is True: + elif self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'end'}} else: logger.debug( @@ -214,7 +329,7 @@ async def end(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'end', self, resp) @@ -227,7 +342,7 @@ async def stop(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'stop'}} - if self.state.is_in_cook_mode is True: + elif self.state.is_in_cook_mode is True: cmd = {'cookMode': {'cookStatus': 'stop'}} else: logger.debug( @@ -235,16 +350,16 @@ async def stop(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'stop', self, resp) if r is None: return False if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP - if self.state.is_in_cook_mode is True: - self.state.cook_status = AirFryerCookStatus.COOK_STOP + self.state.set_preheat_stop_state() + elif self.state.is_in_cook_mode is True: + self.state.set_cook_stop_state() return True async def resume(self, chamber: int = 1) -> bool: @@ -259,7 +374,7 @@ async def resume(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'resume', self, resp) @@ -267,8 +382,8 @@ async def resume(self, chamber: int = 1) -> bool: return False if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.HEATING - if self.state.is_in_cook_mode is True: + self.state.set_preheat_resume_state() + elif self.state.is_in_cook_mode is True: self.state.cook_status = AirFryerCookStatus.COOKING return True @@ -292,7 +407,7 @@ async def set_mode_from_recipe( ) cmd = {'cookMode': cook_req} json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) @@ -301,9 +416,11 @@ async def set_mode_from_recipe( self.state.set_state( cook_status=cook_status, cook_time=recipe.cook_time, + cook_last_time=recipe.cook_time, cook_temp=recipe.target_temp, cook_mode=recipe.cook_mode, - preheat_time=recipe.preheat_time, + preheat_set_time=recipe.preheat_time, + preheat_last_time=recipe.preheat_time, ) return True @@ -320,14 +437,14 @@ async def set_mode( logger.warning('Invalid cook temperature for %s', self.device_name) return False cook_temp = self.round_temperature(cook_temp) - cook_time = self.convert_time(cook_time) + cook_time = self.convert_time_for_api(cook_time) preset_recipe = replace(self.default_preset) preset_recipe.cook_time = cook_time preset_recipe.target_temp = cook_temp if cook_mode is not None: preset_recipe.cook_mode = cook_mode if preheat_time is not None: - preset_recipe.preheat_time = self.convert_time(preheat_time) + preset_recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(preset_recipe, chamber=chamber) async def cook_from_preheat(self, chamber: int = 1) -> bool: @@ -339,11 +456,12 @@ async def cook_from_preheat(self, chamber: int = 1) -> bool: 'cookMode': { 'mode': self.state.cook_mode, 'accountId': self.manager.account_id, - 'cookStatus': 'cooking', + 'cookStatus': AirFryerCookStatus.COOKING.value, + 'tempUnit': self.temp_unit.label, } } json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) @@ -358,18 +476,6 @@ class VeSyncTurboBlazeFryer(BypassV2Mixin, VeSyncFryer): __slots__ = () - def __init__( - self, - details: ResponseDeviceDetailsModel, - manager: VeSync, - feature_map: AirFryerMap, - ) -> None: - """Init the VeSync TurboBlaze Air Fryer class.""" - super().__init__(details, manager, feature_map) - - # Single chamber fryer state - self.state: FryerState = FryerState(self, details, feature_map) - def _build_cook_request( self, recipe: AirFryerPresetRecipe ) -> models.FryerTurboBlazeRequestData: @@ -411,15 +517,23 @@ async def get_details(self) -> None: return cook_step = resp_model.stepArray[resp_model.stepIndex] + if resp_model.cookStatus not in self.status_map: + logger.warning( + 'Unknown cook status %s for %s', + resp_model.cookStatus, + self.device_name, + ) + self.state.set_standby() + return self.state.set_state( - cook_status=resp_model.cookStatus, + cook_status=self.status_map[resp_model.cookStatus], cook_time=cook_step.cookSetTime, cook_last_time=cook_step.cookLastTime, cook_temp=cook_step.cookTemp, temp_unit=resp_model.tempUnit, cook_mode=cook_step.mode, - preheat_time=resp_model.preheatSetTime, + preheat_set_time=resp_model.preheatSetTime, preheat_last_time=resp_model.preheatLastTime, current_temp=resp_model.currentTemp, ) @@ -451,7 +565,7 @@ async def set_mode_from_recipe(self, recipe: AirFryerPresetRecipe) -> bool: cook_last_time=recipe.cook_time, cook_temp=recipe.target_temp, cook_mode=recipe.cook_mode, - preheat_time=recipe.preheat_time if recipe.preheat_time else None, + preheat_set_time=recipe.preheat_time if recipe.preheat_time else None, preheat_last_time=recipe.preheat_time if recipe.preheat_time else None, ) return True @@ -466,8 +580,8 @@ async def set_mode( ) -> bool: del chamber # chamber not used for this air fryer recipe = replace(self.default_preset) - recipe.cook_time = self.convert_time(cook_time) + recipe.cook_time = self.convert_time_for_api(cook_time) recipe.target_temp = self.round_temperature(cook_temp) if preheat_time is not None: - recipe.preheat_time = self.convert_time(preheat_time) + recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(recipe) diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index ab0c966..2bc9e2f 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -5,30 +5,53 @@ from dataclasses import dataclass, field from typing import Annotated +from mashumaro.exceptions import MissingField from mashumaro.types import Discriminator from pyvesync.models.base_models import RequestBaseModel, ResponseBaseModel from pyvesync.models.bypass_models import ( BypassV1Result, BypassV2InnerResult, - RequestBypassV1, ) @dataclass -class Fryer158RequestModel(RequestBypassV1): +class Fryer158RequestModel(RequestBaseModel): """Request model for air fryer commands.""" - pid: str # type: ignore[misc] # bug in mypy invalid argument ordering - jsonCmd: dict # type: ignore[misc] # bug in mypy invalid argument ordering - deviceId: str = field(default_factory=str) - configModel: str = field(default_factory=lambda: '') - - def __post_serialize__(self, d: dict) -> dict: - """Remove empty strings before serialization.""" - for attrs in ['deviceId', 'configModel']: - d.pop(attrs, None) - return d + acceptLanguage: str + accountID: str + appVersion: str + cid: str + configModule: str + debugMode: bool + method: str + phoneBrand: str + phoneOS: str + traceId: str + timeZone: str + token: str + userCountryCode: str + uuid: str + pid: str = field(default_factory=str) + jsonCmd: dict = field(default_factory=dict) + # deviceId: str = field(default_factory=str) + # configModel: str = field(default_factory=lambda: '') + + @classmethod + def __post_deserialize__(cls, obj: Fryer158RequestModel) -> Fryer158RequestModel: # type: ignore[reportIncompatibleMethodOverride] + """Validate required fields after deserialization.""" + if not obj.pid: + raise MissingField('pid', str, Fryer158RequestModel) + if not obj.jsonCmd: + raise MissingField('jsonCmd', dict, Fryer158RequestModel) + return obj + + # def __post_serialize__(self, d: dict) -> dict: + # """Remove empty strings before serialization.""" + # for attrs in ['deviceId', 'configModel']: + # d.pop(attrs, None) + # return d @dataclass @@ -52,6 +75,7 @@ class Fryer158CookingReturnStatus(ResponseBaseModel): preheatLastTime: int | None = None preheatSetTime: int | None = None targetTemp: int | None = None + customRecipe: str | None = None @dataclass diff --git a/src/tests/call_json.py b/src/tests/call_json.py index cb9d6a6..52f31cf 100644 --- a/src/tests/call_json.py +++ b/src/tests/call_json.py @@ -1,5 +1,6 @@ import copy from typing import Any +import call_json_fryers import pyvesync.const as const from defaults import TestDefaults from pyvesync.device_map import DeviceMapTemplate @@ -26,7 +27,8 @@ *call_json_outlets.outlet_modules, *call_json_switches.switch_modules, *call_json_humidifiers.humidifier_modules, - *call_json_purifiers.purifier_modules + *call_json_purifiers.purifier_modules, + *call_json_fryers.air_fryer_modules ] ALL_DEVICE_MAP_DICT: dict[str, DeviceMapTemplate] = { @@ -384,6 +386,8 @@ def device_list_item(cls, module: DeviceMapTemplate): model_dict['deviceType'] = module.dev_types[0] if module.setup_entry == 'ESO15-TB': model_dict['subDeviceNo'] = 1 + if 'CS137-AF/CS158-AF' in module.dev_types: + model_dict['configModule'] = 'WiFi_SKA_AirFryer158_US' return model_dict @classmethod diff --git a/src/tests/call_json_air_fryers.py b/src/tests/call_json_fryers.py similarity index 63% rename from src/tests/call_json_air_fryers.py rename to src/tests/call_json_fryers.py index db1a51e..860367c 100644 --- a/src/tests/call_json_air_fryers.py +++ b/src/tests/call_json_fryers.py @@ -37,8 +37,9 @@ def status_response(request_body=None): from copy import deepcopy from pyvesync.device_map import air_fryer_modules from pyvesync.const import ( - DeviceStatus, - ConnectionStatus, + # DeviceStatus, + # ConnectionStatus, + AirFryerPresetRecipe, TemperatureUnits, AirFryerCookStatus, ) @@ -46,29 +47,48 @@ def status_response(request_body=None): TestDefaults, FunctionResponsesV2, FunctionResponsesV1, - build_bypass_v1_response, - build_bypass_v2_response, ) +FRYERS = [m.setup_entry for m in air_fryer_modules] +FRYERS_NUM = len(FRYERS) + class AirFryerDefaults: temp_unit = TemperatureUnits.FAHRENHEIT - cook_time_f = 10 + cook_time_m = 10 + cook_time_s = cook_time_m * 60 + cook_last_time_m = cook_time_m - 2 + cook_last_time_s = cook_last_time_m * 60 + preheat_time_m = 5 + preheat_time_s = preheat_time_m * 60 cook_temp_f = 350 + cook_temp_c = 175 cook_mode = "custom" current_temp_f = 150 cook_status = AirFryerCookStatus.COOKING recipe = "Manual" +DC601_RECIPE = AirFryerPresetRecipe( + recipe_name="Air Fry", + cook_mode="AirFry", + recipe_id=14, + recipe_type=3, + target_temp=AirFryerDefaults.cook_temp_f, + temp_unit=AirFryerDefaults.temp_unit, + cook_time=AirFryerDefaults.cook_time_s, + preheat_time=AirFryerDefaults.preheat_time_s, +) + + AIR_FRYER_COOKING_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { "CS158-AF": { "returnStatus": { "curentTemp": AirFryerDefaults.current_temp_f, "cookSetTemp": AirFryerDefaults.cook_temp_f, "mode": AirFryerDefaults.cook_mode, - "cookSetTime": AirFryerDefaults.cook_time_f, - "cookLastTime": AirFryerDefaults.cook_time_f - 2, + "cookSetTime": AirFryerDefaults.cook_time_m, # Minutes + "cookLastTime": AirFryerDefaults.cook_last_time_m, # Minutes "cookStatus": AirFryerDefaults.cook_status.value, "tempUnit": AirFryerDefaults.temp_unit.label, "accountId": TestDefaults.account_id, @@ -81,21 +101,21 @@ class AirFryerDefaults: "result": { "stepArray": [ { - "cookSetTime": 1200, - "cookTemp": 330, - "mode": "Bake", - "cookLastTime": 1176, + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": DC601_RECIPE.cook_mode, + "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds "shakeTime": 0, "cookEndTime": 0, - "recipeName": "Bake", - "recipeId": 9, - "recipeType": 3, + "recipeName": DC601_RECIPE.recipe_name, + "recipeId": DC601_RECIPE.recipe_id, + "recipeType": DC601_RECIPE.recipe_type, } ], "cookMode": "normal", - "tempUnit": "f", + "tempUnit": AirFryerDefaults.temp_unit.label, "stepIndex": 0, - "cookStatus": "cooking", + "cookStatus": AirFryerDefaults.cook_status.value, "preheatSetTime": 0, "preheatLastTime": 0, "preheatEndTime": 0, @@ -112,7 +132,7 @@ class AirFryerDefaults: "CAF-DC601S": { "stepArray": [], "cookMode": "normal", - "tempUnit": "f", + "tempUnit": AirFryerDefaults.temp_unit.label, "stepIndex": 0, "cookStatus": "standby", "preheatSetTime": 0, @@ -126,3 +146,21 @@ class AirFryerDefaults: }, "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, } + + +METHOD_RESPONSES = { + 'CS158-AF': deepcopy(FunctionResponsesV1), + 'CAF-DC601S': deepcopy(FunctionResponsesV2), +} + + +DETAILS_RESPONSES_COOKING = { + "CS158-AF": deepcopy(AIR_FRYER_COOKING_DETAILS['CS158-AF']), + "CAF-DC601S": deepcopy(AIR_FRYER_COOKING_DETAILS['CAF-DC601S']), +} + + +DETAILS_RESPONSES_STANDBY = { + "CS158-AF": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CS158-AF']), + "CAF-DC601S": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CAF-DC601S']), +} diff --git a/src/tests/test_fryers.py b/src/tests/test_fryers.py new file mode 100644 index 0000000..338873e --- /dev/null +++ b/src/tests/test_fryers.py @@ -0,0 +1,268 @@ +""" +This tests requests for FANS (not fryers or humidifiers). + +All tests inherit from the TestBase class which contains the fixtures +and methods needed to run the tests. + +The tests are automatically parametrized by `pytest_generate_tests` in +conftest.py. The two methods that are parametrized are `test_details` +and `test_methods`. The class variables are used to build the list of +devices, test methods and arguments. + +The `helpers.call_api` method is patched to return a mock response. +The method, endpoint, headers and json arguments are recorded +in YAML files in the api directory, categorized in folders by +module and files by the class name. + +The default is to record requests that do not exist and compare requests +that already exist. If the API changes, set the overwrite argument to True +in order to overwrite the existing YAML file with the new request. + +See Also +-------- +`utils.TestBase` - Base class for all tests, containing mock objects +`confest.pytest_generate_tests` - Parametrizes tests based on + method names & class attributes +`call_json_fryers` - Contains API responses +""" + +import logging +import pytest +from dataclasses import asdict +import pyvesync.const as const +from pyvesync.base_devices.fryer_base import VeSyncFryer +from base_test_cases import TestBase +from utils import assert_test, parse_args +from defaults import TestDefaults +import call_json_fryers + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +DETAILS_PARAMS_STANDBY = [ + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CS158-AF", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CS158-AF"], + id="CS158-AF.update.standby", + ), + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CAF-DC601S", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-DC601S"], + id="CAF-DC601S.update.standby", + ), +] + +DETAILS_PARAMS_COOKING = [ + pytest.param( + const.AirFryerCookStatus.COOKING, + "CS158-AF", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CS158-AF"], + id="CS158-AF.update.cooking", + ), + pytest.param( + const.AirFryerCookStatus.COOKING, + "CAF-DC601S", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-DC601S"], + id="CAF-DC601S.update.cooking", + ), +] + + +class TestFryers(TestBase): + """Fryer testing class. + + This class tests Fryer product details and methods. The methods are + parametrized from the class variables using `pytest_generate_tests`. + The call_json_fryers module contains the responses for the API requests. + The device is instantiated from the details provided by + `call_json_fryers.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. + + Instance Attributes + ------------------- + self.manager : VeSync + Instantiated VeSync object + self.mock_api : Mock + Mock with patched `helpers.call_api` method + self.caplog : LogCaptureFixture + Pytest fixture for capturing logs + + Class Variables + --------------- + device : str + Name of product class - fryers + fryers : list + List of setup_entry's for fryers, this variable is named + after the device variable value + base_methods : List[List[str, Dict[str, Any]]] + List of common methods for all devices + device_methods : Dict[List[List[str, Dict[str, Any]]]] + Dictionary of methods specific to device types + + Methods + -------- + test_details() + Test the device details API request and response + test_methods() + Test device methods API request and response + + Examples + -------- + >>> device = 'fryers' + >>> fryers = call_json_fryers.FRYER_MODELS + >>> base_methods = [['turn_on'], ['turn_off'], ['update']] + >>> device_methods = { + 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] + } + + """ + + device = 'fryers' + fryers = call_json_fryers.FRYERS + base_methods = [] # type: ignore + device_methods = { # type: ignore + "CS158-AF": [], + "CAF-DC601S": [], + } + + @pytest.mark.parametrize( + "cook_status, setup_entry, method, response_dict", + DETAILS_PARAMS_STANDBY + DETAILS_PARAMS_COOKING, + ) + def test_details_fryers(self, cook_status: const.AirFryerCookStatus, setup_entry: str, method: str, response_dict: dict): + """Test the device details API request and response. + + This method is automatically parametrized by `pytest_generate_tests` + based on class variables `device` (name of product class - fryers), + device name (fryers) list of setup_entry's. + + Example: + >>> device = 'fryers' + >>> fryers = call_json_fryers.FRYERS + + See Also + -------- + `utils.TestBase` class docstring + `call_json_fryers` module docstring + + Notes + ------ + The device is instantiated using the `call_json.DeviceList.device_list_item()` + method. The device details contain the default values set in `utils.Defaults` + """ + # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES + return_dict = response_dict + self.mock_api.return_value = (return_dict, 200) + + # Instantiate device from device list return item + fryer_obj = self.get_device("air_fryers", setup_entry) + assert isinstance(fryer_obj, VeSyncFryer) + + method_call = getattr(fryer_obj, method) + self.run_in_loop(method_call) + + # Parse mock_api args tuple from arg, kwargs to kwargs + all_kwargs = parse_args(self.mock_api) + + if cook_status == const.AirFryerCookStatus.STANDBY: + assert fryer_obj.state_chamber_1.cook_status == cook_status + assert fryer_obj.state_chamber_1.cook_set_temp is None + assert fryer_obj.state_chamber_1.cook_set_time is None + assert fryer_obj.state_chamber_1.cook_mode is None + assert fryer_obj.state_chamber_1.current_temp is None + assert fryer_obj.state_chamber_1.preheat_last_time is None + assert fryer_obj.state_chamber_1.cook_last_time is None + assert fryer_obj.state_chamber_1.last_timestamp is None + if const.AirFryerFeatures.DUAL_CHAMBER in fryer_obj.features: + assert fryer_obj.state_chamber_2.cook_status == cook_status + assert fryer_obj.state_chamber_2.cook_set_temp is None + assert fryer_obj.state_chamber_2.cook_set_time is None + assert fryer_obj.state_chamber_2.cook_mode is None + assert fryer_obj.state_chamber_2.current_temp is None + assert fryer_obj.state_chamber_2.preheat_last_time is None + assert fryer_obj.state_chamber_2.cook_last_time is None + assert fryer_obj.state_chamber_2.last_timestamp is None + + # Assert request matches recorded request or write new records + assert assert_test( + method_call, all_kwargs, setup_entry, self.write_api, self.overwrite + ) + + # def test_methods(self, setup_entry, method): + # """Test device methods API request and response. + + # This method is automatically parametrized by `pytest_generate_tests` + # based on class variables `device` (name of product class - humidifiers), + # device name (humidifiers) list of setup_entry's, `base_methods` - list of + # methods for all devices, and `device_methods` - list of methods for + # each device type. + + # Example: + # >>> base_methods = [['turn_on'], ['turn_off'], ['update']] + # >>> device_methods = { + # 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] + # } + + # Notes + # ----- + # The response can be a callable that accepts the `kwargs` argument to + # sync the device response with the API response. In some cases the API + # returns data from the method call, such as `get_yearly_energy`, in other cases the + # API returns a simple confirmation the command was successful. + + # See Also + # -------- + # `TestBase` class method + # `call_json_fans` module + + # """ + # # Get method name and kwargs from method fixture + # method_name = method[0] + # if len(method) == 2 and isinstance(method[1], dict): + # method_kwargs = method[1] + # else: + # method_kwargs = {} + + # # Set return value for call_api based on call_json_fans.METHOD_RESPONSES + # method_response = call_json_fans.METHOD_RESPONSES[setup_entry][method_name] + # if callable(method_response): + # if method_kwargs: + # self.mock_api.return_value = method_response(method_kwargs), 200 + # else: + # self.mock_api.return_value = method_response(), 200 + # else: + # self.mock_api.return_value = method_response, 200 + + # # Get device configuration from call_json.DeviceList.device_list_item() + # fan_obj = self.get_device("fans", setup_entry) + # assert isinstance(fan_obj, VeSyncFanBase) + + # # Get method from device object + # method_call = getattr(fan_obj, method[0]) + + # # Ensure method runs based on device configuration + # if method[0] == 'turn_on': + # fan_obj.state.device_status = const.DeviceStatus.OFF + # elif method[0] == 'turn_off': + # fan_obj.state.device_status = const.DeviceStatus.ON + + # # Call method with kwargs if defined + # if method_kwargs: + # self.run_in_loop(method_call, **method_kwargs) + # else: + # self.run_in_loop(method_call) + + # # Parse arguments from mock_api call into a dictionary + # all_kwargs = parse_args(self.mock_api) + + # # Assert request matches recorded request or write new records + # assert assert_test( + # method_call, all_kwargs, setup_entry, self.write_api, self.overwrite + # ) From 233a93ef19ecc1e2bb006c9a86f27e6b9aaa546a Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 2 Mar 2026 22:04:11 -0500 Subject: [PATCH 05/19] Fryer Working Tests --- src/pyvesync/base_devices/fryer_base.py | 5 +- src/pyvesync/const.py | 1 + src/pyvesync/device_map.py | 31 ++ src/pyvesync/devices/vesynckitchen.py | 309 ++++++++++++++++++-- src/pyvesync/models/fryer_models.py | 57 ++++ src/tests/api/vesynckitchen/CAF-DC601S.yaml | 26 ++ src/tests/api/vesynckitchen/CAF-TF101S.yaml | 26 ++ src/tests/api/vesynckitchen/CS158-AF.yaml | 24 ++ src/tests/call_json_fryers.py | 144 ++++++--- src/tests/test_fryers.py | 101 ++----- 10 files changed, 579 insertions(+), 145 deletions(-) create mode 100644 src/tests/api/vesynckitchen/CAF-DC601S.yaml create mode 100644 src/tests/api/vesynckitchen/CAF-TF101S.yaml create mode 100644 src/tests/api/vesynckitchen/CS158-AF.yaml diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 9c401f1..cb05637 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -482,9 +482,8 @@ def __init__( if self.manager.measure_unit and self.manager.measure_unit.lower() == 'imperial': self._temp_unit = TemperatureUnits.FAHRENHEIT - # Use single state attribute if not dual chamber fryer for compatibility - if AirFryerFeatures.DUAL_CHAMBER not in self.features: - self.state = self.state_chamber_1 + # Set state to primary chamber (chamber 1) for base class compatibility + self.state = self.state_chamber_1 @property def temp_unit(self) -> TemperatureUnits: diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index 09b2a7e..61aaa98 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -856,6 +856,7 @@ class AirFryerCookModes(StrEnum): WARM = 'warm' AIRFRY = 'airfry' DRY = 'dry' + GRILL = 'grill' PREHEAT = 'preheat' diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index c97df98..fd32ab1 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1143,6 +1143,37 @@ class ThermostatMap(DeviceMapTemplate): 'cookEnd': AirFryerCookStatus.COOK_END, }), ), + AirFryerMap( + class_name='VeSyncDualAirFryer', + module=vesynckitchen, + dev_types=['CAF-TF101S-AEU'], + setup_entry='CAF-TF101S', + device_alias='Dual Air Fryer', + model_display='CAF-TF101S Series', + model_name='Cosori Dual Air Fryer', + temperature_step_f=5, + features=[AirFryerFeatures.DUAL_CHAMBER], + cook_modes={ + AirFryerCookModes.AIRFRY: 'AirFry', + AirFryerCookModes.BAKE: 'Bake', + AirFryerCookModes.ROAST: 'Roast', + AirFryerCookModes.GRILL: 'Grill', + AirFryerCookModes.DRY: 'Dry', + AirFryerCookModes.REHEAT: 'Reheat', + }, + default_cook_mode=AirFryerCookModes.AIRFRY, + default_preset=AirFryerPresets.air_fry, + time_units=TimeUnits.SECONDS, + temperature_range_f=(130, 450), + temperature_range_c=(55, 240), + status_map=MappingProxyType({ + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + }), + ), ] """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 b631d44..d35a78f 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: from pyvesync import VeSync + from pyvesync.base_devices.fryer_base import FryerState from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel @@ -238,35 +239,6 @@ def _build_preheat_request( preheat_mode['preheatStatus'] = self.status_map[AirFryerCookStatus.HEATING] return preheat_mode -# "jsonCmd": { -# "preheat": { -# "tempUnit": "fahrenheit", -# "accountId": "1221391", -# "mode": "steak", -# "recipeType": 3, -# "readyStart": false, -# "preheatStatus": "heating", -# "recipeId": 2, -# "customRecipe": "Steak", -# "cookSetTime": 10, -# "preheatSetTime": 5, -# "targetTemp": 400 -# } -# }, -# "cookMode": { -# "cookSetTime": 15, -# "cookSetTemp": 350, -# "appointmentTs": 0, -# "recipeId": 1, -# "readyStart": false, -# "recipeType": 3, -# "customRecipe": "Manual", -# "mode": "custom", -# "accountId": "1221391", -# "cookStatus": "cooking", -# "tempUnit": "fahrenheit" -# } - async def get_details(self) -> None: cmd = {'getStatus': 'status'} jsoncmd = {'jsonCmd': cmd} @@ -585,3 +557,282 @@ async def set_mode( if preheat_time is not None: recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(recipe) + + +class VeSyncDualAirFryer(BypassV2Mixin, VeSyncFryer): + """Cosori Dual Air Fryer Class (CAF-TF101S). + + Supports dual-chamber cooking with three operating modes: + - Single chamber (left or right) + - Whole chamber (merged) + - Sync mode (both chambers with identical settings) + + The device has no preheat, pause, or resume API. Pausing is handled + physically by pulling the basket out, which triggers a ``cookStop`` + or ``pullOut`` state. Pushing the basket back in resumes cooking + automatically. + + Args: + details (ResponseDeviceDetailsModel): Device details. + manager (VeSync): Manager class. + feature_map (AirFryerMap): Device feature map. + + Attributes: + state_chamber_1 (FryerState): State for chamber 1 (left) or whole. + state_chamber_2 (FryerState): State for chamber 2 (right). + sync_chambers (bool): Whether chambers are synced for cooking. + """ + + __slots__ = () + + # workChamber API values + _WC_NONE = 0 + _WC_LEFT = 1 + _WC_RIGHT = 2 + _WC_WHOLE = 3 + _WC_SYNC = 4 + _SYNC_TYPE_SYNCED = 2 + + def _get_chamber_state(self, chamber: int) -> FryerState: + """Return the FryerState for the given chamber number. + + Args: + chamber: Chamber number (1=left, 2=right, 3=whole maps to chamber 1). + """ + if chamber == self._WC_RIGHT: + return self.state_chamber_2 + return self.state_chamber_1 + + def _get_work_chamber(self, chamber: int) -> int: + """Return the API workChamber value for the given chamber. + + Args: + chamber: Chamber number (1, 2, or 3). + + Returns: + API workChamber value (1, 2, 3, or 4 for sync). + """ + if self.sync_chambers: + return self._WC_SYNC + return chamber + + def _get_sync_type(self) -> int: + """Return the API syncType value.""" + return self._SYNC_TYPE_SYNCED if self.sync_chambers else 0 + + async def get_details(self) -> None: + """Get device details from the API. + + Polls ``getAirfryerMultiStatus`` and updates state for each chamber. + """ + resp = await self.call_bypassv2_api( + payload_method='getAirfryerMultiStatus', + ) + resp_model = process_bypassv2_result( + self, + logger, + 'get_details', + resp, + models.FryerDualMultiStatusResult, + ) + + if resp_model is None: + self.state_chamber_1.set_standby() + self.state_chamber_2.set_standby() + return + + # Update sync state from API response + self.sync_chambers = resp_model.syncType == self._SYNC_TYPE_SYNCED + self.state_chamber_1.sync_chambers = self.sync_chambers + self.state_chamber_2.sync_chambers = self.sync_chambers + + # Track which chambers were updated + updated_chambers: set[int] = set() + + for status_item in resp_model.statusList: + ch_num = status_item.chamber + updated_chambers.add(ch_num) + chamber_state = self._get_chamber_state(ch_num) + + if ( + status_item.cookStatus not in self.status_map + or status_item.cookStatus == AirFryerCookStatus.STANDBY.value + ): + chamber_state.set_standby() + continue + + chamber_state.set_state( + cook_status=self.status_map[status_item.cookStatus], + cook_time=status_item.cookSetTime, + cook_last_time=status_item.currentRemainingTime, + cook_temp=status_item.cookTemp, + temp_unit=resp_model.tempUnit, + cook_mode=status_item.mode, + recipe=status_item.recipeName or None, + ) + + # Set any chambers not in the response to standby + if ( + self._WC_LEFT not in updated_chambers + and self._WC_WHOLE not in updated_chambers + ): + self.state_chamber_1.set_standby() + if ( + self._WC_RIGHT not in updated_chambers + and self._WC_WHOLE not in updated_chambers + ): + self.state_chamber_2.set_standby() + + async def end(self, chamber: int = 1) -> bool: + """End the current cooking session. + + Args: + chamber: Chamber to end (1=left, 2=right, 3=whole). + If sync mode is active, ends both chambers. + + Returns: + True if the command was successful. + """ + api_chamber = self._get_work_chamber(chamber) + resp = await self.call_bypassv2_api( + payload_method='endCook', + data={'chamber': api_chamber}, + ) + r = Helpers.process_dev_response(logger, 'end', self, resp) + if r is None: + return False + + if self.sync_chambers or api_chamber == self._WC_SYNC: + self.state_chamber_1.set_standby() + self.state_chamber_2.set_standby() + self.sync_chambers = False + elif chamber == self._WC_WHOLE: + self.state_chamber_1.set_standby() + else: + self._get_chamber_state(chamber).set_standby() + return True + + def _build_cook_configs( + self, + recipe: AirFryerPresetRecipe, + chamber: int, + ) -> list[models.FryerDualCookConfig]: + """Build cookConfigs list for startMultiCook request. + + Args: + recipe: The recipe to cook. + chamber: Chamber number (1, 2, or 3 for whole). + + Returns: + List of FryerDualCookConfig for the API request. + """ + config_dict = { + 'cookSetTime': recipe.cook_time, + 'cookTemp': recipe.target_temp, + 'mode': recipe.cook_mode, + 'recipeId': recipe.recipe_id, + 'recipeName': recipe.recipe_name, + 'recipeType': recipe.recipe_type, + } + + if self.sync_chambers: + return [ + models.FryerDualCookConfig.from_dict( + {**config_dict, 'chamber': 1} + ), + models.FryerDualCookConfig.from_dict( + {**config_dict, 'chamber': 2} + ), + ] + return [ + models.FryerDualCookConfig.from_dict( + {**config_dict, 'chamber': chamber} + ), + ] + + async def set_mode_from_recipe( + self, + recipe: AirFryerPresetRecipe, + *, + chamber: int = 1, + ) -> bool: + """Start cooking with a preset recipe. + + Args: + recipe: The preset recipe to use. + chamber: Chamber to cook in (1=left, 2=right, 3=whole). + If ``sync_chambers`` is True, both chambers cook in sync. + + Returns: + True if the command was successful. + """ + cook_configs = self._build_cook_configs(recipe, chamber) + work_chamber = self._get_work_chamber(chamber) + sync_type = self._get_sync_type() + + start_data = models.FryerDualStartCookData.from_dict({ + 'accountId': self.manager.account_id, + 'cookConfigs': [c.to_dict() for c in cook_configs], + 'readyStart': True, + 'syncType': sync_type, + 'tempUnit': self.temp_unit.code, + 'workChamber': work_chamber, + }) + + resp = await self.call_bypassv2_api( + payload_method='startMultiCook', + data=start_data.to_dict(), + ) + r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) + if r is None: + return False + + # Update state for affected chambers + chambers = ( + [self.state_chamber_1, self.state_chamber_2] + if self.sync_chambers + else [self._get_chamber_state(chamber)] + ) + for ch_state in chambers: + ch_state.set_state( + cook_status=AirFryerCookStatus.COOKING, + cook_time=recipe.cook_time, + cook_last_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + recipe=recipe.recipe_name, + ) + return True + + async def set_mode( + self, + cook_time: int, + cook_temp: int, + *, + preheat_time: int | None = None, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + """Set cooking mode with manual parameters. + + Args: + cook_time: Cooking time in seconds. + cook_temp: Cooking temperature. + preheat_time: Not used for this device. + cook_mode: Cooking mode string (e.g. 'AirFry'). + chamber: Chamber number (1=left, 2=right, 3=whole). + + Returns: + True if the command was successful. + """ + del preheat_time # not supported by this device + if not self.validate_temperature(cook_temp): + logger.warning('Invalid cook temperature for %s', self.device_name) + return False + cook_temp = self.round_temperature(cook_temp) + recipe = replace(self.default_preset) + recipe.cook_time = cook_time + recipe.target_temp = cook_temp + if cook_mode is not None: + recipe.cook_mode = cook_mode + return await self.set_mode_from_recipe(recipe, chamber=chamber) diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index 2bc9e2f..81c4ae6 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -218,6 +218,63 @@ class FryerTurboBlazeStartActItem(RequestBaseModel): shakeTime: int = 0 +# Dual Air Fryer Models (CAF-TF101S) + + +@dataclass +class FryerDualChamberStatusItem(ResponseBaseModel): + """Status item for a single chamber in dual air fryer status response.""" + + cookStatus: str + chamber: int + cookSetTime: int = 0 + cookTemp: int = 0 + mode: str = '' + currentRemainingTime: int = 0 + totalTimeRemaining: int = 0 + startTime: int = 0 + recipeType: int = 3 + recipeId: int = 0 + recipeName: str = '' + upc: str = '' + holdTime: int = 0 + + +@dataclass +class FryerDualMultiStatusResult(BypassV2InnerResult): + """Result model for dual air fryer getAirfryerMultiStatus response.""" + + statusList: list[FryerDualChamberStatusItem] + tempUnit: str = 'c' + syncType: int = 0 + workChamber: int = 0 + + +@dataclass +class FryerDualCookConfig(RequestBaseModel): + """Cook configuration for a single chamber in startMultiCook request.""" + + chamber: int + cookSetTime: int + cookTemp: int + mode: str + recipeId: int + recipeName: str + recipeType: int + + +@dataclass +class FryerDualStartCookData(RequestBaseModel): + """Request data for dual air fryer startMultiCook command.""" + + accountId: str + cookConfigs: list[FryerDualCookConfig] + readyStart: bool + syncType: int + tempUnit: str + workChamber: int + + # a = { # 'cookMode': { # 'accountId': '1221391', diff --git a/src/tests/api/vesynckitchen/CAF-DC601S.yaml b/src/tests/api/vesynckitchen/CAF-DC601S.yaml new file mode 100644 index 0000000..3d32bab --- /dev/null +++ b/src/tests/api/vesynckitchen/CAF-DC601S.yaml @@ -0,0 +1,26 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CAF-DC601S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: CAF-DC601S-CID + method: bypassV2 + payload: + data: {} + method: getAirfyerStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/api/vesynckitchen/CAF-TF101S.yaml b/src/tests/api/vesynckitchen/CAF-TF101S.yaml new file mode 100644 index 0000000..3ea1ad5 --- /dev/null +++ b/src/tests/api/vesynckitchen/CAF-TF101S.yaml @@ -0,0 +1,26 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CAF-TF101S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: CAF-TF101S-CID + method: bypassV2 + payload: + data: {} + method: getAirfryerMultiStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/api/vesynckitchen/CS158-AF.yaml b/src/tests/api/vesynckitchen/CS158-AF.yaml new file mode 100644 index 0000000..56ec6b5 --- /dev/null +++ b/src/tests/api/vesynckitchen/CS158-AF.yaml @@ -0,0 +1,24 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CS158-AF-CID + configModule: WiFi_SKA_AirFryer158_US + debugMode: false + jsonCmd: + getStatus: status + method: bypass + phoneBrand: pyvesync + phoneOS: Android + pid: 2cl8hmafsthl65bd + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + uuid: CS158-AF-UUID + method: post + url: /cloud/v1/deviceManaged/bypass diff --git a/src/tests/call_json_fryers.py b/src/tests/call_json_fryers.py index 860367c..1ca53a3 100644 --- a/src/tests/call_json_fryers.py +++ b/src/tests/call_json_fryers.py @@ -37,8 +37,6 @@ def status_response(request_body=None): from copy import deepcopy from pyvesync.device_map import air_fryer_modules from pyvesync.const import ( - # DeviceStatus, - # ConnectionStatus, AirFryerPresetRecipe, TemperatureUnits, AirFryerCookStatus, @@ -47,6 +45,8 @@ def status_response(request_body=None): TestDefaults, FunctionResponsesV2, FunctionResponsesV1, + build_bypass_v1_response, + build_bypass_v2_response, ) FRYERS = [m.setup_entry for m in air_fryer_modules] @@ -80,8 +80,19 @@ class AirFryerDefaults: preheat_time=AirFryerDefaults.preheat_time_s, ) +TF101S_RECIPE = AirFryerPresetRecipe( + recipe_name="Air Fry", + cook_mode="AirFry", + recipe_id=14, + recipe_type=3, + target_temp=AirFryerDefaults.cook_temp_f, + temp_unit=AirFryerDefaults.temp_unit, + cook_time=AirFryerDefaults.cook_time_s, +) + -AIR_FRYER_COOKING_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { +# Raw detail data for each device (V1 result or V2 inner result) +AIR_FRYER_COOKING_DETAILS: dict[str, dict] = { "CS158-AF": { "returnStatus": { "curentTemp": AirFryerDefaults.current_temp_f, @@ -96,39 +107,73 @@ class AirFryerDefaults: } }, "CAF-DC601S": { - "traceId": "1767318172645", - "code": 0, - "result": { - "stepArray": [ - { - "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds - "cookTemp": AirFryerDefaults.cook_temp_f, - "mode": DC601_RECIPE.cook_mode, - "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds - "shakeTime": 0, - "cookEndTime": 0, - "recipeName": DC601_RECIPE.recipe_name, - "recipeId": DC601_RECIPE.recipe_id, - "recipeType": DC601_RECIPE.recipe_type, - } - ], - "cookMode": "normal", - "tempUnit": AirFryerDefaults.temp_unit.label, - "stepIndex": 0, - "cookStatus": AirFryerDefaults.cook_status.value, - "preheatSetTime": 0, - "preheatLastTime": 0, - "preheatEndTime": 0, - "preheatTemp": 0, - "startTime": 1767318116, - "totalTimeRemaining": 1176, - "currentTemp": 89, - "shakeStatus": 0, - }, + "stepArray": [ + { + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": DC601_RECIPE.cook_mode, + "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds + "shakeTime": 0, + "cookEndTime": 0, + "recipeName": DC601_RECIPE.recipe_name, + "recipeId": DC601_RECIPE.recipe_id, + "recipeType": DC601_RECIPE.recipe_type, + } + ], + "cookMode": "normal", + "tempUnit": AirFryerDefaults.temp_unit.label, + "stepIndex": 0, + "cookStatus": AirFryerDefaults.cook_status.value, + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 1767318116, + "totalTimeRemaining": 1176, + "currentTemp": 89, + "shakeStatus": 0, + }, + "CAF-TF101S": { + "statusList": [ + { + "cookStatus": AirFryerDefaults.cook_status.value, + "chamber": 1, + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": TF101S_RECIPE.cook_mode, + "currentRemainingTime": AirFryerDefaults.cook_last_time_s, + "totalTimeRemaining": AirFryerDefaults.cook_last_time_s, + "startTime": 1767318116, + "recipeType": TF101S_RECIPE.recipe_type, + "recipeId": TF101S_RECIPE.recipe_id, + "recipeName": TF101S_RECIPE.recipe_name, + "upc": "", + "holdTime": 0, + }, + { + "cookStatus": "standby", + "chamber": 2, + "cookSetTime": 0, + "cookTemp": 0, + "mode": "", + "currentRemainingTime": 0, + "totalTimeRemaining": 0, + "startTime": 0, + "recipeType": 3, + "recipeId": 0, + "recipeName": "", + "upc": "", + "holdTime": 0, + }, + ], + "tempUnit": AirFryerDefaults.temp_unit.label, + "syncType": 0, + "workChamber": 1, }, } -AIR_FRYER_STANDYBY_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { +AIR_FRYER_STANDBY_DETAILS: dict[str, dict] = { + "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, "CAF-DC601S": { "stepArray": [], "cookMode": "normal", @@ -144,23 +189,46 @@ class AirFryerDefaults: "currentTemp": 43, "shakeStatus": 0, }, - "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, + "CAF-TF101S": { + "statusList": [ + {"cookStatus": "standby", "chamber": 1}, + {"cookStatus": "standby", "chamber": 2}, + ], + "tempUnit": AirFryerDefaults.temp_unit.label, + "syncType": 0, + "workChamber": 0, + }, } METHOD_RESPONSES = { 'CS158-AF': deepcopy(FunctionResponsesV1), 'CAF-DC601S': deepcopy(FunctionResponsesV2), + 'CAF-TF101S': deepcopy(FunctionResponsesV2), } DETAILS_RESPONSES_COOKING = { - "CS158-AF": deepcopy(AIR_FRYER_COOKING_DETAILS['CS158-AF']), - "CAF-DC601S": deepcopy(AIR_FRYER_COOKING_DETAILS['CAF-DC601S']), + "CS158-AF": build_bypass_v1_response( + result_dict=deepcopy(AIR_FRYER_COOKING_DETAILS["CS158-AF"]), + ), + "CAF-DC601S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_COOKING_DETAILS["CAF-DC601S"]), + ), + "CAF-TF101S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_COOKING_DETAILS["CAF-TF101S"]), + ), } DETAILS_RESPONSES_STANDBY = { - "CS158-AF": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CS158-AF']), - "CAF-DC601S": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CAF-DC601S']), + "CS158-AF": build_bypass_v1_response( + result_dict=deepcopy(AIR_FRYER_STANDBY_DETAILS["CS158-AF"]), + ), + "CAF-DC601S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_STANDBY_DETAILS["CAF-DC601S"]), + ), + "CAF-TF101S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_STANDBY_DETAILS["CAF-TF101S"]), + ), } diff --git a/src/tests/test_fryers.py b/src/tests/test_fryers.py index 338873e..2cdb4af 100644 --- a/src/tests/test_fryers.py +++ b/src/tests/test_fryers.py @@ -1,5 +1,5 @@ """ -This tests requests for FANS (not fryers or humidifiers). +This tests requests for AIR FRYERS. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. @@ -28,12 +28,10 @@ import logging import pytest -from dataclasses import asdict import pyvesync.const as const from pyvesync.base_devices.fryer_base import VeSyncFryer from base_test_cases import TestBase from utils import assert_test, parse_args -from defaults import TestDefaults import call_json_fryers @@ -56,6 +54,13 @@ call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-DC601S"], id="CAF-DC601S.update.standby", ), + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CAF-TF101S", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-TF101S"], + id="CAF-TF101S.update.standby", + ), ] DETAILS_PARAMS_COOKING = [ @@ -73,6 +78,13 @@ call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-DC601S"], id="CAF-DC601S.update.cooking", ), + pytest.param( + const.AirFryerCookStatus.COOKING, + "CAF-TF101S", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-TF101S"], + id="CAF-TF101S.update.cooking", + ), ] @@ -130,6 +142,7 @@ class TestFryers(TestBase): device_methods = { # type: ignore "CS158-AF": [], "CAF-DC601S": [], + "CAF-TF101S": [], } @pytest.mark.parametrize( @@ -190,79 +203,17 @@ def test_details_fryers(self, cook_status: const.AirFryerCookStatus, setup_entry assert fryer_obj.state_chamber_2.cook_last_time is None assert fryer_obj.state_chamber_2.last_timestamp is None + elif cook_status == const.AirFryerCookStatus.COOKING: + assert fryer_obj.state_chamber_1.cook_status == cook_status + assert fryer_obj.state_chamber_1.cook_set_temp == call_json_fryers.AirFryerDefaults.cook_temp_f + assert fryer_obj.state_chamber_1.cook_set_time == call_json_fryers.AirFryerDefaults.cook_time_s + assert fryer_obj.state_chamber_1.cook_mode is not None + assert fryer_obj.state_chamber_1.cook_last_time == call_json_fryers.AirFryerDefaults.cook_last_time_s + if const.AirFryerFeatures.DUAL_CHAMBER in fryer_obj.features: + # Chamber 2 should be standby in cooking test data + assert fryer_obj.state_chamber_2.cook_status == const.AirFryerCookStatus.STANDBY + # Assert request matches recorded request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) - - # def test_methods(self, setup_entry, method): - # """Test device methods API request and response. - - # This method is automatically parametrized by `pytest_generate_tests` - # based on class variables `device` (name of product class - humidifiers), - # device name (humidifiers) list of setup_entry's, `base_methods` - list of - # methods for all devices, and `device_methods` - list of methods for - # each device type. - - # Example: - # >>> base_methods = [['turn_on'], ['turn_off'], ['update']] - # >>> device_methods = { - # 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] - # } - - # Notes - # ----- - # The response can be a callable that accepts the `kwargs` argument to - # sync the device response with the API response. In some cases the API - # returns data from the method call, such as `get_yearly_energy`, in other cases the - # API returns a simple confirmation the command was successful. - - # See Also - # -------- - # `TestBase` class method - # `call_json_fans` module - - # """ - # # Get method name and kwargs from method fixture - # method_name = method[0] - # if len(method) == 2 and isinstance(method[1], dict): - # method_kwargs = method[1] - # else: - # method_kwargs = {} - - # # Set return value for call_api based on call_json_fans.METHOD_RESPONSES - # method_response = call_json_fans.METHOD_RESPONSES[setup_entry][method_name] - # if callable(method_response): - # if method_kwargs: - # self.mock_api.return_value = method_response(method_kwargs), 200 - # else: - # self.mock_api.return_value = method_response(), 200 - # else: - # self.mock_api.return_value = method_response, 200 - - # # Get device configuration from call_json.DeviceList.device_list_item() - # fan_obj = self.get_device("fans", setup_entry) - # assert isinstance(fan_obj, VeSyncFanBase) - - # # Get method from device object - # method_call = getattr(fan_obj, method[0]) - - # # Ensure method runs based on device configuration - # if method[0] == 'turn_on': - # fan_obj.state.device_status = const.DeviceStatus.OFF - # elif method[0] == 'turn_off': - # fan_obj.state.device_status = const.DeviceStatus.ON - - # # Call method with kwargs if defined - # if method_kwargs: - # self.run_in_loop(method_call, **method_kwargs) - # else: - # self.run_in_loop(method_call) - - # # Parse arguments from mock_api call into a dictionary - # all_kwargs = parse_args(self.mock_api) - - # # Assert request matches recorded request or write new records - # assert assert_test( - # method_call, all_kwargs, setup_entry, self.write_api, self.overwrite - # ) From c8d2e2209d4fa5cb92abf325f077c2259b4191b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:04:17 +0000 Subject: [PATCH 06/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/base_devices/fryer_base.py | 12 ++---- src/pyvesync/device_map.py | 52 ++++++++++++++----------- src/pyvesync/devices/vesynckitchen.py | 50 +++++++++--------------- 3 files changed, 50 insertions(+), 64 deletions(-) diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index cb05637..c48f4eb 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -244,9 +244,7 @@ def set_preheat_stop_state(self, preheat_set_time: int | None = None) -> None: """Set the fryer state to preheat stopped.""" self.cook_status = AirFryerCookStatus.PREHEAT_STOP if preheat_set_time is not None: - self.preheat_set_time = self.device.convert_time_for_state( - preheat_set_time - ) + self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = self.preheat_time_remaining self.last_timestamp = None @@ -254,9 +252,7 @@ def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None: """Set the fryer state to preheat resumed.""" self.cook_status = AirFryerCookStatus.HEATING if preheat_set_time is not None: - self.preheat_set_time = self.device.convert_time_for_state( - preheat_set_time - ) + self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = self.preheat_time_remaining self.last_timestamp = datetime.now(timezone.utc) @@ -363,9 +359,7 @@ def set_state( # noqa: PLR0912, PLR0913, C901 # Handle preheat attributes if preheat_set_time is not None: - self.preheat_set_time = self.device.convert_time_for_state( - preheat_set_time - ) + self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = ( self.device.convert_time_for_state(preheat_last_time) if preheat_last_time is not None diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index fd32ab1..c40fc6d 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1107,14 +1107,16 @@ class ThermostatMap(DeviceMapTemplate): default_preset=AirFryerPresets.custom, default_cook_mode=AirFryerCookModes.CUSTOM, time_units=TimeUnits.MINUTES, - status_map=MappingProxyType({ - 'heating': AirFryerCookStatus.HEATING, - 'cooking': AirFryerCookStatus.COOKING, - 'cookStop': AirFryerCookStatus.COOK_STOP, - 'heatStop': AirFryerCookStatus.PREHEAT_STOP, - 'heatEnd': AirFryerCookStatus.PREHEAT_END, - 'standby': AirFryerCookStatus.STANDBY, - }) + status_map=MappingProxyType( + { + 'heating': AirFryerCookStatus.HEATING, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'heatStop': AirFryerCookStatus.PREHEAT_STOP, + 'heatEnd': AirFryerCookStatus.PREHEAT_END, + 'standby': AirFryerCookStatus.STANDBY, + } + ), ), AirFryerMap( class_name='VeSyncTurboBlazeFryer', @@ -1134,14 +1136,16 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(90, 450), temperature_range_c=(30, 230), - status_map=MappingProxyType({ - 'ready': AirFryerCookStatus.COOK_STOP, - 'cooking': AirFryerCookStatus.COOKING, - 'heating': AirFryerCookStatus.HEATING, - 'cookStop': AirFryerCookStatus.COOK_STOP, - 'pullOut': AirFryerCookStatus.PULL_OUT, - 'cookEnd': AirFryerCookStatus.COOK_END, - }), + status_map=MappingProxyType( + { + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'heating': AirFryerCookStatus.HEATING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'cookEnd': AirFryerCookStatus.COOK_END, + } + ), ), AirFryerMap( class_name='VeSyncDualAirFryer', @@ -1166,13 +1170,15 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(130, 450), temperature_range_c=(55, 240), - status_map=MappingProxyType({ - 'standby': AirFryerCookStatus.STANDBY, - 'ready': AirFryerCookStatus.COOK_STOP, - 'cooking': AirFryerCookStatus.COOKING, - 'cookStop': AirFryerCookStatus.COOK_STOP, - 'pullOut': AirFryerCookStatus.PULL_OUT, - }), + status_map=MappingProxyType( + { + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + } + ), ), ] """List of ['AirFryerMap'][pyvesync.device_map.AirFryerMap] configuration diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index d35a78f..1c69c35 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -301,9 +301,7 @@ async def end(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'end', self, resp) if r is None: return False @@ -322,9 +320,7 @@ async def stop(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'stop', self, resp) if r is None: return False @@ -346,9 +342,7 @@ async def resume(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'resume', self, resp) if r is None: return False @@ -379,9 +373,7 @@ async def set_mode_from_recipe( ) cmd = {'cookMode': cook_req} json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) if r is None: return False @@ -433,9 +425,7 @@ async def cook_from_preheat(self, chamber: int = 1) -> bool: } } json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) if r is None: return False @@ -737,17 +727,11 @@ def _build_cook_configs( if self.sync_chambers: return [ - models.FryerDualCookConfig.from_dict( - {**config_dict, 'chamber': 1} - ), - models.FryerDualCookConfig.from_dict( - {**config_dict, 'chamber': 2} - ), + models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': 1}), + models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': 2}), ] return [ - models.FryerDualCookConfig.from_dict( - {**config_dict, 'chamber': chamber} - ), + models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': chamber}), ] async def set_mode_from_recipe( @@ -770,14 +754,16 @@ async def set_mode_from_recipe( work_chamber = self._get_work_chamber(chamber) sync_type = self._get_sync_type() - start_data = models.FryerDualStartCookData.from_dict({ - 'accountId': self.manager.account_id, - 'cookConfigs': [c.to_dict() for c in cook_configs], - 'readyStart': True, - 'syncType': sync_type, - 'tempUnit': self.temp_unit.code, - 'workChamber': work_chamber, - }) + start_data = models.FryerDualStartCookData.from_dict( + { + 'accountId': self.manager.account_id, + 'cookConfigs': [c.to_dict() for c in cook_configs], + 'readyStart': True, + 'syncType': sync_type, + 'tempUnit': self.temp_unit.code, + 'workChamber': work_chamber, + } + ) resp = await self.call_bypassv2_api( payload_method='startMultiCook', From 0e29ad8f86bb1a9fc432703c585f539783b6e6a9 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Wed, 25 Mar 2026 20:16:48 -0400 Subject: [PATCH 07/19] Fix fryer and auth bug --- .gitignore | 1 + src/pyvesync/auth.py | 52 ++++++++++++++++--------- src/pyvesync/base_devices/fryer_base.py | 2 +- src/pyvesync/devices/vesynckitchen.py | 12 +++++- src/pyvesync/vesync.py | 3 +- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index f1bfd12..bb7a12f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ creds.json docstest/* /.claude .mcp.json +.vesync_auth \ No newline at end of file diff --git a/src/pyvesync/auth.py b/src/pyvesync/auth.py index 899ebc9..635c18b 100644 --- a/src/pyvesync/auth.py +++ b/src/pyvesync/auth.py @@ -37,6 +37,20 @@ class VeSyncAuth: Handles login, token management, and persistent storage of authentication credentials for VeSync API access. + + Args: + manager: VeSync manager instance for API calls + username: VeSync account username (email) + password: VeSync account password + country_code: Country code in ISO 3166 Alpha-2 format + + Note: + Either username/password or token/account_id must be provided. + If token_file_path is provided, credentials will be saved/loaded + automatically. When loading credentials, the current working directory + and home directory are checked for the token file if no path is provided. + When saving credentials, if no path is provided, it will save to the current + working directory. """ __slots__ = ( @@ -57,19 +71,7 @@ def __init__( password: str, country_code: str = DEFAULT_REGION, ) -> None: - """Initialize VeSync Authentication Manager. - - Args: - manager: VeSync manager instance for API calls - username: VeSync account username (email) - password: VeSync account password - country_code: Country code in ISO 3166 Alpha-2 format - - Note: - Either username/password or token/account_id must be provided. - If token_file_path is provided, credentials will be saved/loaded - automatically. - """ + """Initialize VeSync Authentication Manager.""" self.manager = manager self._username = username self._password = password @@ -85,6 +87,11 @@ def _country_code_to_region(self) -> str: return 'US' return 'EU' + @property + def credentials_saved(self) -> bool: + """Return whether credentials have been saved to file.""" + return self._token_file_path is not None and self._token_file_path.exists() + @property def token(self) -> str: """Return VeSync API token.""" @@ -150,17 +157,25 @@ async def reauthenticate(self) -> bool: True if re-authentication successful, False otherwise """ self.clear_credentials() - return await self.login() + success = await self.login() + if success: + logger.debug('Re-authentication successful for user: %s', self._username) + if self.credentials_saved: + await self.save_credentials_to_file(self._token_file_path) + else: + logger.debug('Re-authentication failed for user: %s', self._username) + return False + return success async def load_credentials_from_file( self, file_path: str | Path | None = None ) -> bool: """Load credentials from token file if path is set. - If no path is provided, it will try to load from the users home directory and - then the current working directory. + If no path is provided, it will try to load from the current working directory and + then the user's home directory. """ - locations = [Path.home() / '.vesync_auth', Path.cwd() / '.vesync_auth'] + locations = [Path.cwd() / '.vesync_auth', Path.home() / '.vesync_auth'] file_path_object: Path | None = None if file_path is None: for location in locations: @@ -174,6 +189,7 @@ async def load_credentials_from_file( if not file_path_object or not file_path_object.exists(): logger.debug('Credentials file not found: %s', file_path_object) return False + self._token_file_path = file_path_object try: data = await asyncio.to_thread( Path(file_path_object).read_text, encoding='utf-8' @@ -228,7 +244,7 @@ async def save_credentials_to_file(self, file_path: str | Path | None = None) -> file_path_object = self._token_file_path else: logger.debug('No token file path set, saving to default location') - file_path_object = Path.home() / '.vesync_auth' + file_path_object = Path.cwd() / '.vesync_auth' if not self.is_authenticated: logger.debug('No credentials to save, not authenticated') return diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index c48f4eb..1f19d5d 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -473,7 +473,7 @@ def __init__( # attempt to set temp unit from country code before first update self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS - if self.manager.measure_unit and self.manager.measure_unit.lower() == 'imperial': + if self.manager.country_code == 'US': self._temp_unit = TemperatureUnits.FAHRENHEIT # Set state to primary chamber (chamber 1) for base class compatibility diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index 1c69c35..c2ea9d7 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -28,6 +28,7 @@ AirFryerCookStatus, AirFryerPresetRecipe, ConnectionStatus, + TemperatureUnits, ) from pyvesync.models import fryer_models as models from pyvesync.utils.device_mixins import ( @@ -488,7 +489,7 @@ async def get_details(self) -> None: self.state.set_standby() return - self.state.set_state( + self.state_chamber_1.set_state( cook_status=self.status_map[resp_model.cookStatus], cook_time=cook_step.cookSetTime, cook_last_time=cook_step.cookLastTime, @@ -631,6 +632,8 @@ async def get_details(self) -> None: self.state_chamber_2.set_standby() return + self.temp_unit = TemperatureUnits.from_string(resp_model.tempUnit) + # Update sync state from API response self.sync_chambers = resp_model.syncType == self._SYNC_TYPE_SYNCED self.state_chamber_1.sync_chambers = self.sync_chambers @@ -648,6 +651,13 @@ async def get_details(self) -> None: status_item.cookStatus not in self.status_map or status_item.cookStatus == AirFryerCookStatus.STANDBY.value ): + if status_item.cookStatus not in self.status_map: + logger.warning( + 'Unknown cook status %s for %s chamber %d', + status_item.cookStatus, + self.device_name, + ch_num, + ) chamber_state.set_standby() continue diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 68cca90..6ef135e 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -241,8 +241,7 @@ async def save_credentials(self, filename: str | Path | None) -> None: filename (str | Path | None): The name of the file to save credentials to. If None, no action is taken. """ - if filename is not None: - await self.auth.save_credentials_to_file(filename) + await self.auth.save_credentials_to_file(filename) async def load_credentials_from_file( self, filename: str | Path | None = None From 78290de686cc91f2ce133a4a81f2a2253aed56c4 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 18 Jan 2026 19:23:12 -0500 Subject: [PATCH 08/19] Refactor air fryer modules --- ruff.toml | 2 +- src/pyvesync/base_devices/fryer_base.py | 315 ++++++++- src/pyvesync/const.py | 208 +++++- src/pyvesync/device_map.py | 45 +- src/pyvesync/devices/vesynckitchen.py | 881 ++++++++++++------------ src/pyvesync/models/fryer_models.py | 157 ++++- src/pyvesync/utils/helpers.py | 10 + 7 files changed, 1158 insertions(+), 460 deletions(-) diff --git a/ruff.toml b/ruff.toml index b82d11c..7eca042 100644 --- a/ruff.toml +++ b/ruff.toml @@ -50,7 +50,7 @@ ignore = [ "EXE002", # Use of exec - IGNORE "DTZ005", # Use of datetime.now() without tz - IGNORE # "Q000", # Quotes - # "ERA001", # Commented out code + "ERA001", # Commented out code ] diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 875b142..58e54f5 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -3,9 +3,18 @@ from __future__ import annotations import logging +import time from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice +from pyvesync.const import ( + AIRFRYER_PID_MAP, + AirFryerCookStatus, + AirFryerFeatures, + AirFryerPresetRecipe, + TemperatureUnits, + TimeUnits, +) if TYPE_CHECKING: from pyvesync import VeSync @@ -19,11 +28,24 @@ class FryerState(DeviceState): """State class for Air Fryer devices. - Note: This class is a placeholder for future functionality and does not currently - implement any specific features or attributes. + Time units are in seconds by default. They are automatically converted + from the API response. """ - __slots__ = () + __slots__ = ( + '_time_conv', + 'cook_last_time', + 'cook_mode', + 'cook_set_temp', + 'cook_set_time', + 'cook_status', + 'current_temp', + 'last_timestamp', + 'preheat_last_time', + 'preheat_set_time', + 'ready_start', + 'time_units', + ) def __init__( self, @@ -42,12 +64,180 @@ def __init__( super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features + self.time_units: TimeUnits = feature_map.time_units + self.ready_start: bool = False + self.cook_status: str | None = None + self.cook_mode: 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._time_conv: float = ( + 60 if feature_map.time_units == TimeUnits.MINUTES else 1 + ) + + @property + def is_in_preheat_mode(self) -> bool: + """Return True if the fryer has preheat feature.""" + return self.cook_status in [ + AirFryerCookStatus.HEATING, + AirFryerCookStatus.PREHEAT_STOP, + ] or ( + self.cook_status == AirFryerCookStatus.PULL_OUT + and self.preheat_set_time is not None + ) + + @property + def is_in_cook_mode(self) -> bool: + """Return True if the fryer is in cook mode.""" + return self.cook_status in [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.COOK_STOP, + ] or ( + self.cook_status == AirFryerCookStatus.PULL_OUT + and self.cook_set_time is not None + ) + + @property + def is_cooking(self) -> bool: + """Return True if the fryer is currently cooking (not preheating).""" + return self.cook_status == AirFryerCookStatus.COOKING + + @property + def is_preheating(self) -> bool: + """Return True if the fryer is currently preheating.""" + return self.cook_status == AirFryerCookStatus.HEATING + + @property + def is_running(self) -> bool: + """Return True if the fryer is running (cooking or preheating).""" + return self.is_cooking or self.is_preheating + + @property + def can_resume(self) -> bool: + """Return True if the fryer can resume cooking.""" + return self.cook_status in [ + AirFryerCookStatus.PREHEAT_STOP, + AirFryerCookStatus.COOK_STOP, + ] + + @property + def preheat_time_remaining(self) -> int | None: + """Return the remaining preheat time in seconds.""" + if not self.is_in_preheat_mode: + return None + if self.cook_status in [ + AirFryerCookStatus.PREHEAT_STOP, + AirFryerCookStatus.PULL_OUT, + ]: + return self.preheat_last_time + if self.preheat_last_time is not None and self.last_timestamp is not None: + return max( + 0, + self.preheat_last_time + - int((self.last_timestamp - time.time()) * self._time_conv), + ) + return None + + @property + def cook_time_remaining(self) -> int | None: + """Return the remaining cook time in seconds.""" + if not self.is_in_cook_mode: + return None + if self.cook_status in [ + AirFryerCookStatus.PULL_OUT, + AirFryerCookStatus.COOK_STOP, + ]: + return self.cook_last_time + if self.cook_last_time is not None and self.last_timestamp is not None: + return max( + 0, + self.cook_last_time + - int((self.last_timestamp - time.time()) * self._time_conv), + ) + return None + + def _clear_preheat(self) -> None: + """Clear preheat status.""" + self.preheat_set_time = None + self.preheat_last_time = None + + def set_standby(self) -> None: + """Set the fryer state to standby and clear all state attributes. + + This is to be called by device classes before updating the state from + the API response to prevent stale data. The get_details API responses + do not include all keys in every response depending on the status. + """ + self.cook_status = AirFryerCookStatus.STANDBY + self.current_temp = None + self.cook_set_temp = None + self.cook_set_time = None + self.cook_last_time = None + self.last_timestamp = None + self._clear_preheat() + + def set_state( # noqa: PLR0913 + self, + *, + cook_status: str, + cook_time: int | None = None, + cook_temp: int | None = None, + temp_unit: str | None = None, + cook_mode: str | None = None, + preheat_time: int | None = None, + current_temp: int | None = None, + ) -> None: + """Set the cook state parameters. + + Args: + cook_status (str): The cooking status. + cook_time (int | None): The cooking time in seconds. + cook_temp (int | None): The cooking temperature. + temp_unit (str | None): The temperature units (F or C). + cook_mode (str | None): The cooking mode. + preheat_time (int | None): The preheating time in seconds. + current_temp (int | None): The current temperature. + """ + if cook_status == AirFryerCookStatus.STANDBY: + self.set_standby() + return + self.cook_status = cook_status + self.cook_set_time = cook_time + self.cook_set_temp = cook_temp + self.cook_mode = cook_mode + self.current_temp = current_temp + if temp_unit is not None: + self.device.temp_unit = temp_unit + if preheat_time is not None: + self.preheat_set_time = preheat_time + if cook_status in [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.HEATING, + ]: + self.last_timestamp = int(time.time()) + else: + self.last_timestamp = None class VeSyncFryer(VeSyncBaseDevice): """Base class for VeSync Air Fryer devices.""" - __slots__ = () + __slots__ = ( + '_temp_unit', + 'cook_modes', + 'default_preset', + 'max_temp_c', + 'max_temp_f', + 'min_temp_c', + 'min_temp_f', + 'state_chamber_1', + 'state_chamber_2', + 'sync_chambers', + ) def __init__( self, @@ -66,3 +256,120 @@ def __init__( This is a bare class as there is only one supported air fryer model. """ super().__init__(details, manager, feature_map) + self.cook_modes: dict[str, str] = feature_map.cook_modes + self.pid: str | None = AIRFRYER_PID_MAP.get(details.deviceType, None) + self.default_preset: AirFryerPresetRecipe = feature_map.default_preset + self.state_chamber_1: FryerState = FryerState(self, details, feature_map) + self.state_chamber_2: FryerState = FryerState(self, details, feature_map) + self.sync_chambers: bool = False + self._temp_unit: TemperatureUnits | None = None + 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] + + # Use single state attribute if not dual chamber fryer for compatibility + if AirFryerFeatures.DUAL_CHAMBER not in self.features: + self.state = self.state_chamber_1 + + @property + def temp_unit(self) -> TemperatureUnits | None: + """Return the temperature unit (F or C).""" + return self._temp_unit + + @temp_unit.setter + def temp_unit(self, value: str) -> None: + """Set the temperature unit. + + Args: + value (str): The temperature unit (F or C). + """ + self._temp_unit = TemperatureUnits.from_string(value) + + async def end(self, chamber: int = 1) -> bool: + """End the current cooking or preheating session. + + Arguments: + chamber (int): The chamber number to end for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber + logger.info('end not configured for this fryer.') + return False + + async def stop(self, chamber: int = 1) -> bool: + """Stop (Pause) the current cooking or preheating session. + + Arguments: + chamber (int): The chamber number to stop for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber + logger.info('stop not configured for this fryer.') + return False + + async def resume(self, chamber: int = 1) -> bool: + """Resume a paused cooking or preheating session. + + Arguments: + chamber (int): The chamber number to resume for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber + logger.info('resume not configured for this fryer.') + return False + + async def set_cook_mode( + self, + cook_time: int, + cook_temp: int, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + """Set the cooking mode. + + Args: + cook_time (int): The cooking time in seconds. + cook_temp (int): The cooking temperature. + cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. + chamber (int): The chamber number to set cooking for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del cook_time, cook_temp, cook_mode, chamber + logger.warning('set_cook_mode method not implemented for base fryer class.') + return False + + async def set_preheat_mode( + self, + target_temp: int, + preheat_time: int, + cook_time: int, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + """Set the preheating mode. + + Args: + target_temp (int): The target temperature for preheating. + preheat_time (int): The preheating time in seconds. + cook_time (int): The cooking time in seconds after preheating. + cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. + chamber (int): The chamber number to set preheating for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del target_temp, preheat_time, cook_time, cook_mode, chamber + if AirFryerFeatures.PREHEAT not in self.features: + logger.warning('set_preheat_mode method not supported for this fryer.') + return False + logger.warning('set_preheat_mode method not implemented for base fryer class.') + return False diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index a0571b4..d070e08 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -28,6 +28,7 @@ import platform import uuid +from dataclasses import dataclass from enum import Enum, IntEnum, StrEnum from random import randint from types import MappingProxyType @@ -75,6 +76,20 @@ KELVIN_MAX = 6500 +class TimeUnits(StrEnum): + """Time units for VeSync devices. + + Attributes: + MINUTES: Time in minutes. + SECONDS: Time in seconds. + HOURS: Time in hours. + """ + + MINUTES = 'minutes' + SECONDS = 'seconds' + HOURS = 'hours' + + class ProductLines(StrEnum): """High level product line.""" @@ -303,6 +318,52 @@ def from_bool(cls, value: bool | None) -> ConnectionStatus: return cls.ONLINE if value else cls.OFFLINE +class TemperatureUnits(StrEnum): + """Temperature units for VeSync devices. + + Attributes: + CELSIUS: Temperature in Celsius. + FAHRENHEIT: Temperature in Fahrenheit. + """ + + CELSIUS = 'c' + FAHRENHEIT = 'f' + + @property + def code(self) -> str: + """Return the code for the temperature unit.""" + return self.value + + @property + def label(self) -> str: + """Return the label for the temperature unit.""" + return self.name.lower() + + @classmethod + def from_string(cls, value: str) -> TemperatureUnits: + """Convert string value to corresponding TemperatureUnit.""" + if value.lower() == 'c' or value.lower() == 'celsius': + return cls.CELSIUS + if value.lower() == 'f' or value.lower() == 'fahrenheit': + return cls.FAHRENHEIT + exc_msg = f'Invalid temperature unit: {value} value' + raise ValueError(exc_msg) + + @classmethod + def to_celsius(cls, value: float, unit: TemperatureUnits) -> float: + """Convert temperature to Celsius.""" + if unit == cls.FAHRENHEIT: + return (value - 32) * 5.0 / 9.0 + return value + + @classmethod + def to_fahrenheit(cls, value: float, unit: TemperatureUnits) -> float: + """Convert temperature to Fahrenheit.""" + if unit == cls.CELSIUS: + return (value * 9.0 / 5.0) + 32 + return value + + class NightlightModes(StrEnum): """Nightlight modes. @@ -690,6 +751,144 @@ class FanModes(StrEnum): """PID's for VeSync Air Fryers based on ConfigModule.""" +CUSTOM_RECIPE_ID = 1 +CUSTOM_RECIPE_TYPE = 3 +CUSTOM_RECIPE_NAME = 'Manual Cook' +CUSTOM_COOK_MODE = 'custom' + + +@dataclass +class AirFryerPresetRecipe: + """Preset recipe for VeSync Air Fryers. + + Attributes: + recipe_id (int): Recipe ID. + recipe_type (int): Recipe type. + name (str): Recipe name. + """ + + cook_mode: str + recipe_id: int + recipe_type: int + target_temp: int + temp_unit: str + cook_time: int + + +class AirFryerPresets: + """Preset recipes for VeSync Air Fryers. + + Attributes: + custom (AirFryerPresetRecipe): Custom preset recipe. + """ + custom: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Custom', + recipe_id=1, + recipe_type=3, + target_temp=350, + temp_unit='f', + cook_time=20, + ) + air_fry: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='AirFry', + recipe_id=4, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=25, + ) + + +AIRFRYER_PRESET_MAP = { + 'custom': AirFryerPresetRecipe( + cook_mode='Custom', + recipe_id=1, + recipe_type=3, + target_temp=350, + temp_unit='f', + cook_time=20, + ), + 'airfry': AirFryerPresetRecipe( + cook_mode='AirFry', + recipe_id=4, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=25, + ), + +} + + +class AirFryerCookModes(StrEnum): + """Cooking modes for VeSync Air Fryers. + + Attributes: + CUSTOM: Custom cooking mode. + FRY: Fry cooking mode. + ROAST: Roast cooking mode. + BAKE: Bake cooking mode. + REHEAT: Reheat cooking mode. + DEHYDRATE: Dehydrate cooking mode. + """ + + CUSTOM = 'custom' + ROAST = 'roast' + BAKE = 'bake' + REHEAT = 'reheat' + DEHYDRATE = 'dehydrate' + FROZEN = 'frozen' + PROOF = 'proof' + BROIL = 'broil' + WARM = 'warm' + AIRFRY = 'airfry' + DRY = 'dry' + PREHEAT = 'preheat' + + +class AirFryerFeatures(Features): + """VeSync Air Fryer features. + + Attributes: + ONOFF: Device on/off status. + TEMP: Temperature status. + TIME: Time status. + COOK_MODE: Cooking mode status. + PRESET_RECIPE: Preset recipe status. + CUSTOM_RECIPE: Custom recipe status. + PAUSE: Pause status. + """ + + DUAL_BLAZE = 'dual_blaze' + PREHEAT = 'preheat' + DUAL_CHAMBER = 'dual_chamber' + RESUMABLE = 'resumable' + + +class AirFryerCookStatus(StrEnum): + """Cooking status for VeSync Air Fryers. + + Attributes: + COOKING: Device is cooking. + PAUSED: Device is paused. + COMPLETED: Cooking is completed. + UNKNOWN: Cooking status is unknown. + """ + + COOKING = 'cooking' + COOK_STOP = 'cook_stop' + COOK_END = 'cook_end' + PULL_OUT = 'pull_out' + PAUSED = 'paused' + COMPLETED = 'completed' + HEATING = 'heating' + STOPPED = 'stopped' + UNKNOWN = 'unknown' + STANDBY = 'standby' + PREHEAT_END = 'preheat_end' + PREHEAT_STOP = 'preheat_stop' + + # Thermostat Constants @@ -833,15 +1032,6 @@ class ThermostatConst: WorkStatus = ThermostatWorkStatusCodes -# ------------------- AIR FRYER CONST ------------------ # - - -CUSTOM_RECIPE_ID = 1 -CUSTOM_RECIPE_TYPE = 3 -CUSTOM_RECIPE_NAME = 'Manual Cook' -CUSTOM_COOK_MODE = 'custom' - - # ------------------- OUTLET CONST ------------------ # diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index d467eb1..7cd6078 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -65,6 +65,10 @@ from typing import Union from pyvesync.const import ( + AirFryerCookModes, + AirFryerFeatures, + AirFryerPresetRecipe, + AirFryerPresets, BulbFeatures, ColorMode, EnergyIntervals, @@ -86,6 +90,7 @@ ThermostatHoldOptions, ThermostatRoutineTypes, ThermostatWorkModes, + TimeUnits, ) from pyvesync.devices import ( vesyncbulb, @@ -135,8 +140,8 @@ class DeviceMapTemplate: setup_entry: str model_display: str model_name: str - device_alias: str | None = None - features: list[str] = field(default_factory=list) + features: list[str] + device_alias: str @dataclass(kw_only=True) @@ -241,11 +246,11 @@ class FanMap(DeviceMapTemplate): set_mode_method (str): Method to set the mode for the device. """ + modes: dict[str, str] + fan_levels: list[int] product_line: str = ProductLines.WIFI_AIR product_type: str = ProductTypes.FAN module: ModuleType = vesyncfan - fan_levels: list[int] = field(default_factory=list) - modes: dict[str, str] = field(default_factory=dict) sleep_preferences: list[str] = field(default_factory=list) set_mode_method: str = '' @@ -271,9 +276,9 @@ class HumidifierMap(DeviceMapTemplate): warm_mist_levels (list[int | str]): List of warm mist levels for the device. """ + mist_modes: dict[str, str] + mist_levels: list[int] product_line: str = ProductLines.WIFI_AIR - mist_modes: dict[str, str] = field(default_factory=dict) - mist_levels: list[int] = field(default_factory=list) product_type: str = ProductTypes.HUMIDIFIER module: ModuleType = vesynchumidifier target_minmax: tuple[int, int] = (30, 80) @@ -302,11 +307,11 @@ class PurifierMap(DeviceMapTemplate): auto_preferences (list[str]): List of auto preferences for the device. """ + fan_levels: list[int] + modes: list[str] product_line: str = ProductLines.WIFI_AIR product_type: str = ProductTypes.PURIFIER module: ModuleType = vesyncpurifier - fan_levels: list[int] = field(default_factory=list) - modes: list[str] = field(default_factory=list) nightlight_modes: list[str] = field(default_factory=list) auto_preferences: list[str] = field(default_factory=list) @@ -330,11 +335,16 @@ class AirFryerMap(DeviceMapTemplate): module (ModuleType): Module for the device. """ + time_units: TimeUnits = TimeUnits.MINUTES temperature_range_f: tuple[int, int] = (200, 400) temperature_range_c: tuple[int, int] = (75, 200) + temperature_step_f: int = 10 product_line: str = ProductLines.WIFI_KITCHEN product_type: str = ProductTypes.AIR_FRYER module: ModuleType = vesynckitchen + default_preset: AirFryerPresetRecipe = AirFryerPresets.custom + cook_modes: dict[str, str] = field(default_factory=dict) + default_cook_mode: str = AirFryerCookModes.AIRFRY @dataclass(kw_only=True) @@ -373,6 +383,7 @@ class ThermostatMap(DeviceMapTemplate): ThermostatMap( dev_types=['LTM-A401S-WUS'], class_name='VeSyncAuraThermostat', + features=[], fan_modes=[ ThermostatFanModes.AUTO, ThermostatFanModes.CIRCULATE, @@ -407,6 +418,7 @@ class ThermostatMap(DeviceMapTemplate): ], setup_entry='LTM-A401S-WUS', model_display='LTM-A401S Series', + device_alias='Aura Thermostat', model_name='Aura Thermostat', ) ] @@ -418,6 +430,7 @@ class ThermostatMap(DeviceMapTemplate): class_name='VeSyncOutlet7A', features=[OutletFeatures.ENERGY_MONITOR], model_name='WiFi Outlet US/CA', + device_alias='Round 7A WiFi Outlet', model_display='ESW01-USA Series', setup_entry='wifi-switch-1.3', ), @@ -427,6 +440,7 @@ class ThermostatMap(DeviceMapTemplate): features=[], model_name='10A WiFi Outlet USA', model_display='ESW10-USA Series', + device_alias='10A Round WiFi Outlet', setup_entry='ESW10-USA', ), OutletMap( @@ -435,6 +449,7 @@ class ThermostatMap(DeviceMapTemplate): features=[OutletFeatures.ENERGY_MONITOR], model_name='ESW03 10A WiFi Outlet', model_display='ESW01/03 USA/EU', + device_alias='10A Round WiFi Outlet', setup_entry='ESW03', ), OutletMap( @@ -444,6 +459,7 @@ class ThermostatMap(DeviceMapTemplate): nightlight_modes=[NightlightModes.ON, NightlightModes.OFF, NightlightModes.AUTO], model_name='15A WiFi Outlet US/CA', model_display='ESW15-USA Series', + device_alias='15A Rectangular WiFi Outlet', setup_entry='ESW15-USA', ), OutletMap( @@ -452,6 +468,7 @@ class ThermostatMap(DeviceMapTemplate): features=[OutletFeatures.ENERGY_MONITOR], model_name='Outdoor Plug', model_display='ESO15-TB Series', + device_alias='Outdoor Smart Plug', setup_entry='ESO15-TB', ), OutletMap( @@ -1075,8 +1092,16 @@ class ThermostatMap(DeviceMapTemplate): device_alias='Air Fryer', 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', - ) + setup_entry='CS158-AF', + temperature_step_f=10, + features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], + cook_modes={ + AirFryerCookModes.AIRFRY: 'custom', + AirFryerCookModes.PREHEAT: 'preheat', + }, + default_preset=AirFryerPresets.custom, + default_cook_mode=AirFryerCookModes.CUSTOM + ), ] """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 b1e5499..7f7623c 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -34,7 +34,9 @@ from typing_extensions import deprecated from pyvesync.base_devices import FryerState, VeSyncFryer -from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus +from pyvesync.const import AIRFRYER_PID_MAP, ConnectionStatus, DeviceStatus, TemperatureUnits, AirFryerCookModes, AirFryerCookStatus, AirFryerFeatures, AirFryerPresets +from pyvesync.models.fryer_models import Fryer158CookingReturnStatus, Fryer158RequestModel, Fryer158Result +from pyvesync.utils.device_mixins import BypassV1Mixin, process_bypassv1_result from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers from pyvesync.utils.logs import LibraryLogger @@ -61,274 +63,268 @@ 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): +# 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__ = () + +# 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 self.cook_status in ('cooking', 'heating') and 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: +# """Set status from jsonCmd of API call.""" +# self.last_timestamp = None +# if not isinstance(json_cmd, dict): +# return +# self.preheat = False + +# preheat_cmd = json_cmd.get('preheat') +# if isinstance(preheat_cmd, dict): +# self.preheat = True +# preheat_status = preheat_cmd.get('preheatStatus') +# if preheat_status == 'stop': +# self.cook_status = 'preheatStop' +# return +# if preheat_status == 'heating': +# self.cook_status = 'heating' +# self.last_timestamp = int(time.time()) +# self.preheat_set_time = preheat_cmd.get( +# 'preheatSetTime', self.preheat_set_time +# ) +# preheat_set_time = preheat_cmd.get('preheatSetTime') +# if preheat_set_time is not None: +# self.preheat_last_time = preheat_set_time +# self.cook_set_temp = preheat_cmd.get('targetTemp', self.cook_set_temp) +# self.cook_set_time = preheat_cmd.get('cookSetTime', self.cook_set_time) +# self.cook_last_time = None +# return +# if preheat_status == 'end': +# self.cook_status = 'preheatEnd' +# self.preheat_last_time = 0 +# return + +# cook_cmd = json_cmd.get('cookMode') +# if not isinstance(cook_cmd, dict): +# return + +# self.clear_preheat() +# cook_status = cook_cmd.get('cookStatus') +# if cook_status == 'stop': +# self.cook_status = 'cookStop' +# return +# if cook_status == 'cooking': +# self.cook_status = 'cooking' +# self.last_timestamp = int(time.time()) +# self.cook_set_time = cook_cmd.get('cookSetTime', self.cook_set_time) +# self.cook_set_temp = cook_cmd.get('cookSetTemp', self.cook_set_temp) +# self.current_temp = cook_cmd.get('currentTemp', self.current_temp) +# self.temp_unit = cook_cmd.get( +# 'tempUnit', +# self.temp_unit, # type: ignore[assignment] +# ) +# return +# if cook_status == '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(BypassV1Mixin, VeSyncFryer): """Cosori Air Fryer Class. Args: @@ -338,7 +334,7 @@ class VeSyncAirFryer158(VeSyncFryer): Attributes: features (list[str]): List of features. - state (AirFryer158138State): Air fryer state. + state (FryerState): 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. @@ -361,12 +357,13 @@ class VeSyncAirFryer158(VeSyncFryer): """ __slots__ = ( - 'cook_temps', 'last_update', 'ready_start', 'refresh_interval', ) + request_keys: tuple[str, ...] = (*BypassV1Mixin.request_keys, 'pid') + def __init__( self, details: ResponseDeviceDetailsModel, @@ -376,171 +373,207 @@ def __init__( """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 + self.ready_start = True + self.state: FryerState = FryerState(self, details, feature_map) 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}' + f'{self.config_module} not found in PID map for {self.device_type}' ) 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', - ) + # 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( + # 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 + + def _build_cook_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 + cook_time: int, + cook_temp: int, + cook_status: str = 'cooking', + ) -> dict[str, int | str | bool]: + """Internal command to build cookMode API command.""" + cook_mode: dict[str, int | str | bool] = {} + cook_mode['accountId'] = self.manager.account_id + cook_mode['appointmentTs'] = 0 + cook_mode['cookSetTemp'] = cook_temp + cook_mode['cookSetTime'] = cook_time + cook_mode['cookStatus'] = cook_status + cook_mode['customRecipe'] = 'Manual' + cook_mode['mode'] = self.default_preset.cook_mode + cook_mode['readyStart'] = True + cook_mode['recipeId'] = self.default_preset.recipe_id + cook_mode['recipeType'] = self.default_preset.recipe_type + if self.temp_unit is not None: + cook_mode['tempUnit'] = self.temp_unit.label + else: + cook_mode['tempUnit'] = 'fahrenheit' + return cook_mode 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) + resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=cmd) + + resp_model = process_bypassv1_result( + self, + logger, + 'get_details', + resp, + Fryer158Result, + ) + + if resp_model is None or resp_model.returnStatus is None: + logger.debug('No returnStatus in get_details response for %s', self.device_name) + self.state.set_standby() + return None + + return_status = resp_model.returnStatus + return self.state.set_state( + cook_status=return_status.cookStatus, + cook_time=return_status.cookSetTime, + cook_temp=return_status.cookSetTemp, + temp_unit=return_status.tempUnit, + cook_mode=return_status.mode, + preheat_time=return_status.preheatSetTime, + current_temp=return_status.currentTemp, + ) + + async def end_cook(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + cmd = {'cookMode': {'cookStatus': 'end'}} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict={'jsonCmd': cmd} + ) 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 - ) + logger.debug('No response from end command for %s', self.device_name) return False + self.state.set_standby() + return True - status_api = await self._status_api(cmd) - if status_api is False: + async def end_preheat(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + cmd = {'preheat': {'preheatStatus': 'end'}} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict={'jsonCmd': cmd} + ) + if resp is None: + logger.debug('No response from end preheat command for %s', self.device_name) 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']: + async def end(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.is_in_cook_mode is True: + cmd = {'cookMode': {'cookStatus': 'end'}} + if self.state.is_in_preheat_mode is True: + cmd = {'preheat': {'preheatStatus': 'end'}} + else: logger.debug( - 'Cannot pause %s as it is not cooking or preheating', self.device_name + 'Cannot end %s as it is not cooking or preheating', self.device_name ) return False - if self.state.preheat is True: + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=json_cmd) + if resp is not None: + self.state.set_standby() + return True + logger.warning('Error ending for %s', self.device_name) + return False + + async def stop(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'stop'}} - else: + if self.state.is_in_cook_mode is True: 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' + else: + logger.debug( + 'Cannot stop %s as it is not cooking or preheating', self.device_name + ) + return False + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict=json_cmd + ) + if resp is not None: + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOK_STOP return True + logger.warning('Error stopping for %s', self.device_name) 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 resume(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.is_in_preheat_mode is True: + cmd = {'preheat': {'preheatStatus': 'heating'}} + if self.state.is_in_cook_mode is True: + cmd = {'cookMode': {'cookStatus': 'cooking'}} + else: + logger.debug( + 'Cannot resume %s as it is not cooking or preheating', self.device_name + ) + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + Fryer158RequestModel, update_dict=json_cmd + ) + if resp is not None: + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.HEATING + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOKING + return True + logger.warning('Error resuming for %s', self.device_name) + return False async def cook(self, set_temp: int, set_time: int) -> bool: """Set cook time and temperature in Minutes.""" @@ -549,24 +582,24 @@ async def cook(self, set_temp: int, set_time: int) -> bool: 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 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.is_in_preheat_mode 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.is_in_preheat_mode 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.""" @@ -589,7 +622,7 @@ async def set_preheat(self, target_temp: int, cook_time: int) -> bool: 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': + if self.state.is_in_preheat_mode 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') diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index bf14725..cc30dec 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -2,33 +2,166 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Annotated -from pyvesync.models.base_models import ResponseBaseModel +from mashumaro.types import Discriminator + +from pyvesync.models.base_models import RequestBaseModel, ResponseBaseModel +from pyvesync.models.bypass_models import BypassV1Result, RequestBypassV1 + + +@dataclass +class Fryer158RequestModel(RequestBypassV1): + """Request model for air fryer commands.""" + + pid: str # type: ignore[misc] # bug in mypy invalid argument ordering + jsonCmd: dict # type: ignore[misc] # bug in mypy invalid argument ordering + deviceId: str = field(default_factory=str) + configModel: str = field(default_factory=lambda: '') + + def __post_serialize__(self, d: dict) -> dict: + """Remove empty strings before serialization.""" + for attrs in ['deviceId', 'configModel']: + d.pop(attrs, None) + return d @dataclass -class ResultFryerDetails(ResponseBaseModel): +class Fryer158Result(BypassV1Result): """Result model for air fryer details.""" - returnStatus: FryerCookingReturnStatus | FryerBaseReturnStatus | None = None + returnStatus: Fryer158CookingReturnStatus @dataclass -class FryerCookingReturnStatus(ResponseBaseModel): +class Fryer158CookingReturnStatus(ResponseBaseModel): """Result returnStatus model for air fryer status.""" - currentTemp: int - cookSetTemp: int + cookStatus: str + currentTemp: int | None = None + cookSetTemp: int | None = None + mode: str | None = None + cookSetTime: int | None = None + cookLastTime: int | None = None + tempUnit: str | None = None + preheatLastTime: int | None = None + preheatSetTime: int | None = None + targetTemp: int | None = None + + +@dataclass +class Fryer158CookRequest(RequestBaseModel): + """Base request model for air fryer cooking commands.""" + cookMode: Annotated[Fryer158CookModeBase, Discriminator(include_subtypes=True)] + + +@dataclass +class Fryer158PreheatRequest(RequestBaseModel): + """Base request model for air fryer preheat commands.""" + preheat: Annotated[Fryer158PreheatModeBase, Discriminator(include_subtypes=True)] + + +@dataclass +class Fryer158CookModeBase(RequestBaseModel): + """Base model for air fryer cooking modes.""" + + +@dataclass +class Fryer158CookModeFromPreheat(Fryer158CookModeBase): + """Model for continuing a cooking mode.""" + cookStatus: str + accountId: str mode: str - cookSetTime: int - cookLastTime: int + + +@dataclass +class Fryer158CookModeChange(Fryer158CookModeBase): + """Model for stopping a cooking mode.""" cookStatus: str + + +@dataclass +class Fryer158CookModeStart(Fryer158CookModeBase): + """Model for starting a cooking mode.""" + cookStatus: str + accountId: str + mode: str tempUnit: str + readyStart: bool + cookSetTime: int + cookSetTemp: int + appointmentTs: int = 0 + customRecipe: str = 'Manual Cooking' + recipeId: int = 1 + recipeType: int = 3 @dataclass -class FryerBaseReturnStatus(ResponseBaseModel): - """Result returnStatus model for air fryer status.""" +class Fryer158PreheatModeBase(RequestBaseModel): + """Base model for air fryer preheat modes.""" - cookStatus: str + +@dataclass +class Fryer158PreheatModeChange(Fryer158PreheatModeBase): + """Model for continuing a preheat mode.""" + preheatStatus: str + + +@dataclass +class Fryer158PreheatModeStart(Fryer158PreheatModeBase): + """Model for starting a preheat mode.""" + preheatStatus: str + accountId: str + mode: str + tempUnit: str + readyStart: bool + preheatSetTime: int + targetTemp: int + cookSetTime: int + customRecipe: str = 'Manual' + recipeId: int = 1 + recipeType: int = 3 + + +# a = { +# 'cookMode': { +# 'accountId': '1221391', +# 'appointmentTs': 0, +# 'cookSetTemp': 350, +# 'cookSetTime': 15, +# 'cookStatus': 'cooking', +# 'customRecipe': 'Manual Cooking', +# 'mode': 'custom', +# 'readyStart': True, +# 'recipeId': 1, +# 'recipeType': 3, +# 'tempUnit': 'fahrenheit', +# }, +# 'preheat': { +# 'customRecipe': 'Manual', +# 'readyStart': False, +# 'cookSetTime': 15, +# 'tempUnit': 'fahrenheit', +# 'mode': 'custom', +# 'accountId': '1221391', +# 'targetTemp': 350, +# 'preheatSetTime': 4, +# 'preheatStatus': 'heating', +# 'recipeId': 1, +# 'recipeType': 3, +# }, +# 'cookMode': { +# 'accountId': '1221391', +# 'cookSetTemp': 400, +# 'recipeId': 1, +# 'mode': 'custom', +# 'readyStart': False, +# 'appointmentTs': 0, +# 'cookStatus': 'cooking', +# 'customRecipe': 'Manual', +# 'cookSetTime': 15, +# 'tempUnit': 'fahrenheit', +# 'recipeType': 3, +# }, +# } diff --git a/src/pyvesync/utils/helpers.py b/src/pyvesync/utils/helpers.py index 8994851..47065e6 100644 --- a/src/pyvesync/utils/helpers.py +++ b/src/pyvesync/utils/helpers.py @@ -123,6 +123,16 @@ def temperature_celsius_to_fahrenheit(celsius: float) -> float: """Convert Celsius to Fahrenheit.""" return celsius * 9.0 / 5.0 + 32 + @staticmethod + def minutes_to_seconds(minutes: int) -> int: + """Convert minutes to seconds.""" + return minutes * 60 + + @staticmethod + def seconds_to_minutes(seconds: int) -> int: + """Convert seconds to minutes.""" + return seconds // 60 + class Helpers: """VeSync Helper Functions.""" From e55c3bf34050c2eeb08a009236f4941ebaac9a87 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 18 Jan 2026 23:35:06 -0500 Subject: [PATCH 09/19] add DC601S --- src/pyvesync/base_devices/fryer_base.py | 141 +++-- src/pyvesync/const.py | 33 +- src/pyvesync/device_map.py | 23 +- src/pyvesync/devices/vesynckitchen.py | 780 +++++++++--------------- src/pyvesync/models/fryer_models.py | 65 +- src/pyvesync/vesync.py | 2 + src/tests/call_json_air_fryers.py | 128 ++++ 7 files changed, 628 insertions(+), 544 deletions(-) create mode 100644 src/tests/call_json_air_fryers.py diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 58e54f5..ab78619 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -152,6 +152,7 @@ def cook_time_remaining(self) -> int | None: AirFryerCookStatus.COOK_STOP, ]: return self.cook_last_time + if self.cook_last_time is not None and self.last_timestamp is not None: return max( 0, @@ -180,15 +181,17 @@ def set_standby(self) -> None: self.last_timestamp = None self._clear_preheat() - def set_state( # noqa: PLR0913 + def set_state( # noqa: PLR0913, C901 self, *, cook_status: str, cook_time: int | None = None, + cook_last_time: int | None = None, cook_temp: int | None = None, temp_unit: str | None = None, cook_mode: str | None = None, preheat_time: int | None = None, + preheat_last_time: int | None = None, current_temp: int | None = None, ) -> None: """Set the cook state parameters. @@ -196,31 +199,42 @@ def set_state( # noqa: PLR0913 Args: cook_status (str): The cooking status. cook_time (int | None): The cooking time in seconds. + cook_last_time (int | None): The last cooking time in seconds. cook_temp (int | None): The cooking temperature. temp_unit (str | None): The temperature units (F or C). cook_mode (str | None): The cooking mode. preheat_time (int | None): The preheating time in seconds. + preheat_last_time (int | None): The remaining preheat time in seconds. current_temp (int | None): The current temperature. """ if cook_status == AirFryerCookStatus.STANDBY: self.set_standby() return - self.cook_status = cook_status - self.cook_set_time = cook_time - self.cook_set_temp = cook_temp - self.cook_mode = cook_mode - self.current_temp = current_temp + + self.preheat_set_time = preheat_time + self.preheat_last_time = preheat_last_time + + if cook_status is not None: + self.cook_status = AirFryerCookStatus(cook_status) + if cook_time is not None: + self.cook_set_time = cook_time + if cook_temp is not None: + self.cook_set_temp = cook_temp + if cook_mode is not None: + self.cook_mode = cook_mode + if current_temp is not None: + self.current_temp = current_temp if temp_unit is not None: - self.device.temp_unit = temp_unit + self.device.temp_unit = TemperatureUnits.from_string(temp_unit) if preheat_time is not None: self.preheat_set_time = preheat_time + if cook_last_time is not None: + self.cook_last_time = cook_last_time if cook_status in [ AirFryerCookStatus.COOKING, AirFryerCookStatus.HEATING, ]: self.last_timestamp = int(time.time()) - else: - self.last_timestamp = None class VeSyncFryer(VeSyncBaseDevice): @@ -237,6 +251,8 @@ class VeSyncFryer(VeSyncBaseDevice): 'state_chamber_1', 'state_chamber_2', 'sync_chambers', + 'temperature_interval', + 'time_units', ) def __init__( @@ -262,30 +278,77 @@ def __init__( self.state_chamber_1: FryerState = FryerState(self, details, feature_map) self.state_chamber_2: FryerState = FryerState(self, details, feature_map) self.sync_chambers: bool = False - self._temp_unit: TemperatureUnits | None = None 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.temperature_interval: int = feature_map.temperature_step_f + self.time_units: TimeUnits = feature_map.time_units + + # attempt to set temp unit from country code before first update + self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS + if self.manager.measure_unit and self.manager.measure_unit.lower() == 'imperial': + self._temp_unit = TemperatureUnits.FAHRENHEIT # Use single state attribute if not dual chamber fryer for compatibility if AirFryerFeatures.DUAL_CHAMBER not in self.features: self.state = self.state_chamber_1 @property - def temp_unit(self) -> TemperatureUnits | None: + def temp_unit(self) -> TemperatureUnits: """Return the temperature unit (F or C).""" return self._temp_unit @temp_unit.setter - def temp_unit(self, value: str) -> None: + def temp_unit(self, value: TemperatureUnits) -> None: """Set the temperature unit. Args: - value (str): The temperature unit (F or C). + value (TemperatureUnits): The temperature unit (F or C). """ self._temp_unit = TemperatureUnits.from_string(value) + def validate_temperature(self, temperature: int) -> bool: + """Validate the temperature is within the allowed range. + + Args: + temperature (int): The temperature to validate. + + Returns: + bool: True if the temperature is valid, False otherwise. + """ + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + return self.min_temp_f <= temperature <= self.max_temp_f + return self.min_temp_c <= temperature <= self.max_temp_c + + def round_temperature(self, temperature: int) -> int: + """Round the temperature to the nearest valid step. + + Args: + temperature (int): The temperature to round. + + Returns: + int: The rounded temperature. + """ + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + step: float = self.temperature_interval + return int(round(temperature / step) * step) + step = self.temperature_interval * 5 / 9 + return int(round(temperature / step) * step) + + def convert_time(self, time_in_seconds: int) -> int: + """Convert time in seconds to the device's time units. + + Args: + time_in_seconds (int): The time in seconds. + + Returns: + int: The time converted to the device's time units. + """ + if self.time_units == TimeUnits.MINUTES: + return int(time_in_seconds / 60) + return time_in_seconds + async def end(self, chamber: int = 1) -> bool: """End the current cooking or preheating session. @@ -309,7 +372,7 @@ async def stop(self, chamber: int = 1) -> bool: bool: True if the command was successful, False otherwise. """ del chamber - logger.info('stop not configured for this fryer.') + logger.info('stop not supported by this fryer.') return False async def resume(self, chamber: int = 1) -> bool: @@ -322,14 +385,15 @@ async def resume(self, chamber: int = 1) -> bool: bool: True if the command was successful, False otherwise. """ del chamber - logger.info('resume not configured for this fryer.') + logger.info('resume not supported by this fryer.') return False - async def set_cook_mode( + async def set_mode( self, cook_time: int, cook_temp: int, - cook_mode: str | None = None, + *, + preheat_time: int | None = None, chamber: int = 1, ) -> bool: """Set the cooking mode. @@ -337,39 +401,46 @@ async def set_cook_mode( Args: cook_time (int): The cooking time in seconds. cook_temp (int): The cooking temperature. - cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. + preheat_time (int | None): The preheating time in seconds, if any. chamber (int): The chamber number to set cooking for. Default is 1. Returns: bool: True if the command was successful, False otherwise. """ - del cook_time, cook_temp, cook_mode, chamber - logger.warning('set_cook_mode method not implemented for base fryer class.') + del cook_time, cook_temp, chamber, preheat_time + logger.warning('set_mode method not implemented for base fryer class.') return False - async def set_preheat_mode( + async def set_mode_from_recipe( self, - target_temp: int, - preheat_time: int, - cook_time: int, - cook_mode: str | None = None, - chamber: int = 1, + recipe: AirFryerPresetRecipe, ) -> bool: - """Set the preheating mode. + """Set the cooking mode from a preset recipe. Args: - target_temp (int): The target temperature for preheating. - preheat_time (int): The preheating time in seconds. - cook_time (int): The cooking time in seconds after preheating. - cook_mode (str): The cooking mode, defaults to default_cook_mode attribute. - chamber (int): The chamber number to set preheating for. Default is 1. + recipe (AirFryerPresetRecipe): The preset recipe to use. Returns: bool: True if the command was successful, False otherwise. """ - del target_temp, preheat_time, cook_time, cook_mode, chamber + del recipe + logger.warning( + 'set_mode_from_recipe method not implemented for base fryer class.' + ) + return False + + async def cook_from_preheat(self, chamber: int = 1) -> bool: + """Start cooking after preheating, cookStatus must be preheatEnd. + + Args: + chamber (int): The chamber number to start cooking for. Default is 1. + + Returns: + bool: True if the command was successful, False otherwise. + """ + del chamber if AirFryerFeatures.PREHEAT not in self.features: - logger.warning('set_preheat_mode method not supported for this fryer.') + logger.info('Preheat feature not supported on this fryer.') return False - logger.warning('set_preheat_mode method not implemented for base fryer class.') + logger.info('cook_from_preheat not configured for this fryer.') return False diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index d070e08..fdd7cb6 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -331,7 +331,7 @@ class TemperatureUnits(StrEnum): @property def code(self) -> str: - """Return the code for the temperature unit.""" + """Return the code for the temperature unit 'f' or 'c'.""" return self.value @property @@ -761,18 +761,27 @@ class FanModes(StrEnum): class AirFryerPresetRecipe: """Preset recipe for VeSync Air Fryers. + Set preheat_time to enable preheat mode. + Attributes: recipe_id (int): Recipe ID. recipe_type (int): Recipe type. - name (str): Recipe name. + recipe_name (str): Recipe name. + cook_mode (str): Cooking mode. + target_temp (int): Target temperature. + temp_unit (str): Temperature unit ('f' or 'c'). + cook_time (int): Cooking time in seconds. + preheat_time (int | None): Preheating time in seconds, if any. """ + recipe_name: str cook_mode: str recipe_id: int recipe_type: int target_temp: int temp_unit: str cook_time: int + preheat_time: int | None = None class AirFryerPresets: @@ -783,25 +792,28 @@ class AirFryerPresets: """ custom: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='Custom', + recipe_name='Manual Cook', recipe_id=1, recipe_type=3, target_temp=350, temp_unit='f', - cook_time=20, + cook_time=10*60, ) air_fry: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='AirFry', - recipe_id=4, + recipe_name='AirFry', + recipe_id=14, recipe_type=3, target_temp=400, temp_unit='f', - cook_time=25, + cook_time=10*60, ) AIRFRYER_PRESET_MAP = { 'custom': AirFryerPresetRecipe( cook_mode='Custom', + recipe_name='Manual Cook', recipe_id=1, recipe_type=3, target_temp=350, @@ -810,6 +822,7 @@ class AirFryerPresets: ), 'airfry': AirFryerPresetRecipe( cook_mode='AirFry', + recipe_name='AirFry', recipe_id=4, recipe_type=3, target_temp=400, @@ -876,17 +889,17 @@ class AirFryerCookStatus(StrEnum): """ COOKING = 'cooking' - COOK_STOP = 'cook_stop' - COOK_END = 'cook_end' - PULL_OUT = 'pull_out' + COOK_STOP = 'cookStop' + COOK_END = 'cookEnd' + PULL_OUT = 'pullOut' PAUSED = 'paused' COMPLETED = 'completed' HEATING = 'heating' STOPPED = 'stopped' UNKNOWN = 'unknown' STANDBY = 'standby' - PREHEAT_END = 'preheat_end' - PREHEAT_STOP = 'preheat_stop' + PREHEAT_END = 'preheatEnd' + PREHEAT_STOP = 'preheatStop' # Thermostat Constants diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 7cd6078..6cae8c2 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1097,11 +1097,30 @@ class ThermostatMap(DeviceMapTemplate): features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], cook_modes={ AirFryerCookModes.AIRFRY: 'custom', - AirFryerCookModes.PREHEAT: 'preheat', }, default_preset=AirFryerPresets.custom, - default_cook_mode=AirFryerCookModes.CUSTOM + default_cook_mode=AirFryerCookModes.CUSTOM, + time_units=TimeUnits.MINUTES, ), + AirFryerMap( + class_name='VeSyncTurboBlazeFryer', + module=vesynckitchen, + dev_types=['CAF-DC601S-WUSR', 'CAF-DC601S-WUS'], + setup_entry='CAF-DC601S', + device_alias='TurboBlaze Air Fryer', + model_display='CAF-DC601S Series', + model_name='TurboBlaze 6 Qt. Air Fryer', + temperature_step_f=5, + features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], + cook_modes={ + AirFryerCookModes.AIRFRY: 'AirFry', + }, + default_cook_mode=AirFryerCookModes.AIRFRY, + default_preset=AirFryerPresets.air_fry, + time_units=TimeUnits.SECONDS, + temperature_range_f=(90, 450), + temperature_range_c=(30, 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 7f7623c..fb57788 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -12,34 +12,33 @@ 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 dataclasses import replace 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, TemperatureUnits, AirFryerCookModes, AirFryerCookStatus, AirFryerFeatures, AirFryerPresets -from pyvesync.models.fryer_models import Fryer158CookingReturnStatus, Fryer158RequestModel, Fryer158Result -from pyvesync.utils.device_mixins import BypassV1Mixin, process_bypassv1_result +from pyvesync.const import ( + AIRFRYER_PID_MAP, + AirFryerCookStatus, + AirFryerPresetRecipe, +) +from pyvesync.models import fryer_models as models +from pyvesync.utils.device_mixins import ( + BypassV1Mixin, + BypassV2Mixin, + process_bypassv1_result, + process_bypassv2_result, +) from pyvesync.utils.errors import VeSyncError from pyvesync.utils.helpers import Helpers -from pyvesync.utils.logs import LibraryLogger + +# from pyvesync.utils.logs import LibraryLogger if TYPE_CHECKING: from pyvesync import VeSync @@ -51,279 +50,6 @@ 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__ = () - -# 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 self.cook_status in ('cooking', 'heating') and 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: -# """Set status from jsonCmd of API call.""" -# self.last_timestamp = None -# if not isinstance(json_cmd, dict): -# return -# self.preheat = False - -# preheat_cmd = json_cmd.get('preheat') -# if isinstance(preheat_cmd, dict): -# self.preheat = True -# preheat_status = preheat_cmd.get('preheatStatus') -# if preheat_status == 'stop': -# self.cook_status = 'preheatStop' -# return -# if preheat_status == 'heating': -# self.cook_status = 'heating' -# self.last_timestamp = int(time.time()) -# self.preheat_set_time = preheat_cmd.get( -# 'preheatSetTime', self.preheat_set_time -# ) -# preheat_set_time = preheat_cmd.get('preheatSetTime') -# if preheat_set_time is not None: -# self.preheat_last_time = preheat_set_time -# self.cook_set_temp = preheat_cmd.get('targetTemp', self.cook_set_temp) -# self.cook_set_time = preheat_cmd.get('cookSetTime', self.cook_set_time) -# self.cook_last_time = None -# return -# if preheat_status == 'end': -# self.cook_status = 'preheatEnd' -# self.preheat_last_time = 0 -# return - -# cook_cmd = json_cmd.get('cookMode') -# if not isinstance(cook_cmd, dict): -# return - -# self.clear_preheat() -# cook_status = cook_cmd.get('cookStatus') -# if cook_status == 'stop': -# self.cook_status = 'cookStop' -# return -# if cook_status == 'cooking': -# self.cook_status = 'cooking' -# self.last_timestamp = int(time.time()) -# self.cook_set_time = cook_cmd.get('cookSetTime', self.cook_set_time) -# self.cook_set_temp = cook_cmd.get('cookSetTemp', self.cook_set_temp) -# self.current_temp = cook_cmd.get('currentTemp', self.current_temp) -# self.temp_unit = cook_cmd.get( -# 'tempUnit', -# self.temp_unit, # type: ignore[assignment] -# ) -# return -# if cook_status == '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(BypassV1Mixin, VeSyncFryer): """Cosori Air Fryer Class. @@ -382,95 +108,84 @@ def __init__( ) 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 + def _build_base_request( + self, cook_set_time: int, recipe: AirFryerPresetRecipe | None = None + ) -> dict[str, int | str | bool]: + """Build base cook or preheat request body. + + This allows a custom recipe to be passed, but defaults to manual + cooking. The cook_set_time argument is required and will override + the default time in the recipe. + """ + cook_base: dict[str, int | str | bool] = {} + cook_base['cookSetTime'] = cook_set_time + if recipe is None: + cook_base['recipeId'] = self.default_preset.recipe_id + cook_base['customRecipe'] = self.default_preset.recipe_name + cook_base['mode'] = self.default_preset.cook_mode + cook_base['recipeType'] = self.default_preset.recipe_type + else: + cook_base['recipeId'] = recipe.recipe_id + cook_base['customRecipe'] = recipe.recipe_name + cook_base['mode'] = recipe.cook_mode + cook_base['recipeType'] = recipe.recipe_type + + cook_base['accountId'] = self.manager.account_id + if self.temp_unit is not None: + cook_base['tempUnit'] = self.temp_unit.label + else: + cook_base['tempUnit'] = 'fahrenheit' + cook_base['readyStart'] = True + return cook_base def _build_cook_request( self, cook_time: int, cook_temp: int, - cook_status: str = 'cooking', + recipe: AirFryerPresetRecipe | None = None, ) -> dict[str, int | str | bool]: """Internal command to build cookMode API command.""" - cook_mode: dict[str, int | str | bool] = {} - cook_mode['accountId'] = self.manager.account_id + cook_mode = self._build_base_request(cook_time, recipe) cook_mode['appointmentTs'] = 0 cook_mode['cookSetTemp'] = cook_temp - cook_mode['cookSetTime'] = cook_time - cook_mode['cookStatus'] = cook_status - cook_mode['customRecipe'] = 'Manual' - cook_mode['mode'] = self.default_preset.cook_mode - cook_mode['readyStart'] = True - cook_mode['recipeId'] = self.default_preset.recipe_id - cook_mode['recipeType'] = self.default_preset.recipe_type - if self.temp_unit is not None: - cook_mode['tempUnit'] = self.temp_unit.label - else: - cook_mode['tempUnit'] = 'fahrenheit' + cook_mode['cookStatus'] = AirFryerCookStatus.COOKING.value return cook_mode + def _build_preheat_request( + self, + cook_time: int, + cook_temp: int, + recipe: AirFryerPresetRecipe | None = None, + ) -> dict[str, int | str | bool]: + """Internal command to build preheat API command.""" + preheat_mode = self._build_base_request(cook_time, recipe) + preheat_mode['targetTemp'] = cook_temp + preheat_mode['preheatSetTime'] = cook_time + preheat_mode['preheatStatus'] = AirFryerCookStatus.HEATING.value + return preheat_mode + async def get_details(self) -> None: cmd = {'getStatus': 'status'} - resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=cmd) + resp = await self.call_bypassv1_api(models.Fryer158RequestModel, update_dict=cmd) resp_model = process_bypassv1_result( self, logger, 'get_details', resp, - Fryer158Result, + models.Fryer158Result, ) if resp_model is None or resp_model.returnStatus is None: - logger.debug('No returnStatus in get_details response for %s', self.device_name) + logger.debug( + 'No returnStatus in get_details response for %s', self.device_name + ) self.state.set_standby() return None @@ -478,37 +193,15 @@ async def get_details(self) -> None: return self.state.set_state( cook_status=return_status.cookStatus, cook_time=return_status.cookSetTime, + cook_last_time=return_status.cookLastTime, cook_temp=return_status.cookSetTemp, temp_unit=return_status.tempUnit, cook_mode=return_status.mode, preheat_time=return_status.preheatSetTime, + preheat_last_time=return_status.preheatLastTime, current_temp=return_status.currentTemp, ) - async def end_cook(self, chamber: int = 1) -> bool: - del chamber # chamber not used for this air fryer - cmd = {'cookMode': {'cookStatus': 'end'}} - resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict={'jsonCmd': cmd} - ) - if resp is None: - logger.debug('No response from end command for %s', self.device_name) - return False - self.state.set_standby() - return True - - async def end_preheat(self, chamber: int = 1) -> bool: - del chamber # chamber not used for this air fryer - cmd = {'preheat': {'preheatStatus': 'end'}} - resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict={'jsonCmd': cmd} - ) - if resp is None: - logger.debug('No response from end preheat command for %s', self.device_name) - return False - self.state.set_standby() - return True - async def end(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_cook_mode is True: @@ -521,12 +214,14 @@ async def end(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api(Fryer158RequestModel, update_dict=json_cmd) - if resp is not None: - self.state.set_standby() - return True - logger.warning('Error ending for %s', self.device_name) - return False + resp = await self.call_bypassv1_api( + models.Fryer158RequestModel, update_dict=json_cmd + ) + r = Helpers.process_dev_response(logger, 'end', self, resp) + if r is None: + return False + self.state.set_standby() + return True async def stop(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer @@ -541,146 +236,239 @@ async def stop(self, chamber: int = 1) -> bool: return False json_cmd = {'jsonCmd': cmd} resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict=json_cmd + models.Fryer158RequestModel, update_dict=json_cmd ) - if resp is not None: - if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP - if self.state.is_in_cook_mode is True: - self.state.cook_status = AirFryerCookStatus.COOK_STOP - return True - logger.warning('Error stopping for %s', self.device_name) - return False + r = Helpers.process_dev_response(logger, 'stop', self, resp) + if r is None: + return False + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOK_STOP + return True async def resume(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'heating'}} - if self.state.is_in_cook_mode is True: + elif self.state.is_in_cook_mode is True: cmd = {'cookMode': {'cookStatus': 'cooking'}} else: logger.debug( - 'Cannot resume %s as it is not cooking or preheating', self.device_name + 'Cannot resume %s as it is not cooking or preheating', self.device_name ) + return False json_cmd = {'jsonCmd': cmd} resp = await self.call_bypassv1_api( - Fryer158RequestModel, update_dict=json_cmd + models.Fryer158RequestModel, update_dict=json_cmd ) - if resp is not None: - if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.HEATING - if self.state.is_in_cook_mode is True: - self.state.cook_status = AirFryerCookStatus.COOKING - return True - logger.warning('Error resuming for %s', self.device_name) - return False - - 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: + r = Helpers.process_dev_response(logger, 'resume', self, resp) + if r is None: 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.is_in_preheat_mode 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.is_in_preheat_mode 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 + + if self.state.is_in_preheat_mode is True: + self.state.cook_status = AirFryerCookStatus.HEATING + if self.state.is_in_cook_mode is True: + self.state.cook_status = AirFryerCookStatus.COOKING + return True + + async def set_mode_from_recipe( + self, + recipe: AirFryerPresetRecipe, + *, + chamber: int = 1, + ) -> bool: + del chamber # chamber not used for this air fryer + if recipe.preheat_time is not None and recipe.preheat_time > 0: + cook_status = AirFryerCookStatus.HEATING + preheat_req = self._build_preheat_request( + cook_time=recipe.preheat_time, cook_temp=recipe.target_temp, recipe=recipe ) + cmd = {'preheat': preheat_req} + else: + cook_status = AirFryerCookStatus.COOKING + cook_req = self._build_cook_request( + cook_time=recipe.cook_time, cook_temp=recipe.target_temp, recipe=recipe + ) + cmd = {'cookMode': cook_req} + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + models.Fryer158RequestModel, update_dict=json_cmd + ) + r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) + if r is None: return False - if self._validate_temp(target_temp) is False: + self.state.set_state( + cook_status=cook_status, + cook_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + preheat_time=recipe.preheat_time, + ) + return True + + async def set_mode( + self, + cook_time: int, + cook_temp: int, + *, + preheat_time: int | None = None, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + if self.validate_temperature(cook_temp) is False: + logger.warning('Invalid cook temperature for %s', self.device_name) 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.is_in_preheat_mode is False or self.state.cook_status != 'preheatEnd': + cook_temp = self.round_temperature(cook_temp) + cook_time = self.convert_time(cook_time) + preset_recipe = replace(self.default_preset) + preset_recipe.cook_time = cook_time + preset_recipe.target_temp = cook_temp + if cook_mode is not None: + preset_recipe.cook_mode = cook_mode + if preheat_time is not None: + preset_recipe.preheat_time = self.convert_time(preheat_time) + return await self.set_mode_from_recipe(preset_recipe, chamber=chamber) + + async def cook_from_preheat(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + if self.state.cook_status != AirFryerCookStatus.PREHEAT_END: 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, + cmd = { + 'cookMode': { + 'mode': self.state.cook_mode, + 'accountId': self.manager.account_id, + 'cookStatus': 'cooking', } + } + json_cmd = {'jsonCmd': cmd} + resp = await self.call_bypassv1_api( + models.Fryer158RequestModel, update_dict=json_cmd ) - return cmd + r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) + if r is None: + return False + self.state.set_state(cook_status=AirFryerCookStatus.COOKING) + return True + + +class VeSyncTurboBlazeFryer(BypassV2Mixin, VeSyncFryer): + """VeSync TurboBlaze Air Fryer Class.""" - async def _set_cook( + __slots__ = () + + def __init__( 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 + details: ResponseDeviceDetailsModel, + manager: VeSync, + feature_map: AirFryerMap, + ) -> None: + """Init the VeSync TurboBlaze Air Fryer class.""" + super().__init__(details, manager, feature_map) - 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: + # Single chamber fryer state + self.state: FryerState = FryerState(self, details, feature_map) + + def _build_cook_request( + self, recipe: AirFryerPresetRecipe + ) -> models.FryerTurboBlazeRequestData: + cook_req: dict[str, int | str | bool | dict] = {} + cook_req['accountId'] = self.manager.account_id + if recipe.preheat_time is not None and recipe.preheat_time > 0: + cook_req['hasPreheat'] = int(True) + cook_req['hasWarm'] = False + cook_req['mode'] = recipe.cook_mode + cook_req['readyStart'] = True + cook_req['recipeId'] = recipe.recipe_id + cook_req['recipeName'] = recipe.recipe_name + cook_req['recipeType'] = recipe.recipe_type + cook_req['tempUnit'] = self.temp_unit.code + cook_req['startAct'] = { + 'cookSetTime': recipe.cook_time, + 'cookTemp': recipe.target_temp, + 'preheatTemp': recipe.target_temp if recipe.preheat_time else 0, + 'shakeTime': 0, + } + return models.FryerTurboBlazeRequestData.from_dict(cook_req) + + async def get_details(self) -> None: + + resp = await self.call_bypassv2_api(payload_method='getAirfyerStatus') + resp_model = process_bypassv2_result( + self, + logger, + 'get_details', + resp, + models.FryerTurboBlazeDetailResult, + ) + + if ( + resp_model is None + or resp_model.cookStatus == AirFryerCookStatus.STANDBY.value + or not resp_model.stepArray + ): + self.state.set_standby() + return + + cook_step = resp_model.stepArray[resp_model.stepIndex] + + self.state.set_state( + cook_status=resp_model.cookStatus, + cook_time=cook_step.cookSetTime, + cook_last_time=cook_step.cookLastTime, + cook_temp=cook_step.cookTemp, + temp_unit=resp_model.tempUnit, + cook_mode=cook_step.mode, + preheat_time=resp_model.preheatSetTime, + preheat_last_time=resp_model.preheatLastTime, + current_temp=resp_model.currentTemp, + ) + + async def end(self, chamber: int = 1) -> bool: + del chamber # chamber not used for this air fryer + payload_method = 'endCook' + resp = await self.call_bypassv2_api(payload_method=payload_method) + r = Helpers.process_dev_response(logger, 'end', self, resp) + if r is None: return False + self.state.set_standby() + return True - self.last_update = int(time.time()) - self.state.status_request(json_cmd) - await self.update() + async def set_mode_from_recipe(self, recipe: AirFryerPresetRecipe) -> bool: + payload_method = 'startCook' + data = self._build_cook_request(recipe) + resp = await self.call_bypassv2_api( + payload_method=payload_method, + data=data.to_dict(), + ) + r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) + if r is None: + self.state.set_standby() + return False + self.state.set_state( + cook_status=AirFryerCookStatus.COOKING, + cook_time=recipe.cook_time, + cook_last_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + preheat_time=recipe.preheat_time if recipe.preheat_time else None, + preheat_last_time=recipe.preheat_time if recipe.preheat_time else None, + ) return True + + async def set_mode( + self, + cook_time: int, + cook_temp: int, + *, + preheat_time: int | None = None, + chamber: int = 1, + ) -> bool: + del chamber # chamber not used for this air fryer + recipe = replace(self.default_preset) + recipe.cook_time = self.convert_time(cook_time) + recipe.target_temp = self.round_temperature(cook_temp) + if preheat_time is not None: + recipe.preheat_time = self.convert_time(preheat_time) + return await self.set_mode_from_recipe(recipe) diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index cc30dec..d86871d 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -8,7 +8,11 @@ from mashumaro.types import Discriminator from pyvesync.models.base_models import RequestBaseModel, ResponseBaseModel -from pyvesync.models.bypass_models import BypassV1Result, RequestBypassV1 +from pyvesync.models.bypass_models import ( + BypassV1Result, + BypassV2InnerResult, + RequestBypassV1, +) @dataclass @@ -124,6 +128,65 @@ class Fryer158PreheatModeStart(Fryer158PreheatModeBase): recipeType: int = 3 +@dataclass +class FryerTurboBlazeDetailResult(BypassV2InnerResult): + """Result model for TurboBlaze air fryer details.""" + + stepArray: list[FryerTurboBlazeStepItem] + cookMode: str + tempUnit: str + stepIndex: int + cookStatus: str + preheatSetTime: int + preheatLastTime: int + preheatEndTime: int + preheatTemp: int + startTime: int + totalTimeRemaining: int + currentTemp: int + shakeStatus: int + + +@dataclass +class FryerTurboBlazeStepItem(ResponseBaseModel): + """Data model for TurboBlaze air fryer cooking steps.""" + + cookSetTime: int + cookTemp: int + mode: str + cookLastTime: int + shakeTime: int + cookEndTime: int + recipeName: str + recipeId: int + recipeType: int + + +@dataclass +class FryerTurboBlazeRequestData(RequestBaseModel): + """Request model for TurboBlaze air fryer cooking commands.""" + + accountId: str + hasPreheat: int + hasWarm: bool + readyStart: bool + recipeId: int + recipeName: str + recipeType: int + tempUnit: str + startAct: list[FryerTurboBlazeStartActItem] + + +@dataclass +class FryerTurboBlazeStartActItem(RequestBaseModel): + """Data model for TurboBlaze air fryer startAct items.""" + + cookSetTime: int + cookTemp: int + preheatTemp: int = 0 + shakeTime: int = 0 + + # a = { # 'cookMode': { # 'accountId': '1221391', diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 63ffc43..75a67df 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -64,6 +64,7 @@ class VeSync: # pylint: disable=function-redefined 'enabled', 'in_process', 'language', + 'measure_unit', 'session', 'time_zone', ) @@ -144,6 +145,7 @@ class is instantiated, call `await manager.login()` to log in to VeSync servers, self.language: str = 'en' self.enabled = False self.in_process = False + self.measure_unit: str | None = None self._device_container: DeviceContainer = DeviceContainer() # Initialize authentication manager diff --git a/src/tests/call_json_air_fryers.py b/src/tests/call_json_air_fryers.py new file mode 100644 index 0000000..db1a51e --- /dev/null +++ b/src/tests/call_json_air_fryers.py @@ -0,0 +1,128 @@ +""" +Air Fryer Device API Responses + +AIR_FRYER variable is a list of device types + +DETAILS_RESPONSES variable is a dictionary of responses from the API +for get_details() methods. The keys are the device types and the +values are the responses. The responses are tuples of (response, status) + +METHOD_RESPONSES variable is a defaultdict of responses from the API. This is +the FunctionResponse variable from the utils module in the tests dir. +The default response is a tuple with the value ({"code": 0, "msg": "success"}, 200). + +The values of METHOD_RESPONSES can be a function that takes a single argument or +a static value. The value is checked if callable at runtime and if so, it is called +with the provided argument. If not callable, the value is returned as is. + +METHOD_RESPONSES = { + 'CS158-AF': defaultdict( + lambda: ({"code": 0, "msg": "success"}, 200)) + ) +} + +# For a function to handle the response +def status_response(request_body=None): + # do work with request_body + return request_body, 200 + +METHOD_RESPONSES['CS158-AF']['set_cook_mode'] = status_response + +# To change the default value for a device type + +METHOD_RESPONSES['CS158-AF'].default_factory = lambda: ({"code": 0, "msg": "success"}, 200) + +""" + +from copy import deepcopy +from pyvesync.device_map import air_fryer_modules +from pyvesync.const import ( + DeviceStatus, + ConnectionStatus, + TemperatureUnits, + AirFryerCookStatus, +) +from defaults import ( + TestDefaults, + FunctionResponsesV2, + FunctionResponsesV1, + build_bypass_v1_response, + build_bypass_v2_response, +) + + +class AirFryerDefaults: + temp_unit = TemperatureUnits.FAHRENHEIT + cook_time_f = 10 + cook_temp_f = 350 + cook_mode = "custom" + current_temp_f = 150 + cook_status = AirFryerCookStatus.COOKING + recipe = "Manual" + + +AIR_FRYER_COOKING_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { + "CS158-AF": { + "returnStatus": { + "curentTemp": AirFryerDefaults.current_temp_f, + "cookSetTemp": AirFryerDefaults.cook_temp_f, + "mode": AirFryerDefaults.cook_mode, + "cookSetTime": AirFryerDefaults.cook_time_f, + "cookLastTime": AirFryerDefaults.cook_time_f - 2, + "cookStatus": AirFryerDefaults.cook_status.value, + "tempUnit": AirFryerDefaults.temp_unit.label, + "accountId": TestDefaults.account_id, + "customRecipe": AirFryerDefaults.recipe, + } + }, + "CAF-DC601S": { + "traceId": "1767318172645", + "code": 0, + "result": { + "stepArray": [ + { + "cookSetTime": 1200, + "cookTemp": 330, + "mode": "Bake", + "cookLastTime": 1176, + "shakeTime": 0, + "cookEndTime": 0, + "recipeName": "Bake", + "recipeId": 9, + "recipeType": 3, + } + ], + "cookMode": "normal", + "tempUnit": "f", + "stepIndex": 0, + "cookStatus": "cooking", + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 1767318116, + "totalTimeRemaining": 1176, + "currentTemp": 89, + "shakeStatus": 0, + }, + }, +} + +AIR_FRYER_STANDYBY_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { + "CAF-DC601S": { + "stepArray": [], + "cookMode": "normal", + "tempUnit": "f", + "stepIndex": 0, + "cookStatus": "standby", + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 0, + "totalTimeRemaining": 0, + "currentTemp": 43, + "shakeStatus": 0, + }, + "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, +} From 3dc34b74fd1da8c82e35c8ac409bc50aa672e953 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:42:47 +0000 Subject: [PATCH 10/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/base_devices/fryer_base.py | 4 +--- src/pyvesync/const.py | 6 +++--- src/pyvesync/device_map.py | 2 +- src/pyvesync/devices/vesynckitchen.py | 13 ++++++------- src/pyvesync/models/fryer_models.py | 7 +++++++ 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index ab78619..7c6e766 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -75,9 +75,7 @@ def __init__( self.last_timestamp: int | None = None self.preheat_set_time: int | None = None self.preheat_last_time: int | None = None - self._time_conv: float = ( - 60 if feature_map.time_units == TimeUnits.MINUTES else 1 - ) + self._time_conv: float = 60 if feature_map.time_units == TimeUnits.MINUTES else 1 @property def is_in_preheat_mode(self) -> bool: diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index fdd7cb6..8d04c20 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -790,6 +790,7 @@ class AirFryerPresets: Attributes: custom (AirFryerPresetRecipe): Custom preset recipe. """ + custom: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='Custom', recipe_name='Manual Cook', @@ -797,7 +798,7 @@ class AirFryerPresets: recipe_type=3, target_temp=350, temp_unit='f', - cook_time=10*60, + cook_time=10 * 60, ) air_fry: AirFryerPresetRecipe = AirFryerPresetRecipe( cook_mode='AirFry', @@ -806,7 +807,7 @@ class AirFryerPresets: recipe_type=3, target_temp=400, temp_unit='f', - cook_time=10*60, + cook_time=10 * 60, ) @@ -829,7 +830,6 @@ class AirFryerPresets: temp_unit='f', cook_time=25, ), - } diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 6cae8c2..ac94ba3 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1120,7 +1120,7 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(90, 450), temperature_range_c=(30, 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 fb57788..98fd6a2 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -299,12 +299,12 @@ async def set_mode_from_recipe( if r is None: return False self.state.set_state( - cook_status=cook_status, - cook_time=recipe.cook_time, - cook_temp=recipe.target_temp, - cook_mode=recipe.cook_mode, - preheat_time=recipe.preheat_time, - ) + cook_status=cook_status, + cook_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + preheat_time=recipe.preheat_time, + ) return True async def set_mode( @@ -393,7 +393,6 @@ def _build_cook_request( return models.FryerTurboBlazeRequestData.from_dict(cook_req) async def get_details(self) -> None: - resp = await self.call_bypassv2_api(payload_method='getAirfyerStatus') resp_model = process_bypassv2_result( self, diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index d86871d..ab0c966 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -57,12 +57,14 @@ class Fryer158CookingReturnStatus(ResponseBaseModel): @dataclass class Fryer158CookRequest(RequestBaseModel): """Base request model for air fryer cooking commands.""" + cookMode: Annotated[Fryer158CookModeBase, Discriminator(include_subtypes=True)] @dataclass class Fryer158PreheatRequest(RequestBaseModel): """Base request model for air fryer preheat commands.""" + preheat: Annotated[Fryer158PreheatModeBase, Discriminator(include_subtypes=True)] @@ -74,6 +76,7 @@ class Fryer158CookModeBase(RequestBaseModel): @dataclass class Fryer158CookModeFromPreheat(Fryer158CookModeBase): """Model for continuing a cooking mode.""" + cookStatus: str accountId: str mode: str @@ -82,12 +85,14 @@ class Fryer158CookModeFromPreheat(Fryer158CookModeBase): @dataclass class Fryer158CookModeChange(Fryer158CookModeBase): """Model for stopping a cooking mode.""" + cookStatus: str @dataclass class Fryer158CookModeStart(Fryer158CookModeBase): """Model for starting a cooking mode.""" + cookStatus: str accountId: str mode: str @@ -109,12 +114,14 @@ class Fryer158PreheatModeBase(RequestBaseModel): @dataclass class Fryer158PreheatModeChange(Fryer158PreheatModeBase): """Model for continuing a preheat mode.""" + preheatStatus: str @dataclass class Fryer158PreheatModeStart(Fryer158PreheatModeBase): """Model for starting a preheat mode.""" + preheatStatus: str accountId: str mode: str From b534e6d219aabb30881d9e5fb893d13278dbc43e Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 2 Mar 2026 21:26:02 -0500 Subject: [PATCH 11/19] Rebase air fryer onto dev branch --- .gitignore | 2 +- src/pyvesync/base_devices/fryer_base.py | 355 ++++++++++++++---- src/pyvesync/const.py | 25 +- src/pyvesync/device_map.py | 26 +- src/pyvesync/devices/vesynckitchen.py | 206 +++++++--- src/pyvesync/models/fryer_models.py | 48 ++- src/tests/call_json.py | 6 +- ...json_air_fryers.py => call_json_fryers.py} | 72 +++- src/tests/test_fryers.py | 268 +++++++++++++ 9 files changed, 855 insertions(+), 153 deletions(-) rename src/tests/{call_json_air_fryers.py => call_json_fryers.py} (63%) create mode 100644 src/tests/test_fryers.py diff --git a/.gitignore b/.gitignore index a13874c..a151090 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,4 @@ docstest/* .claude/settings.local.json .mcp.json .vesync_auth -**/CLAUDE.md \ No newline at end of file +**/CLAUDE.md diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 7c6e766..9c401f1 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -1,14 +1,18 @@ -"""Air Purifier Base Class.""" +"""Air Fryer Base Class.""" from __future__ import annotations import logging -import time +from datetime import datetime, timezone from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice from pyvesync.const import ( AIRFRYER_PID_MAP, + COOK_STATUSES, + PREHEAT_STATUSES, + RESUMABLE_STATUSES, + RUNNING_STATUSES, AirFryerCookStatus, AirFryerFeatures, AirFryerPresetRecipe, @@ -28,22 +32,61 @@ class FryerState(DeviceState): """State class for Air Fryer devices. - Time units are in seconds by default. They are automatically converted - from the API response. + Each FryerState instance represents a single cooking chamber. For single + chamber fryers, ``state`` is set to ``state_chamber_1``. For dual chamber + fryers, ``state_chamber_1`` and ``state_chamber_2`` are used independently. + + Time units are in SECONDS. They are automatically converted from the API + response via the device's ``convert_time_for_state`` method. The + ``last_timestamp`` attribute is set when the fryer enters a running state + (cooking or heating) and is used to calculate remaining time. + + Args: + device (VeSyncFryer): The device object. + details (ResponseDeviceDetailsModel): The device details. + feature_map (AirFryerMap): The feature map for the device. + + Attributes: + active_time (int): Active time of device, defaults to None. + connection_status (str): Connection status of device. + device (VeSyncFryer): Device object. + device_status (str): Device status. + features (dict): Features of device. + time_units (TimeUnits): The time units used by the device. + cook_status (AirFryerCookStatus | None): The current cooking status. + cook_mode (str | None): The current cooking mode. + current_temp (int | None): The current temperature of the fryer. + cook_set_temp (int | None): The set cooking temperature. + cook_set_time (int | None): The set cooking time in seconds. + cook_last_time (int | None): The remaining cooking time in seconds. + last_timestamp (datetime | None): The timestamp of the last status update. + preheat_set_time (int | None): The set preheating time in seconds. + preheat_last_time (int | None): The remaining preheating time in seconds. + recipe (str | None): The current recipe or cooking mode. + ready_start (bool): Whether the fryer is ready to start cooking. + + Note: + Use convenience state methods like ``set_standby``, ``set_cook_stop_state``, + ``set_preheat_stop_state``, and ``set_preheat_resume_state`` to set the + state attributes when changing cooking states. The ``set_state`` method + can be used to set all state attributes at once when updating from the API + response. The ``last_timestamp`` attribute will be automatically updated + when setting a running state (cooking or heating). """ __slots__ = ( - '_time_conv', + '_cook_status', 'cook_last_time', 'cook_mode', 'cook_set_temp', 'cook_set_time', - 'cook_status', 'current_temp', 'last_timestamp', 'preheat_last_time', 'preheat_set_time', 'ready_start', + 'recipe', + 'sync_chambers', 'time_units', ) @@ -53,37 +96,29 @@ def __init__( details: ResponseDeviceDetailsModel, feature_map: AirFryerMap, ) -> None: - """Initialize FryerState. - - Args: - device (VeSyncFryer): The device object. - details (ResponseDeviceDetailsModel): The device details. - feature_map (AirFryerMap): The feature map for the device. - - """ + """Initialize FryerState.""" super().__init__(device, details, feature_map) self.device: VeSyncFryer = device self.features: list[str] = feature_map.features self.time_units: TimeUnits = feature_map.time_units - self.ready_start: bool = False - self.cook_status: str | None = None - self.cook_mode: str | None = None + self.sync_chambers: bool = False + # Cooking state attributes + self._cook_status: AirFryerCookStatus | None = None self.current_temp: int | None = None + self.cook_mode: str | 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._time_conv: float = 60 if feature_map.time_units == TimeUnits.MINUTES else 1 + self.last_timestamp: datetime | None = None + self.recipe: str | None = None + self.ready_start: bool = False @property def is_in_preheat_mode(self) -> bool: """Return True if the fryer has preheat feature.""" - return self.cook_status in [ - AirFryerCookStatus.HEATING, - AirFryerCookStatus.PREHEAT_STOP, - ] or ( + return self.cook_status in PREHEAT_STATUSES or ( self.cook_status == AirFryerCookStatus.PULL_OUT and self.preheat_set_time is not None ) @@ -91,22 +126,19 @@ def is_in_preheat_mode(self) -> bool: @property def is_in_cook_mode(self) -> bool: """Return True if the fryer is in cook mode.""" - return self.cook_status in [ - AirFryerCookStatus.COOKING, - AirFryerCookStatus.COOK_STOP, - ] or ( + return self.cook_status in COOK_STATUSES or ( self.cook_status == AirFryerCookStatus.PULL_OUT - and self.cook_set_time is not None + and self.preheat_last_time is None ) @property def is_cooking(self) -> bool: - """Return True if the fryer is currently cooking (not preheating).""" + """Return True if the fryer is currently running in cook mode.""" return self.cook_status == AirFryerCookStatus.COOKING @property def is_preheating(self) -> bool: - """Return True if the fryer is currently preheating.""" + """Return True if the fryer is currently running in preheat mode.""" return self.cook_status == AirFryerCookStatus.HEATING @property @@ -117,10 +149,25 @@ def is_running(self) -> bool: @property def can_resume(self) -> bool: """Return True if the fryer can resume cooking.""" - return self.cook_status in [ - AirFryerCookStatus.PREHEAT_STOP, - AirFryerCookStatus.COOK_STOP, - ] + return self.cook_status in RESUMABLE_STATUSES + + @property + def cook_status(self) -> AirFryerCookStatus | None: + """Return the current cooking status.""" + return self._cook_status + + @cook_status.setter + def cook_status(self, value: AirFryerCookStatus | None) -> None: + """Set the current cooking status. + + Automatically updates ``last_timestamp`` when setting a running state. + + Args: + value (AirFryerCookStatus | None): The cooking status to set. + """ + if value in RUNNING_STATUSES: + self.last_timestamp = datetime.now(tz=timezone.utc) + self._cook_status = value @property def preheat_time_remaining(self) -> int | None: @@ -136,7 +183,7 @@ def preheat_time_remaining(self) -> int | None: return max( 0, self.preheat_last_time - - int((self.last_timestamp - time.time()) * self._time_conv), + - int((datetime.now(timezone.utc) - self.last_timestamp).total_seconds()), ) return None @@ -155,7 +202,7 @@ def cook_time_remaining(self) -> int | None: return max( 0, self.cook_last_time - - int((self.last_timestamp - time.time()) * self._time_conv), + - int((datetime.now(timezone.utc) - self.last_timestamp).total_seconds()), ) return None @@ -177,45 +224,165 @@ def set_standby(self) -> None: self.cook_set_time = None self.cook_last_time = None self.last_timestamp = None + self.recipe = None + self._clear_preheat() + + def set_cook_stop_state(self, cook_set_time: int | None = None) -> None: + """Set the fryer state to cook stopped. + + Args: + cook_set_time (int | None): The cooking time in seconds. + """ + self._clear_preheat() + self.cook_status = AirFryerCookStatus.COOK_STOP + if cook_set_time is not None: + self.cook_set_time = cook_set_time + self.cook_last_time = self.cook_time_remaining + self.last_timestamp = None + + def set_preheat_stop_state(self, preheat_set_time: int | None = None) -> None: + """Set the fryer state to preheat stopped.""" + self.cook_status = AirFryerCookStatus.PREHEAT_STOP + if preheat_set_time is not None: + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time + ) + self.preheat_last_time = self.preheat_time_remaining + self.last_timestamp = None + + def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None: + """Set the fryer state to preheat resumed.""" + self.cook_status = AirFryerCookStatus.HEATING + if preheat_set_time is not None: + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time + ) + self.preheat_last_time = self.preheat_time_remaining + self.last_timestamp = datetime.now(timezone.utc) + + def set_cooking_state( + self, *, recipe: str, cook_set_time: int, cook_temp: int, cook_mode: str + ) -> None: + """Set the fryer state to cooking. + + Args: + recipe (str): The recipe name or cooking mode. + cook_set_time (int): The cooking time in device units. + cook_temp (int): The cooking temperature. + cook_mode (str): The cooking mode. + """ self._clear_preheat() + self.cook_status = AirFryerCookStatus.COOKING + self.recipe = recipe + self.cook_set_time = self.device.convert_time_for_state(cook_set_time) + self.cook_last_time = self.cook_set_time + self.cook_set_temp = cook_temp + self.cook_mode = cook_mode + + def set_preheating_state( + self, + *, + recipe: str, + cook_temp: int, + cook_mode: str, + preheat_set_time: int | None = None, + ) -> None: + """Set the fryer state to preheating. + + Args: + recipe (str): The recipe name or cooking mode. + preheat_set_time (int | None): The preheating time in device units. + cook_temp (int): The cooking temperature. + cook_mode (str): The cooking mode. + """ + self.cook_status = AirFryerCookStatus.HEATING + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time or 300 + ) + self.preheat_last_time = self.preheat_set_time + self.cook_set_temp = cook_temp + self.cook_mode = cook_mode + self.recipe = recipe - def set_state( # noqa: PLR0913, C901 + def set_state( # noqa: PLR0912, PLR0913, C901 self, *, - cook_status: str, + cook_status: AirFryerCookStatus, cook_time: int | None = None, cook_last_time: int | None = None, cook_temp: int | None = None, temp_unit: str | None = None, cook_mode: str | None = None, - preheat_time: int | None = None, + preheat_set_time: int | None = None, preheat_last_time: int | None = None, current_temp: int | None = None, + recipe: str | None = None, ) -> None: - """Set the cook state parameters. + """Set the cook state parameters from an API response or action. + + All parameters that are part of the current cook state must be passed. + This should be primarily used when updating from the API response to + ensure all state attributes are updated together. If updating state + from a user action, use the specific state methods like ``set_standby``, + ``set_cook_stop_state``, ``set_cooking_state``, etc. + + Time values are expected in device units and will be converted to + seconds automatically. Args: - cook_status (str): The cooking status. - cook_time (int | None): The cooking time in seconds. - cook_last_time (int | None): The last cooking time in seconds. + cook_status (AirFryerCookStatus): The cooking status. + cook_time (int | None): The cooking time in device units. + cook_last_time (int | None): The remaining cooking time in device units. cook_temp (int | None): The cooking temperature. temp_unit (str | None): The temperature units (F or C). cook_mode (str | None): The cooking mode. - preheat_time (int | None): The preheating time in seconds. - preheat_last_time (int | None): The remaining preheat time in seconds. + preheat_set_time (int | None): The preheating time in device units. + preheat_last_time (int | None): The remaining preheat time in device units. current_temp (int | None): The current temperature. + recipe (str | None): The recipe name or cooking mode. """ + # Stop/standby states delegate to helpers and return early if cook_status == AirFryerCookStatus.STANDBY: self.set_standby() return - self.preheat_set_time = preheat_time - self.preheat_last_time = preheat_last_time + if cook_status == AirFryerCookStatus.COOK_STOP: + self.set_cook_stop_state( + cook_set_time=self.device.convert_time_for_state(cook_time) + if cook_time is not None + else None + ) + return + + if cook_status == AirFryerCookStatus.PREHEAT_STOP: + self.set_preheat_stop_state(preheat_set_time=preheat_set_time) + return + + # Set cook status (setter auto-updates last_timestamp for running statuses) + self.cook_status = cook_status + + # Handle preheat attributes + if preheat_set_time is not None: + self.preheat_set_time = self.device.convert_time_for_state( + preheat_set_time + ) + self.preheat_last_time = ( + self.device.convert_time_for_state(preheat_last_time) + if preheat_last_time is not None + else self.preheat_set_time + ) + elif cook_status not in PREHEAT_STATUSES: + self._clear_preheat() - if cook_status is not None: - self.cook_status = AirFryerCookStatus(cook_status) + # Handle cook time attributes if cook_time is not None: - self.cook_set_time = cook_time + self.cook_set_time = self.device.convert_time_for_state(cook_time) + if cook_last_time is not None: + self.cook_last_time = self.device.convert_time_for_state(cook_last_time) + elif cook_status == AirFryerCookStatus.HEATING: + self.cook_last_time = None + + # Set remaining attributes if cook_temp is not None: self.cook_set_temp = cook_temp if cook_mode is not None: @@ -224,19 +391,53 @@ def set_state( # noqa: PLR0913, C901 self.current_temp = current_temp if temp_unit is not None: self.device.temp_unit = TemperatureUnits.from_string(temp_unit) - if preheat_time is not None: - self.preheat_set_time = preheat_time - if cook_last_time is not None: - self.cook_last_time = cook_last_time - if cook_status in [ - AirFryerCookStatus.COOKING, - AirFryerCookStatus.HEATING, - ]: - self.last_timestamp = int(time.time()) + if recipe is not None: + self.recipe = recipe class VeSyncFryer(VeSyncBaseDevice): - """Base class for VeSync Air Fryer devices.""" + """Base class for VeSync Air Fryer devices. + + Args: + details (ResponseDeviceDetailsModel): The device details. + manager (VeSync): The VeSync manager. + feature_map (AirFryerMap): The feature map for the device. + + Attributes: + state (FryerState): Device state object. For single chamber fryers, + this is set to ``state_chamber_1``. + 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. + latest_firm_version (str | None): Latest firmware version of device. + device_region (str): Region of device. (US, EU, etc.) + pid (str): Product ID of device, pulled by some devices on update. + sub_device_no (int): Sub-device number of device. + product_type (str): Product type of device. + features (dict): Features of device. + status_map (MappingProxyType): Mapping of API status strings to + AirFryerCookStatus enums. + cook_modes (dict[str, str]): The available cooking modes and their API values. + default_preset (AirFryerPresetRecipe): The default preset recipe for the fryer. + min_temp_f (int): The minimum temperature in Fahrenheit. + max_temp_f (int): The maximum temperature in Fahrenheit. + min_temp_c (int): The minimum temperature in Celsius. + max_temp_c (int): The maximum temperature in Celsius. + state_chamber_1 (FryerState): The state object for chamber 1. + state_chamber_2 (FryerState): The state object for chamber 2 (if dual chamber). + sync_chambers (bool): Whether the chambers are synced for cooking. + temperature_interval (int): The available temperature step interval. + time_units (TimeUnits): The time units used by the device (seconds or minutes). + """ __slots__ = ( '_temp_unit', @@ -248,6 +449,7 @@ class VeSyncFryer(VeSyncBaseDevice): 'min_temp_f', 'state_chamber_1', 'state_chamber_2', + 'status_map', 'sync_chambers', 'temperature_interval', 'time_units', @@ -259,19 +461,10 @@ def __init__( manager: VeSync, feature_map: AirFryerMap, ) -> None: - """Initialize VeSyncFryer. - - Args: - details (ResponseDeviceDetailsModel): The device details. - manager (VeSync): The VeSync manager. - feature_map (AirFryerMap): The feature map for the device. - - Note: - This is a bare class as there is only one supported air fryer model. - """ + """Initialize VeSyncFryer.""" super().__init__(details, manager, feature_map) self.cook_modes: dict[str, str] = feature_map.cook_modes - self.pid: str | None = AIRFRYER_PID_MAP.get(details.deviceType, None) + self.pid: str | None = AIRFRYER_PID_MAP.get(details.configModule, None) self.default_preset: AirFryerPresetRecipe = feature_map.default_preset self.state_chamber_1: FryerState = FryerState(self, details, feature_map) self.state_chamber_2: FryerState = FryerState(self, details, feature_map) @@ -282,6 +475,7 @@ def __init__( self.max_temp_c: int = feature_map.temperature_range_c[1] self.temperature_interval: int = feature_map.temperature_step_f self.time_units: TimeUnits = feature_map.time_units + self.status_map = feature_map.status_map # attempt to set temp unit from country code before first update self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS @@ -334,7 +528,7 @@ def round_temperature(self, temperature: int) -> int: step = self.temperature_interval * 5 / 9 return int(round(temperature / step) * step) - def convert_time(self, time_in_seconds: int) -> int: + def convert_time_for_api(self, time_in_seconds: int) -> int: """Convert time in seconds to the device's time units. Args: @@ -347,6 +541,19 @@ def convert_time(self, time_in_seconds: int) -> int: return int(time_in_seconds / 60) return time_in_seconds + def convert_time_for_state(self, time_in_device_units: int) -> int: + """Convert time in device's time units to seconds. + + Args: + time_in_device_units (int): The time in device's time units. + + Returns: + int: The time converted to seconds. + """ + if self.time_units == TimeUnits.MINUTES: + return time_in_device_units * 60 + return time_in_device_units + async def end(self, chamber: int = 1) -> bool: """End the current cooking or preheating session. @@ -420,6 +627,10 @@ async def set_mode_from_recipe( Returns: bool: True if the command was successful, False otherwise. + + Note: + See [AirFryerPresetRecipe][pyvesync.const.AirFryerPresetRecipe] for + more details on how to create a preset recipe. """ del recipe logger.warning( diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index 8d04c20..09b2a7e 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -892,16 +892,37 @@ class AirFryerCookStatus(StrEnum): COOK_STOP = 'cookStop' COOK_END = 'cookEnd' PULL_OUT = 'pullOut' - PAUSED = 'paused' COMPLETED = 'completed' HEATING = 'heating' - STOPPED = 'stopped' UNKNOWN = 'unknown' STANDBY = 'standby' PREHEAT_END = 'preheatEnd' PREHEAT_STOP = 'preheatStop' +PREHEAT_STATUSES = [ + AirFryerCookStatus.PREHEAT_END, + AirFryerCookStatus.PREHEAT_STOP, + AirFryerCookStatus.HEATING, +] + +COOK_STATUSES = [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.COOK_STOP, + AirFryerCookStatus.COOK_END, +] + +RESUMABLE_STATUSES = [ + AirFryerCookStatus.COOK_STOP, + AirFryerCookStatus.PREHEAT_STOP, +] + +RUNNING_STATUSES = [ + AirFryerCookStatus.COOKING, + AirFryerCookStatus.HEATING, +] + + # Thermostat Constants diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index ac94ba3..b68b607 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -61,11 +61,12 @@ from dataclasses import dataclass, field from itertools import chain -from types import ModuleType +from types import MappingProxyType, ModuleType from typing import Union from pyvesync.const import ( AirFryerCookModes, + AirFryerCookStatus, AirFryerFeatures, AirFryerPresetRecipe, AirFryerPresets, @@ -342,9 +343,14 @@ class AirFryerMap(DeviceMapTemplate): product_line: str = ProductLines.WIFI_KITCHEN product_type: str = ProductTypes.AIR_FRYER module: ModuleType = vesynckitchen - default_preset: AirFryerPresetRecipe = AirFryerPresets.custom + default_preset: AirFryerPresetRecipe = field( + default_factory=lambda: AirFryerPresets.custom + ) cook_modes: dict[str, str] = field(default_factory=dict) default_cook_mode: str = AirFryerCookModes.AIRFRY + status_map: MappingProxyType[str, AirFryerCookStatus] = field( + default_factory=lambda: MappingProxyType({}) + ) @dataclass(kw_only=True) @@ -1101,6 +1107,14 @@ class ThermostatMap(DeviceMapTemplate): default_preset=AirFryerPresets.custom, default_cook_mode=AirFryerCookModes.CUSTOM, time_units=TimeUnits.MINUTES, + status_map=MappingProxyType({ + 'heating': AirFryerCookStatus.HEATING, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'heatStop': AirFryerCookStatus.PREHEAT_STOP, + 'heatEnd': AirFryerCookStatus.PREHEAT_END, + 'standby': AirFryerCookStatus.STANDBY, + }) ), AirFryerMap( class_name='VeSyncTurboBlazeFryer', @@ -1120,6 +1134,14 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(90, 450), temperature_range_c=(30, 230), + status_map=MappingProxyType({ + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'heating': AirFryerCookStatus.HEATING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'cookEnd': AirFryerCookStatus.COOK_END, + }), ), ] """List of ['AirFryerMap'][pyvesync.device_map.AirFryerMap] configuration diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index 98fd6a2..b631d44 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -18,19 +18,20 @@ import logging from dataclasses import replace -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from typing_extensions import deprecated -from pyvesync.base_devices import FryerState, VeSyncFryer +from pyvesync.base_devices import VeSyncFryer from pyvesync.const import ( AIRFRYER_PID_MAP, AirFryerCookStatus, AirFryerPresetRecipe, + ConnectionStatus, ) from pyvesync.models import fryer_models as models from pyvesync.utils.device_mixins import ( - BypassV1Mixin, + BYPASS_V1_PATH, BypassV2Mixin, process_bypassv1_result, process_bypassv2_result, @@ -45,12 +46,10 @@ from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel -T = TypeVar('T') - logger = logging.getLogger(__name__) -class VeSyncAirFryer158(BypassV1Mixin, VeSyncFryer): +class VeSyncAirFryer158(VeSyncFryer): """Cosori Air Fryer Class. Args: @@ -88,7 +87,22 @@ class VeSyncAirFryer158(BypassV1Mixin, VeSyncFryer): 'refresh_interval', ) - request_keys: tuple[str, ...] = (*BypassV1Mixin.request_keys, 'pid') + request_keys: tuple[str, ...] = ( + 'acceptLanguage', + 'appVersion', + 'phoneBrand', + 'phoneOS', + 'accountID', + 'cid', + 'configModule', + 'debugMode', + 'traceId', + 'timeZone', + 'token', + 'userCountryCode', + 'uuid', + 'pid', + ) def __init__( self, @@ -100,7 +114,6 @@ def __init__( super().__init__(details, manager, feature_map) self.features: list[str] = feature_map.features self.ready_start = True - self.state: FryerState = FryerState(self, details, feature_map) if self.config_module not in AIRFRYER_PID_MAP: msg = ( 'Report this error as an issue - ' @@ -114,6 +127,61 @@ 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_158_request( + self, + request_model: type[models.Fryer158RequestModel], + update_dict: dict | None = None, + method: str = 'bypass', + ) -> models.Fryer158RequestModel: + """Build API request body for the Bypass V1 endpoint. + + Args: + request_model (type[models.Fryer158RequestModel]): The request model to use. + update_dict (dict | None): Additional keys to add on. + method (str): The method to use in the outer body, defaults to bypass. + + Returns: + models.Fryer158RequestModel: The request body for the Bypass V1 endpoint, + the correct model is determined from the models.Fryer158RequestModel + discriminator. + """ + body = Helpers.get_defaultvalues_attributes(self.request_keys).copy() + body.update(Helpers.get_manager_attributes(self.manager, self.request_keys)) + body.update(Helpers.get_device_attributes(self, self.request_keys)) + body['method'] = method + body.update(update_dict or {}) + return request_model.from_dict(body) + + async def call_158_api( + self, + request_model: type[models.Fryer158RequestModel], + update_dict: dict | None = None, + method: str = 'bypass', + endpoint: str = 'bypass', + ) -> dict | None: + """Send Cosori 158 APIrequest. + + This uses the `_build_158_request` method to send API requests + to the Cosori 158 API. + + Args: + request_model (type[models.Fryer158RequestModel]): The request model to use. + update_dict (dict | None): Additional keys to add on. + method (str): The method to use in the outer body. + endpoint (str | None): The last part of the url path, defaults to + `bypass`, e.g. `/cloud/v1/deviceManaged/bypass`. + + Returns: + bytes: The response from the API request. + """ + request = self._build_158_request(request_model, update_dict, method) + url_path = BYPASS_V1_PATH + endpoint + resp_dict, _ = await self.manager.async_call_api( + url_path, 'post', request, Helpers.req_header_bypass() + ) + + return resp_dict + def _build_base_request( self, cook_set_time: int, recipe: AirFryerPresetRecipe | None = None ) -> dict[str, int | str | bool]: @@ -154,7 +222,7 @@ def _build_cook_request( cook_mode = self._build_base_request(cook_time, recipe) cook_mode['appointmentTs'] = 0 cook_mode['cookSetTemp'] = cook_temp - cook_mode['cookStatus'] = AirFryerCookStatus.COOKING.value + cook_mode['cookStatus'] = self.status_map[AirFryerCookStatus.COOKING] return cook_mode def _build_preheat_request( @@ -167,12 +235,50 @@ def _build_preheat_request( preheat_mode = self._build_base_request(cook_time, recipe) preheat_mode['targetTemp'] = cook_temp preheat_mode['preheatSetTime'] = cook_time - preheat_mode['preheatStatus'] = AirFryerCookStatus.HEATING.value + preheat_mode['preheatStatus'] = self.status_map[AirFryerCookStatus.HEATING] return preheat_mode +# "jsonCmd": { +# "preheat": { +# "tempUnit": "fahrenheit", +# "accountId": "1221391", +# "mode": "steak", +# "recipeType": 3, +# "readyStart": false, +# "preheatStatus": "heating", +# "recipeId": 2, +# "customRecipe": "Steak", +# "cookSetTime": 10, +# "preheatSetTime": 5, +# "targetTemp": 400 +# } +# }, +# "cookMode": { +# "cookSetTime": 15, +# "cookSetTemp": 350, +# "appointmentTs": 0, +# "recipeId": 1, +# "readyStart": false, +# "recipeType": 3, +# "customRecipe": "Manual", +# "mode": "custom", +# "accountId": "1221391", +# "cookStatus": "cooking", +# "tempUnit": "fahrenheit" +# } + async def get_details(self) -> None: cmd = {'getStatus': 'status'} - resp = await self.call_bypassv1_api(models.Fryer158RequestModel, update_dict=cmd) + jsoncmd = {'jsonCmd': cmd} + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=jsoncmd) + + if not isinstance(resp, dict) or 'result' not in resp: + logger.debug( + 'Invalid response for get_details for %s: %s', self.device_name, resp + ) + self.state.connection_status = ConnectionStatus.OFFLINE + self.state.set_standby() + return None resp_model = process_bypassv1_result( self, @@ -190,23 +296,32 @@ async def get_details(self) -> None: return None return_status = resp_model.returnStatus + if return_status.cookStatus not in self.status_map: + logger.warning( + 'Unknown cook status %s for %s', + return_status.cookStatus, + self.device_name, + ) + self.state.set_standby() + return None return self.state.set_state( - cook_status=return_status.cookStatus, + cook_status=self.status_map[return_status.cookStatus], cook_time=return_status.cookSetTime, cook_last_time=return_status.cookLastTime, cook_temp=return_status.cookSetTemp, temp_unit=return_status.tempUnit, cook_mode=return_status.mode, - preheat_time=return_status.preheatSetTime, + preheat_set_time=return_status.preheatSetTime, preheat_last_time=return_status.preheatLastTime, current_temp=return_status.currentTemp, + recipe=return_status.customRecipe, ) async def end(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_cook_mode is True: cmd = {'cookMode': {'cookStatus': 'end'}} - if self.state.is_in_preheat_mode is True: + elif self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'end'}} else: logger.debug( @@ -214,7 +329,7 @@ async def end(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'end', self, resp) @@ -227,7 +342,7 @@ async def stop(self, chamber: int = 1) -> bool: del chamber # chamber not used for this air fryer if self.state.is_in_preheat_mode is True: cmd = {'preheat': {'preheatStatus': 'stop'}} - if self.state.is_in_cook_mode is True: + elif self.state.is_in_cook_mode is True: cmd = {'cookMode': {'cookStatus': 'stop'}} else: logger.debug( @@ -235,16 +350,16 @@ async def stop(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'stop', self, resp) if r is None: return False if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.PREHEAT_STOP - if self.state.is_in_cook_mode is True: - self.state.cook_status = AirFryerCookStatus.COOK_STOP + self.state.set_preheat_stop_state() + elif self.state.is_in_cook_mode is True: + self.state.set_cook_stop_state() return True async def resume(self, chamber: int = 1) -> bool: @@ -259,7 +374,7 @@ async def resume(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'resume', self, resp) @@ -267,8 +382,8 @@ async def resume(self, chamber: int = 1) -> bool: return False if self.state.is_in_preheat_mode is True: - self.state.cook_status = AirFryerCookStatus.HEATING - if self.state.is_in_cook_mode is True: + self.state.set_preheat_resume_state() + elif self.state.is_in_cook_mode is True: self.state.cook_status = AirFryerCookStatus.COOKING return True @@ -292,7 +407,7 @@ async def set_mode_from_recipe( ) cmd = {'cookMode': cook_req} json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) @@ -301,9 +416,11 @@ async def set_mode_from_recipe( self.state.set_state( cook_status=cook_status, cook_time=recipe.cook_time, + cook_last_time=recipe.cook_time, cook_temp=recipe.target_temp, cook_mode=recipe.cook_mode, - preheat_time=recipe.preheat_time, + preheat_set_time=recipe.preheat_time, + preheat_last_time=recipe.preheat_time, ) return True @@ -320,14 +437,14 @@ async def set_mode( logger.warning('Invalid cook temperature for %s', self.device_name) return False cook_temp = self.round_temperature(cook_temp) - cook_time = self.convert_time(cook_time) + cook_time = self.convert_time_for_api(cook_time) preset_recipe = replace(self.default_preset) preset_recipe.cook_time = cook_time preset_recipe.target_temp = cook_temp if cook_mode is not None: preset_recipe.cook_mode = cook_mode if preheat_time is not None: - preset_recipe.preheat_time = self.convert_time(preheat_time) + preset_recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(preset_recipe, chamber=chamber) async def cook_from_preheat(self, chamber: int = 1) -> bool: @@ -339,11 +456,12 @@ async def cook_from_preheat(self, chamber: int = 1) -> bool: 'cookMode': { 'mode': self.state.cook_mode, 'accountId': self.manager.account_id, - 'cookStatus': 'cooking', + 'cookStatus': AirFryerCookStatus.COOKING.value, + 'tempUnit': self.temp_unit.label, } } json_cmd = {'jsonCmd': cmd} - resp = await self.call_bypassv1_api( + resp = await self.call_158_api( models.Fryer158RequestModel, update_dict=json_cmd ) r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) @@ -358,18 +476,6 @@ class VeSyncTurboBlazeFryer(BypassV2Mixin, VeSyncFryer): __slots__ = () - def __init__( - self, - details: ResponseDeviceDetailsModel, - manager: VeSync, - feature_map: AirFryerMap, - ) -> None: - """Init the VeSync TurboBlaze Air Fryer class.""" - super().__init__(details, manager, feature_map) - - # Single chamber fryer state - self.state: FryerState = FryerState(self, details, feature_map) - def _build_cook_request( self, recipe: AirFryerPresetRecipe ) -> models.FryerTurboBlazeRequestData: @@ -411,15 +517,23 @@ async def get_details(self) -> None: return cook_step = resp_model.stepArray[resp_model.stepIndex] + if resp_model.cookStatus not in self.status_map: + logger.warning( + 'Unknown cook status %s for %s', + resp_model.cookStatus, + self.device_name, + ) + self.state.set_standby() + return self.state.set_state( - cook_status=resp_model.cookStatus, + cook_status=self.status_map[resp_model.cookStatus], cook_time=cook_step.cookSetTime, cook_last_time=cook_step.cookLastTime, cook_temp=cook_step.cookTemp, temp_unit=resp_model.tempUnit, cook_mode=cook_step.mode, - preheat_time=resp_model.preheatSetTime, + preheat_set_time=resp_model.preheatSetTime, preheat_last_time=resp_model.preheatLastTime, current_temp=resp_model.currentTemp, ) @@ -451,7 +565,7 @@ async def set_mode_from_recipe(self, recipe: AirFryerPresetRecipe) -> bool: cook_last_time=recipe.cook_time, cook_temp=recipe.target_temp, cook_mode=recipe.cook_mode, - preheat_time=recipe.preheat_time if recipe.preheat_time else None, + preheat_set_time=recipe.preheat_time if recipe.preheat_time else None, preheat_last_time=recipe.preheat_time if recipe.preheat_time else None, ) return True @@ -466,8 +580,8 @@ async def set_mode( ) -> bool: del chamber # chamber not used for this air fryer recipe = replace(self.default_preset) - recipe.cook_time = self.convert_time(cook_time) + recipe.cook_time = self.convert_time_for_api(cook_time) recipe.target_temp = self.round_temperature(cook_temp) if preheat_time is not None: - recipe.preheat_time = self.convert_time(preheat_time) + recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(recipe) diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index ab0c966..2bc9e2f 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -5,30 +5,53 @@ from dataclasses import dataclass, field from typing import Annotated +from mashumaro.exceptions import MissingField from mashumaro.types import Discriminator from pyvesync.models.base_models import RequestBaseModel, ResponseBaseModel from pyvesync.models.bypass_models import ( BypassV1Result, BypassV2InnerResult, - RequestBypassV1, ) @dataclass -class Fryer158RequestModel(RequestBypassV1): +class Fryer158RequestModel(RequestBaseModel): """Request model for air fryer commands.""" - pid: str # type: ignore[misc] # bug in mypy invalid argument ordering - jsonCmd: dict # type: ignore[misc] # bug in mypy invalid argument ordering - deviceId: str = field(default_factory=str) - configModel: str = field(default_factory=lambda: '') - - def __post_serialize__(self, d: dict) -> dict: - """Remove empty strings before serialization.""" - for attrs in ['deviceId', 'configModel']: - d.pop(attrs, None) - return d + acceptLanguage: str + accountID: str + appVersion: str + cid: str + configModule: str + debugMode: bool + method: str + phoneBrand: str + phoneOS: str + traceId: str + timeZone: str + token: str + userCountryCode: str + uuid: str + pid: str = field(default_factory=str) + jsonCmd: dict = field(default_factory=dict) + # deviceId: str = field(default_factory=str) + # configModel: str = field(default_factory=lambda: '') + + @classmethod + def __post_deserialize__(cls, obj: Fryer158RequestModel) -> Fryer158RequestModel: # type: ignore[reportIncompatibleMethodOverride] + """Validate required fields after deserialization.""" + if not obj.pid: + raise MissingField('pid', str, Fryer158RequestModel) + if not obj.jsonCmd: + raise MissingField('jsonCmd', dict, Fryer158RequestModel) + return obj + + # def __post_serialize__(self, d: dict) -> dict: + # """Remove empty strings before serialization.""" + # for attrs in ['deviceId', 'configModel']: + # d.pop(attrs, None) + # return d @dataclass @@ -52,6 +75,7 @@ class Fryer158CookingReturnStatus(ResponseBaseModel): preheatLastTime: int | None = None preheatSetTime: int | None = None targetTemp: int | None = None + customRecipe: str | None = None @dataclass diff --git a/src/tests/call_json.py b/src/tests/call_json.py index cb9d6a6..52f31cf 100644 --- a/src/tests/call_json.py +++ b/src/tests/call_json.py @@ -1,5 +1,6 @@ import copy from typing import Any +import call_json_fryers import pyvesync.const as const from defaults import TestDefaults from pyvesync.device_map import DeviceMapTemplate @@ -26,7 +27,8 @@ *call_json_outlets.outlet_modules, *call_json_switches.switch_modules, *call_json_humidifiers.humidifier_modules, - *call_json_purifiers.purifier_modules + *call_json_purifiers.purifier_modules, + *call_json_fryers.air_fryer_modules ] ALL_DEVICE_MAP_DICT: dict[str, DeviceMapTemplate] = { @@ -384,6 +386,8 @@ def device_list_item(cls, module: DeviceMapTemplate): model_dict['deviceType'] = module.dev_types[0] if module.setup_entry == 'ESO15-TB': model_dict['subDeviceNo'] = 1 + if 'CS137-AF/CS158-AF' in module.dev_types: + model_dict['configModule'] = 'WiFi_SKA_AirFryer158_US' return model_dict @classmethod diff --git a/src/tests/call_json_air_fryers.py b/src/tests/call_json_fryers.py similarity index 63% rename from src/tests/call_json_air_fryers.py rename to src/tests/call_json_fryers.py index db1a51e..860367c 100644 --- a/src/tests/call_json_air_fryers.py +++ b/src/tests/call_json_fryers.py @@ -37,8 +37,9 @@ def status_response(request_body=None): from copy import deepcopy from pyvesync.device_map import air_fryer_modules from pyvesync.const import ( - DeviceStatus, - ConnectionStatus, + # DeviceStatus, + # ConnectionStatus, + AirFryerPresetRecipe, TemperatureUnits, AirFryerCookStatus, ) @@ -46,29 +47,48 @@ def status_response(request_body=None): TestDefaults, FunctionResponsesV2, FunctionResponsesV1, - build_bypass_v1_response, - build_bypass_v2_response, ) +FRYERS = [m.setup_entry for m in air_fryer_modules] +FRYERS_NUM = len(FRYERS) + class AirFryerDefaults: temp_unit = TemperatureUnits.FAHRENHEIT - cook_time_f = 10 + cook_time_m = 10 + cook_time_s = cook_time_m * 60 + cook_last_time_m = cook_time_m - 2 + cook_last_time_s = cook_last_time_m * 60 + preheat_time_m = 5 + preheat_time_s = preheat_time_m * 60 cook_temp_f = 350 + cook_temp_c = 175 cook_mode = "custom" current_temp_f = 150 cook_status = AirFryerCookStatus.COOKING recipe = "Manual" +DC601_RECIPE = AirFryerPresetRecipe( + recipe_name="Air Fry", + cook_mode="AirFry", + recipe_id=14, + recipe_type=3, + target_temp=AirFryerDefaults.cook_temp_f, + temp_unit=AirFryerDefaults.temp_unit, + cook_time=AirFryerDefaults.cook_time_s, + preheat_time=AirFryerDefaults.preheat_time_s, +) + + AIR_FRYER_COOKING_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { "CS158-AF": { "returnStatus": { "curentTemp": AirFryerDefaults.current_temp_f, "cookSetTemp": AirFryerDefaults.cook_temp_f, "mode": AirFryerDefaults.cook_mode, - "cookSetTime": AirFryerDefaults.cook_time_f, - "cookLastTime": AirFryerDefaults.cook_time_f - 2, + "cookSetTime": AirFryerDefaults.cook_time_m, # Minutes + "cookLastTime": AirFryerDefaults.cook_last_time_m, # Minutes "cookStatus": AirFryerDefaults.cook_status.value, "tempUnit": AirFryerDefaults.temp_unit.label, "accountId": TestDefaults.account_id, @@ -81,21 +101,21 @@ class AirFryerDefaults: "result": { "stepArray": [ { - "cookSetTime": 1200, - "cookTemp": 330, - "mode": "Bake", - "cookLastTime": 1176, + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": DC601_RECIPE.cook_mode, + "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds "shakeTime": 0, "cookEndTime": 0, - "recipeName": "Bake", - "recipeId": 9, - "recipeType": 3, + "recipeName": DC601_RECIPE.recipe_name, + "recipeId": DC601_RECIPE.recipe_id, + "recipeType": DC601_RECIPE.recipe_type, } ], "cookMode": "normal", - "tempUnit": "f", + "tempUnit": AirFryerDefaults.temp_unit.label, "stepIndex": 0, - "cookStatus": "cooking", + "cookStatus": AirFryerDefaults.cook_status.value, "preheatSetTime": 0, "preheatLastTime": 0, "preheatEndTime": 0, @@ -112,7 +132,7 @@ class AirFryerDefaults: "CAF-DC601S": { "stepArray": [], "cookMode": "normal", - "tempUnit": "f", + "tempUnit": AirFryerDefaults.temp_unit.label, "stepIndex": 0, "cookStatus": "standby", "preheatSetTime": 0, @@ -126,3 +146,21 @@ class AirFryerDefaults: }, "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, } + + +METHOD_RESPONSES = { + 'CS158-AF': deepcopy(FunctionResponsesV1), + 'CAF-DC601S': deepcopy(FunctionResponsesV2), +} + + +DETAILS_RESPONSES_COOKING = { + "CS158-AF": deepcopy(AIR_FRYER_COOKING_DETAILS['CS158-AF']), + "CAF-DC601S": deepcopy(AIR_FRYER_COOKING_DETAILS['CAF-DC601S']), +} + + +DETAILS_RESPONSES_STANDBY = { + "CS158-AF": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CS158-AF']), + "CAF-DC601S": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CAF-DC601S']), +} diff --git a/src/tests/test_fryers.py b/src/tests/test_fryers.py new file mode 100644 index 0000000..338873e --- /dev/null +++ b/src/tests/test_fryers.py @@ -0,0 +1,268 @@ +""" +This tests requests for FANS (not fryers or humidifiers). + +All tests inherit from the TestBase class which contains the fixtures +and methods needed to run the tests. + +The tests are automatically parametrized by `pytest_generate_tests` in +conftest.py. The two methods that are parametrized are `test_details` +and `test_methods`. The class variables are used to build the list of +devices, test methods and arguments. + +The `helpers.call_api` method is patched to return a mock response. +The method, endpoint, headers and json arguments are recorded +in YAML files in the api directory, categorized in folders by +module and files by the class name. + +The default is to record requests that do not exist and compare requests +that already exist. If the API changes, set the overwrite argument to True +in order to overwrite the existing YAML file with the new request. + +See Also +-------- +`utils.TestBase` - Base class for all tests, containing mock objects +`confest.pytest_generate_tests` - Parametrizes tests based on + method names & class attributes +`call_json_fryers` - Contains API responses +""" + +import logging +import pytest +from dataclasses import asdict +import pyvesync.const as const +from pyvesync.base_devices.fryer_base import VeSyncFryer +from base_test_cases import TestBase +from utils import assert_test, parse_args +from defaults import TestDefaults +import call_json_fryers + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +DETAILS_PARAMS_STANDBY = [ + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CS158-AF", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CS158-AF"], + id="CS158-AF.update.standby", + ), + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CAF-DC601S", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-DC601S"], + id="CAF-DC601S.update.standby", + ), +] + +DETAILS_PARAMS_COOKING = [ + pytest.param( + const.AirFryerCookStatus.COOKING, + "CS158-AF", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CS158-AF"], + id="CS158-AF.update.cooking", + ), + pytest.param( + const.AirFryerCookStatus.COOKING, + "CAF-DC601S", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-DC601S"], + id="CAF-DC601S.update.cooking", + ), +] + + +class TestFryers(TestBase): + """Fryer testing class. + + This class tests Fryer product details and methods. The methods are + parametrized from the class variables using `pytest_generate_tests`. + The call_json_fryers module contains the responses for the API requests. + The device is instantiated from the details provided by + `call_json_fryers.DeviceList.device_list_item()`. Inherits from `utils.TestBase`. + + Instance Attributes + ------------------- + self.manager : VeSync + Instantiated VeSync object + self.mock_api : Mock + Mock with patched `helpers.call_api` method + self.caplog : LogCaptureFixture + Pytest fixture for capturing logs + + Class Variables + --------------- + device : str + Name of product class - fryers + fryers : list + List of setup_entry's for fryers, this variable is named + after the device variable value + base_methods : List[List[str, Dict[str, Any]]] + List of common methods for all devices + device_methods : Dict[List[List[str, Dict[str, Any]]]] + Dictionary of methods specific to device types + + Methods + -------- + test_details() + Test the device details API request and response + test_methods() + Test device methods API request and response + + Examples + -------- + >>> device = 'fryers' + >>> fryers = call_json_fryers.FRYER_MODELS + >>> base_methods = [['turn_on'], ['turn_off'], ['update']] + >>> device_methods = { + 'ESWD16': [['method1'], ['method2', {'kwargs': 'value'}]] + } + + """ + + device = 'fryers' + fryers = call_json_fryers.FRYERS + base_methods = [] # type: ignore + device_methods = { # type: ignore + "CS158-AF": [], + "CAF-DC601S": [], + } + + @pytest.mark.parametrize( + "cook_status, setup_entry, method, response_dict", + DETAILS_PARAMS_STANDBY + DETAILS_PARAMS_COOKING, + ) + def test_details_fryers(self, cook_status: const.AirFryerCookStatus, setup_entry: str, method: str, response_dict: dict): + """Test the device details API request and response. + + This method is automatically parametrized by `pytest_generate_tests` + based on class variables `device` (name of product class - fryers), + device name (fryers) list of setup_entry's. + + Example: + >>> device = 'fryers' + >>> fryers = call_json_fryers.FRYERS + + See Also + -------- + `utils.TestBase` class docstring + `call_json_fryers` module docstring + + Notes + ------ + The device is instantiated using the `call_json.DeviceList.device_list_item()` + method. The device details contain the default values set in `utils.Defaults` + """ + # Set return value for call_api based on call_json_fan.DETAILS_RESPONSES + return_dict = response_dict + self.mock_api.return_value = (return_dict, 200) + + # Instantiate device from device list return item + fryer_obj = self.get_device("air_fryers", setup_entry) + assert isinstance(fryer_obj, VeSyncFryer) + + method_call = getattr(fryer_obj, method) + self.run_in_loop(method_call) + + # Parse mock_api args tuple from arg, kwargs to kwargs + all_kwargs = parse_args(self.mock_api) + + if cook_status == const.AirFryerCookStatus.STANDBY: + assert fryer_obj.state_chamber_1.cook_status == cook_status + assert fryer_obj.state_chamber_1.cook_set_temp is None + assert fryer_obj.state_chamber_1.cook_set_time is None + assert fryer_obj.state_chamber_1.cook_mode is None + assert fryer_obj.state_chamber_1.current_temp is None + assert fryer_obj.state_chamber_1.preheat_last_time is None + assert fryer_obj.state_chamber_1.cook_last_time is None + assert fryer_obj.state_chamber_1.last_timestamp is None + if const.AirFryerFeatures.DUAL_CHAMBER in fryer_obj.features: + assert fryer_obj.state_chamber_2.cook_status == cook_status + assert fryer_obj.state_chamber_2.cook_set_temp is None + assert fryer_obj.state_chamber_2.cook_set_time is None + assert fryer_obj.state_chamber_2.cook_mode is None + assert fryer_obj.state_chamber_2.current_temp is None + assert fryer_obj.state_chamber_2.preheat_last_time is None + assert fryer_obj.state_chamber_2.cook_last_time is None + assert fryer_obj.state_chamber_2.last_timestamp is None + + # Assert request matches recorded request or write new records + assert assert_test( + method_call, all_kwargs, setup_entry, self.write_api, self.overwrite + ) + + # def test_methods(self, setup_entry, method): + # """Test device methods API request and response. + + # This method is automatically parametrized by `pytest_generate_tests` + # based on class variables `device` (name of product class - humidifiers), + # device name (humidifiers) list of setup_entry's, `base_methods` - list of + # methods for all devices, and `device_methods` - list of methods for + # each device type. + + # Example: + # >>> base_methods = [['turn_on'], ['turn_off'], ['update']] + # >>> device_methods = { + # 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] + # } + + # Notes + # ----- + # The response can be a callable that accepts the `kwargs` argument to + # sync the device response with the API response. In some cases the API + # returns data from the method call, such as `get_yearly_energy`, in other cases the + # API returns a simple confirmation the command was successful. + + # See Also + # -------- + # `TestBase` class method + # `call_json_fans` module + + # """ + # # Get method name and kwargs from method fixture + # method_name = method[0] + # if len(method) == 2 and isinstance(method[1], dict): + # method_kwargs = method[1] + # else: + # method_kwargs = {} + + # # Set return value for call_api based on call_json_fans.METHOD_RESPONSES + # method_response = call_json_fans.METHOD_RESPONSES[setup_entry][method_name] + # if callable(method_response): + # if method_kwargs: + # self.mock_api.return_value = method_response(method_kwargs), 200 + # else: + # self.mock_api.return_value = method_response(), 200 + # else: + # self.mock_api.return_value = method_response, 200 + + # # Get device configuration from call_json.DeviceList.device_list_item() + # fan_obj = self.get_device("fans", setup_entry) + # assert isinstance(fan_obj, VeSyncFanBase) + + # # Get method from device object + # method_call = getattr(fan_obj, method[0]) + + # # Ensure method runs based on device configuration + # if method[0] == 'turn_on': + # fan_obj.state.device_status = const.DeviceStatus.OFF + # elif method[0] == 'turn_off': + # fan_obj.state.device_status = const.DeviceStatus.ON + + # # Call method with kwargs if defined + # if method_kwargs: + # self.run_in_loop(method_call, **method_kwargs) + # else: + # self.run_in_loop(method_call) + + # # Parse arguments from mock_api call into a dictionary + # all_kwargs = parse_args(self.mock_api) + + # # Assert request matches recorded request or write new records + # assert assert_test( + # method_call, all_kwargs, setup_entry, self.write_api, self.overwrite + # ) From 627264bab92feb47548b13f67d7fce2c0aa2e85a Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Mon, 2 Mar 2026 22:04:11 -0500 Subject: [PATCH 12/19] Fryer Working Tests --- src/pyvesync/base_devices/fryer_base.py | 5 +- src/pyvesync/const.py | 1 + src/pyvesync/device_map.py | 31 ++ src/pyvesync/devices/vesynckitchen.py | 309 ++++++++++++++++++-- src/pyvesync/models/fryer_models.py | 57 ++++ src/tests/api/vesynckitchen/CAF-DC601S.yaml | 26 ++ src/tests/api/vesynckitchen/CAF-TF101S.yaml | 26 ++ src/tests/api/vesynckitchen/CS158-AF.yaml | 24 ++ src/tests/call_json_fryers.py | 144 ++++++--- src/tests/test_fryers.py | 101 ++----- 10 files changed, 579 insertions(+), 145 deletions(-) create mode 100644 src/tests/api/vesynckitchen/CAF-DC601S.yaml create mode 100644 src/tests/api/vesynckitchen/CAF-TF101S.yaml create mode 100644 src/tests/api/vesynckitchen/CS158-AF.yaml diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 9c401f1..cb05637 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -482,9 +482,8 @@ def __init__( if self.manager.measure_unit and self.manager.measure_unit.lower() == 'imperial': self._temp_unit = TemperatureUnits.FAHRENHEIT - # Use single state attribute if not dual chamber fryer for compatibility - if AirFryerFeatures.DUAL_CHAMBER not in self.features: - self.state = self.state_chamber_1 + # Set state to primary chamber (chamber 1) for base class compatibility + self.state = self.state_chamber_1 @property def temp_unit(self) -> TemperatureUnits: diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index 09b2a7e..61aaa98 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -856,6 +856,7 @@ class AirFryerCookModes(StrEnum): WARM = 'warm' AIRFRY = 'airfry' DRY = 'dry' + GRILL = 'grill' PREHEAT = 'preheat' diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index b68b607..ab6e090 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1143,6 +1143,37 @@ class ThermostatMap(DeviceMapTemplate): 'cookEnd': AirFryerCookStatus.COOK_END, }), ), + AirFryerMap( + class_name='VeSyncDualAirFryer', + module=vesynckitchen, + dev_types=['CAF-TF101S-AEU'], + setup_entry='CAF-TF101S', + device_alias='Dual Air Fryer', + model_display='CAF-TF101S Series', + model_name='Cosori Dual Air Fryer', + temperature_step_f=5, + features=[AirFryerFeatures.DUAL_CHAMBER], + cook_modes={ + AirFryerCookModes.AIRFRY: 'AirFry', + AirFryerCookModes.BAKE: 'Bake', + AirFryerCookModes.ROAST: 'Roast', + AirFryerCookModes.GRILL: 'Grill', + AirFryerCookModes.DRY: 'Dry', + AirFryerCookModes.REHEAT: 'Reheat', + }, + default_cook_mode=AirFryerCookModes.AIRFRY, + default_preset=AirFryerPresets.air_fry, + time_units=TimeUnits.SECONDS, + temperature_range_f=(130, 450), + temperature_range_c=(55, 240), + status_map=MappingProxyType({ + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + }), + ), ] """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 b631d44..d35a78f 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: from pyvesync import VeSync + from pyvesync.base_devices.fryer_base import FryerState from pyvesync.device_map import AirFryerMap from pyvesync.models.vesync_models import ResponseDeviceDetailsModel @@ -238,35 +239,6 @@ def _build_preheat_request( preheat_mode['preheatStatus'] = self.status_map[AirFryerCookStatus.HEATING] return preheat_mode -# "jsonCmd": { -# "preheat": { -# "tempUnit": "fahrenheit", -# "accountId": "1221391", -# "mode": "steak", -# "recipeType": 3, -# "readyStart": false, -# "preheatStatus": "heating", -# "recipeId": 2, -# "customRecipe": "Steak", -# "cookSetTime": 10, -# "preheatSetTime": 5, -# "targetTemp": 400 -# } -# }, -# "cookMode": { -# "cookSetTime": 15, -# "cookSetTemp": 350, -# "appointmentTs": 0, -# "recipeId": 1, -# "readyStart": false, -# "recipeType": 3, -# "customRecipe": "Manual", -# "mode": "custom", -# "accountId": "1221391", -# "cookStatus": "cooking", -# "tempUnit": "fahrenheit" -# } - async def get_details(self) -> None: cmd = {'getStatus': 'status'} jsoncmd = {'jsonCmd': cmd} @@ -585,3 +557,282 @@ async def set_mode( if preheat_time is not None: recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(recipe) + + +class VeSyncDualAirFryer(BypassV2Mixin, VeSyncFryer): + """Cosori Dual Air Fryer Class (CAF-TF101S). + + Supports dual-chamber cooking with three operating modes: + - Single chamber (left or right) + - Whole chamber (merged) + - Sync mode (both chambers with identical settings) + + The device has no preheat, pause, or resume API. Pausing is handled + physically by pulling the basket out, which triggers a ``cookStop`` + or ``pullOut`` state. Pushing the basket back in resumes cooking + automatically. + + Args: + details (ResponseDeviceDetailsModel): Device details. + manager (VeSync): Manager class. + feature_map (AirFryerMap): Device feature map. + + Attributes: + state_chamber_1 (FryerState): State for chamber 1 (left) or whole. + state_chamber_2 (FryerState): State for chamber 2 (right). + sync_chambers (bool): Whether chambers are synced for cooking. + """ + + __slots__ = () + + # workChamber API values + _WC_NONE = 0 + _WC_LEFT = 1 + _WC_RIGHT = 2 + _WC_WHOLE = 3 + _WC_SYNC = 4 + _SYNC_TYPE_SYNCED = 2 + + def _get_chamber_state(self, chamber: int) -> FryerState: + """Return the FryerState for the given chamber number. + + Args: + chamber: Chamber number (1=left, 2=right, 3=whole maps to chamber 1). + """ + if chamber == self._WC_RIGHT: + return self.state_chamber_2 + return self.state_chamber_1 + + def _get_work_chamber(self, chamber: int) -> int: + """Return the API workChamber value for the given chamber. + + Args: + chamber: Chamber number (1, 2, or 3). + + Returns: + API workChamber value (1, 2, 3, or 4 for sync). + """ + if self.sync_chambers: + return self._WC_SYNC + return chamber + + def _get_sync_type(self) -> int: + """Return the API syncType value.""" + return self._SYNC_TYPE_SYNCED if self.sync_chambers else 0 + + async def get_details(self) -> None: + """Get device details from the API. + + Polls ``getAirfryerMultiStatus`` and updates state for each chamber. + """ + resp = await self.call_bypassv2_api( + payload_method='getAirfryerMultiStatus', + ) + resp_model = process_bypassv2_result( + self, + logger, + 'get_details', + resp, + models.FryerDualMultiStatusResult, + ) + + if resp_model is None: + self.state_chamber_1.set_standby() + self.state_chamber_2.set_standby() + return + + # Update sync state from API response + self.sync_chambers = resp_model.syncType == self._SYNC_TYPE_SYNCED + self.state_chamber_1.sync_chambers = self.sync_chambers + self.state_chamber_2.sync_chambers = self.sync_chambers + + # Track which chambers were updated + updated_chambers: set[int] = set() + + for status_item in resp_model.statusList: + ch_num = status_item.chamber + updated_chambers.add(ch_num) + chamber_state = self._get_chamber_state(ch_num) + + if ( + status_item.cookStatus not in self.status_map + or status_item.cookStatus == AirFryerCookStatus.STANDBY.value + ): + chamber_state.set_standby() + continue + + chamber_state.set_state( + cook_status=self.status_map[status_item.cookStatus], + cook_time=status_item.cookSetTime, + cook_last_time=status_item.currentRemainingTime, + cook_temp=status_item.cookTemp, + temp_unit=resp_model.tempUnit, + cook_mode=status_item.mode, + recipe=status_item.recipeName or None, + ) + + # Set any chambers not in the response to standby + if ( + self._WC_LEFT not in updated_chambers + and self._WC_WHOLE not in updated_chambers + ): + self.state_chamber_1.set_standby() + if ( + self._WC_RIGHT not in updated_chambers + and self._WC_WHOLE not in updated_chambers + ): + self.state_chamber_2.set_standby() + + async def end(self, chamber: int = 1) -> bool: + """End the current cooking session. + + Args: + chamber: Chamber to end (1=left, 2=right, 3=whole). + If sync mode is active, ends both chambers. + + Returns: + True if the command was successful. + """ + api_chamber = self._get_work_chamber(chamber) + resp = await self.call_bypassv2_api( + payload_method='endCook', + data={'chamber': api_chamber}, + ) + r = Helpers.process_dev_response(logger, 'end', self, resp) + if r is None: + return False + + if self.sync_chambers or api_chamber == self._WC_SYNC: + self.state_chamber_1.set_standby() + self.state_chamber_2.set_standby() + self.sync_chambers = False + elif chamber == self._WC_WHOLE: + self.state_chamber_1.set_standby() + else: + self._get_chamber_state(chamber).set_standby() + return True + + def _build_cook_configs( + self, + recipe: AirFryerPresetRecipe, + chamber: int, + ) -> list[models.FryerDualCookConfig]: + """Build cookConfigs list for startMultiCook request. + + Args: + recipe: The recipe to cook. + chamber: Chamber number (1, 2, or 3 for whole). + + Returns: + List of FryerDualCookConfig for the API request. + """ + config_dict = { + 'cookSetTime': recipe.cook_time, + 'cookTemp': recipe.target_temp, + 'mode': recipe.cook_mode, + 'recipeId': recipe.recipe_id, + 'recipeName': recipe.recipe_name, + 'recipeType': recipe.recipe_type, + } + + if self.sync_chambers: + return [ + models.FryerDualCookConfig.from_dict( + {**config_dict, 'chamber': 1} + ), + models.FryerDualCookConfig.from_dict( + {**config_dict, 'chamber': 2} + ), + ] + return [ + models.FryerDualCookConfig.from_dict( + {**config_dict, 'chamber': chamber} + ), + ] + + async def set_mode_from_recipe( + self, + recipe: AirFryerPresetRecipe, + *, + chamber: int = 1, + ) -> bool: + """Start cooking with a preset recipe. + + Args: + recipe: The preset recipe to use. + chamber: Chamber to cook in (1=left, 2=right, 3=whole). + If ``sync_chambers`` is True, both chambers cook in sync. + + Returns: + True if the command was successful. + """ + cook_configs = self._build_cook_configs(recipe, chamber) + work_chamber = self._get_work_chamber(chamber) + sync_type = self._get_sync_type() + + start_data = models.FryerDualStartCookData.from_dict({ + 'accountId': self.manager.account_id, + 'cookConfigs': [c.to_dict() for c in cook_configs], + 'readyStart': True, + 'syncType': sync_type, + 'tempUnit': self.temp_unit.code, + 'workChamber': work_chamber, + }) + + resp = await self.call_bypassv2_api( + payload_method='startMultiCook', + data=start_data.to_dict(), + ) + r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) + if r is None: + return False + + # Update state for affected chambers + chambers = ( + [self.state_chamber_1, self.state_chamber_2] + if self.sync_chambers + else [self._get_chamber_state(chamber)] + ) + for ch_state in chambers: + ch_state.set_state( + cook_status=AirFryerCookStatus.COOKING, + cook_time=recipe.cook_time, + cook_last_time=recipe.cook_time, + cook_temp=recipe.target_temp, + cook_mode=recipe.cook_mode, + recipe=recipe.recipe_name, + ) + return True + + async def set_mode( + self, + cook_time: int, + cook_temp: int, + *, + preheat_time: int | None = None, + cook_mode: str | None = None, + chamber: int = 1, + ) -> bool: + """Set cooking mode with manual parameters. + + Args: + cook_time: Cooking time in seconds. + cook_temp: Cooking temperature. + preheat_time: Not used for this device. + cook_mode: Cooking mode string (e.g. 'AirFry'). + chamber: Chamber number (1=left, 2=right, 3=whole). + + Returns: + True if the command was successful. + """ + del preheat_time # not supported by this device + if not self.validate_temperature(cook_temp): + logger.warning('Invalid cook temperature for %s', self.device_name) + return False + cook_temp = self.round_temperature(cook_temp) + recipe = replace(self.default_preset) + recipe.cook_time = cook_time + recipe.target_temp = cook_temp + if cook_mode is not None: + recipe.cook_mode = cook_mode + return await self.set_mode_from_recipe(recipe, chamber=chamber) diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index 2bc9e2f..81c4ae6 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -218,6 +218,63 @@ class FryerTurboBlazeStartActItem(RequestBaseModel): shakeTime: int = 0 +# Dual Air Fryer Models (CAF-TF101S) + + +@dataclass +class FryerDualChamberStatusItem(ResponseBaseModel): + """Status item for a single chamber in dual air fryer status response.""" + + cookStatus: str + chamber: int + cookSetTime: int = 0 + cookTemp: int = 0 + mode: str = '' + currentRemainingTime: int = 0 + totalTimeRemaining: int = 0 + startTime: int = 0 + recipeType: int = 3 + recipeId: int = 0 + recipeName: str = '' + upc: str = '' + holdTime: int = 0 + + +@dataclass +class FryerDualMultiStatusResult(BypassV2InnerResult): + """Result model for dual air fryer getAirfryerMultiStatus response.""" + + statusList: list[FryerDualChamberStatusItem] + tempUnit: str = 'c' + syncType: int = 0 + workChamber: int = 0 + + +@dataclass +class FryerDualCookConfig(RequestBaseModel): + """Cook configuration for a single chamber in startMultiCook request.""" + + chamber: int + cookSetTime: int + cookTemp: int + mode: str + recipeId: int + recipeName: str + recipeType: int + + +@dataclass +class FryerDualStartCookData(RequestBaseModel): + """Request data for dual air fryer startMultiCook command.""" + + accountId: str + cookConfigs: list[FryerDualCookConfig] + readyStart: bool + syncType: int + tempUnit: str + workChamber: int + + # a = { # 'cookMode': { # 'accountId': '1221391', diff --git a/src/tests/api/vesynckitchen/CAF-DC601S.yaml b/src/tests/api/vesynckitchen/CAF-DC601S.yaml new file mode 100644 index 0000000..3d32bab --- /dev/null +++ b/src/tests/api/vesynckitchen/CAF-DC601S.yaml @@ -0,0 +1,26 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CAF-DC601S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: CAF-DC601S-CID + method: bypassV2 + payload: + data: {} + method: getAirfyerStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/api/vesynckitchen/CAF-TF101S.yaml b/src/tests/api/vesynckitchen/CAF-TF101S.yaml new file mode 100644 index 0000000..3ea1ad5 --- /dev/null +++ b/src/tests/api/vesynckitchen/CAF-TF101S.yaml @@ -0,0 +1,26 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CAF-TF101S-CID + configModel: ConfigModule + configModule: ConfigModule + debugMode: false + deviceId: CAF-TF101S-CID + method: bypassV2 + payload: + data: {} + method: getAirfryerMultiStatus + source: APP + phoneBrand: pyvesync + phoneOS: Android + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + method: post + url: /cloud/v2/deviceManaged/bypassV2 diff --git a/src/tests/api/vesynckitchen/CS158-AF.yaml b/src/tests/api/vesynckitchen/CS158-AF.yaml new file mode 100644 index 0000000..56ec6b5 --- /dev/null +++ b/src/tests/api/vesynckitchen/CS158-AF.yaml @@ -0,0 +1,24 @@ +update: + headers: + Content-Type: application/json; charset=UTF-8 + User-Agent: okhttp/3.12.1 + json_object: + acceptLanguage: en + accountID: sample_id + appVersion: 5.6.60 + cid: CS158-AF-CID + configModule: WiFi_SKA_AirFryer158_US + debugMode: false + jsonCmd: + getStatus: status + method: bypass + phoneBrand: pyvesync + phoneOS: Android + pid: 2cl8hmafsthl65bd + timeZone: America/New_York + token: sample_tk + traceId: TRACE_ID + userCountryCode: US + uuid: CS158-AF-UUID + method: post + url: /cloud/v1/deviceManaged/bypass diff --git a/src/tests/call_json_fryers.py b/src/tests/call_json_fryers.py index 860367c..1ca53a3 100644 --- a/src/tests/call_json_fryers.py +++ b/src/tests/call_json_fryers.py @@ -37,8 +37,6 @@ def status_response(request_body=None): from copy import deepcopy from pyvesync.device_map import air_fryer_modules from pyvesync.const import ( - # DeviceStatus, - # ConnectionStatus, AirFryerPresetRecipe, TemperatureUnits, AirFryerCookStatus, @@ -47,6 +45,8 @@ def status_response(request_body=None): TestDefaults, FunctionResponsesV2, FunctionResponsesV1, + build_bypass_v1_response, + build_bypass_v2_response, ) FRYERS = [m.setup_entry for m in air_fryer_modules] @@ -80,8 +80,19 @@ class AirFryerDefaults: preheat_time=AirFryerDefaults.preheat_time_s, ) +TF101S_RECIPE = AirFryerPresetRecipe( + recipe_name="Air Fry", + cook_mode="AirFry", + recipe_id=14, + recipe_type=3, + target_temp=AirFryerDefaults.cook_temp_f, + temp_unit=AirFryerDefaults.temp_unit, + cook_time=AirFryerDefaults.cook_time_s, +) + -AIR_FRYER_COOKING_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { +# Raw detail data for each device (V1 result or V2 inner result) +AIR_FRYER_COOKING_DETAILS: dict[str, dict] = { "CS158-AF": { "returnStatus": { "curentTemp": AirFryerDefaults.current_temp_f, @@ -96,39 +107,73 @@ class AirFryerDefaults: } }, "CAF-DC601S": { - "traceId": "1767318172645", - "code": 0, - "result": { - "stepArray": [ - { - "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds - "cookTemp": AirFryerDefaults.cook_temp_f, - "mode": DC601_RECIPE.cook_mode, - "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds - "shakeTime": 0, - "cookEndTime": 0, - "recipeName": DC601_RECIPE.recipe_name, - "recipeId": DC601_RECIPE.recipe_id, - "recipeType": DC601_RECIPE.recipe_type, - } - ], - "cookMode": "normal", - "tempUnit": AirFryerDefaults.temp_unit.label, - "stepIndex": 0, - "cookStatus": AirFryerDefaults.cook_status.value, - "preheatSetTime": 0, - "preheatLastTime": 0, - "preheatEndTime": 0, - "preheatTemp": 0, - "startTime": 1767318116, - "totalTimeRemaining": 1176, - "currentTemp": 89, - "shakeStatus": 0, - }, + "stepArray": [ + { + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": DC601_RECIPE.cook_mode, + "cookLastTime": AirFryerDefaults.cook_last_time_s, # Seconds + "shakeTime": 0, + "cookEndTime": 0, + "recipeName": DC601_RECIPE.recipe_name, + "recipeId": DC601_RECIPE.recipe_id, + "recipeType": DC601_RECIPE.recipe_type, + } + ], + "cookMode": "normal", + "tempUnit": AirFryerDefaults.temp_unit.label, + "stepIndex": 0, + "cookStatus": AirFryerDefaults.cook_status.value, + "preheatSetTime": 0, + "preheatLastTime": 0, + "preheatEndTime": 0, + "preheatTemp": 0, + "startTime": 1767318116, + "totalTimeRemaining": 1176, + "currentTemp": 89, + "shakeStatus": 0, + }, + "CAF-TF101S": { + "statusList": [ + { + "cookStatus": AirFryerDefaults.cook_status.value, + "chamber": 1, + "cookSetTime": AirFryerDefaults.cook_time_s, # Seconds + "cookTemp": AirFryerDefaults.cook_temp_f, + "mode": TF101S_RECIPE.cook_mode, + "currentRemainingTime": AirFryerDefaults.cook_last_time_s, + "totalTimeRemaining": AirFryerDefaults.cook_last_time_s, + "startTime": 1767318116, + "recipeType": TF101S_RECIPE.recipe_type, + "recipeId": TF101S_RECIPE.recipe_id, + "recipeName": TF101S_RECIPE.recipe_name, + "upc": "", + "holdTime": 0, + }, + { + "cookStatus": "standby", + "chamber": 2, + "cookSetTime": 0, + "cookTemp": 0, + "mode": "", + "currentRemainingTime": 0, + "totalTimeRemaining": 0, + "startTime": 0, + "recipeType": 3, + "recipeId": 0, + "recipeName": "", + "upc": "", + "holdTime": 0, + }, + ], + "tempUnit": AirFryerDefaults.temp_unit.label, + "syncType": 0, + "workChamber": 1, }, } -AIR_FRYER_STANDYBY_DETAILS: dict[str, dict[str, list | str | float | dict | None]] = { +AIR_FRYER_STANDBY_DETAILS: dict[str, dict] = { + "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, "CAF-DC601S": { "stepArray": [], "cookMode": "normal", @@ -144,23 +189,46 @@ class AirFryerDefaults: "currentTemp": 43, "shakeStatus": 0, }, - "CS158-AF": {"returnStatus": {"cookStatus": "standby"}}, + "CAF-TF101S": { + "statusList": [ + {"cookStatus": "standby", "chamber": 1}, + {"cookStatus": "standby", "chamber": 2}, + ], + "tempUnit": AirFryerDefaults.temp_unit.label, + "syncType": 0, + "workChamber": 0, + }, } METHOD_RESPONSES = { 'CS158-AF': deepcopy(FunctionResponsesV1), 'CAF-DC601S': deepcopy(FunctionResponsesV2), + 'CAF-TF101S': deepcopy(FunctionResponsesV2), } DETAILS_RESPONSES_COOKING = { - "CS158-AF": deepcopy(AIR_FRYER_COOKING_DETAILS['CS158-AF']), - "CAF-DC601S": deepcopy(AIR_FRYER_COOKING_DETAILS['CAF-DC601S']), + "CS158-AF": build_bypass_v1_response( + result_dict=deepcopy(AIR_FRYER_COOKING_DETAILS["CS158-AF"]), + ), + "CAF-DC601S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_COOKING_DETAILS["CAF-DC601S"]), + ), + "CAF-TF101S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_COOKING_DETAILS["CAF-TF101S"]), + ), } DETAILS_RESPONSES_STANDBY = { - "CS158-AF": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CS158-AF']), - "CAF-DC601S": deepcopy(AIR_FRYER_STANDYBY_DETAILS['CAF-DC601S']), + "CS158-AF": build_bypass_v1_response( + result_dict=deepcopy(AIR_FRYER_STANDBY_DETAILS["CS158-AF"]), + ), + "CAF-DC601S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_STANDBY_DETAILS["CAF-DC601S"]), + ), + "CAF-TF101S": build_bypass_v2_response( + inner_result=deepcopy(AIR_FRYER_STANDBY_DETAILS["CAF-TF101S"]), + ), } diff --git a/src/tests/test_fryers.py b/src/tests/test_fryers.py index 338873e..2cdb4af 100644 --- a/src/tests/test_fryers.py +++ b/src/tests/test_fryers.py @@ -1,5 +1,5 @@ """ -This tests requests for FANS (not fryers or humidifiers). +This tests requests for AIR FRYERS. All tests inherit from the TestBase class which contains the fixtures and methods needed to run the tests. @@ -28,12 +28,10 @@ import logging import pytest -from dataclasses import asdict import pyvesync.const as const from pyvesync.base_devices.fryer_base import VeSyncFryer from base_test_cases import TestBase from utils import assert_test, parse_args -from defaults import TestDefaults import call_json_fryers @@ -56,6 +54,13 @@ call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-DC601S"], id="CAF-DC601S.update.standby", ), + pytest.param( + const.AirFryerCookStatus.STANDBY, + "CAF-TF101S", + "update", + call_json_fryers.DETAILS_RESPONSES_STANDBY["CAF-TF101S"], + id="CAF-TF101S.update.standby", + ), ] DETAILS_PARAMS_COOKING = [ @@ -73,6 +78,13 @@ call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-DC601S"], id="CAF-DC601S.update.cooking", ), + pytest.param( + const.AirFryerCookStatus.COOKING, + "CAF-TF101S", + "update", + call_json_fryers.DETAILS_RESPONSES_COOKING["CAF-TF101S"], + id="CAF-TF101S.update.cooking", + ), ] @@ -130,6 +142,7 @@ class TestFryers(TestBase): device_methods = { # type: ignore "CS158-AF": [], "CAF-DC601S": [], + "CAF-TF101S": [], } @pytest.mark.parametrize( @@ -190,79 +203,17 @@ def test_details_fryers(self, cook_status: const.AirFryerCookStatus, setup_entry assert fryer_obj.state_chamber_2.cook_last_time is None assert fryer_obj.state_chamber_2.last_timestamp is None + elif cook_status == const.AirFryerCookStatus.COOKING: + assert fryer_obj.state_chamber_1.cook_status == cook_status + assert fryer_obj.state_chamber_1.cook_set_temp == call_json_fryers.AirFryerDefaults.cook_temp_f + assert fryer_obj.state_chamber_1.cook_set_time == call_json_fryers.AirFryerDefaults.cook_time_s + assert fryer_obj.state_chamber_1.cook_mode is not None + assert fryer_obj.state_chamber_1.cook_last_time == call_json_fryers.AirFryerDefaults.cook_last_time_s + if const.AirFryerFeatures.DUAL_CHAMBER in fryer_obj.features: + # Chamber 2 should be standby in cooking test data + assert fryer_obj.state_chamber_2.cook_status == const.AirFryerCookStatus.STANDBY + # Assert request matches recorded request or write new records assert assert_test( method_call, all_kwargs, setup_entry, self.write_api, self.overwrite ) - - # def test_methods(self, setup_entry, method): - # """Test device methods API request and response. - - # This method is automatically parametrized by `pytest_generate_tests` - # based on class variables `device` (name of product class - humidifiers), - # device name (humidifiers) list of setup_entry's, `base_methods` - list of - # methods for all devices, and `device_methods` - list of methods for - # each device type. - - # Example: - # >>> base_methods = [['turn_on'], ['turn_off'], ['update']] - # >>> device_methods = { - # 'setup_entry': [['method1'], ['method2', {'kwargs': 'value'}]] - # } - - # Notes - # ----- - # The response can be a callable that accepts the `kwargs` argument to - # sync the device response with the API response. In some cases the API - # returns data from the method call, such as `get_yearly_energy`, in other cases the - # API returns a simple confirmation the command was successful. - - # See Also - # -------- - # `TestBase` class method - # `call_json_fans` module - - # """ - # # Get method name and kwargs from method fixture - # method_name = method[0] - # if len(method) == 2 and isinstance(method[1], dict): - # method_kwargs = method[1] - # else: - # method_kwargs = {} - - # # Set return value for call_api based on call_json_fans.METHOD_RESPONSES - # method_response = call_json_fans.METHOD_RESPONSES[setup_entry][method_name] - # if callable(method_response): - # if method_kwargs: - # self.mock_api.return_value = method_response(method_kwargs), 200 - # else: - # self.mock_api.return_value = method_response(), 200 - # else: - # self.mock_api.return_value = method_response, 200 - - # # Get device configuration from call_json.DeviceList.device_list_item() - # fan_obj = self.get_device("fans", setup_entry) - # assert isinstance(fan_obj, VeSyncFanBase) - - # # Get method from device object - # method_call = getattr(fan_obj, method[0]) - - # # Ensure method runs based on device configuration - # if method[0] == 'turn_on': - # fan_obj.state.device_status = const.DeviceStatus.OFF - # elif method[0] == 'turn_off': - # fan_obj.state.device_status = const.DeviceStatus.ON - - # # Call method with kwargs if defined - # if method_kwargs: - # self.run_in_loop(method_call, **method_kwargs) - # else: - # self.run_in_loop(method_call) - - # # Parse arguments from mock_api call into a dictionary - # all_kwargs = parse_args(self.mock_api) - - # # Assert request matches recorded request or write new records - # assert assert_test( - # method_call, all_kwargs, setup_entry, self.write_api, self.overwrite - # ) From 219d5bd6e69cca2f1ccdf746731f1c00878b1a13 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:04:17 +0000 Subject: [PATCH 13/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pyvesync/base_devices/fryer_base.py | 12 ++---- src/pyvesync/device_map.py | 52 ++++++++++++++----------- src/pyvesync/devices/vesynckitchen.py | 50 +++++++++--------------- 3 files changed, 50 insertions(+), 64 deletions(-) diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index cb05637..c48f4eb 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -244,9 +244,7 @@ def set_preheat_stop_state(self, preheat_set_time: int | None = None) -> None: """Set the fryer state to preheat stopped.""" self.cook_status = AirFryerCookStatus.PREHEAT_STOP if preheat_set_time is not None: - self.preheat_set_time = self.device.convert_time_for_state( - preheat_set_time - ) + self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = self.preheat_time_remaining self.last_timestamp = None @@ -254,9 +252,7 @@ def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None: """Set the fryer state to preheat resumed.""" self.cook_status = AirFryerCookStatus.HEATING if preheat_set_time is not None: - self.preheat_set_time = self.device.convert_time_for_state( - preheat_set_time - ) + self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = self.preheat_time_remaining self.last_timestamp = datetime.now(timezone.utc) @@ -363,9 +359,7 @@ def set_state( # noqa: PLR0912, PLR0913, C901 # Handle preheat attributes if preheat_set_time is not None: - self.preheat_set_time = self.device.convert_time_for_state( - preheat_set_time - ) + self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = ( self.device.convert_time_for_state(preheat_last_time) if preheat_last_time is not None diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index ab6e090..1ee7c12 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1107,14 +1107,16 @@ class ThermostatMap(DeviceMapTemplate): default_preset=AirFryerPresets.custom, default_cook_mode=AirFryerCookModes.CUSTOM, time_units=TimeUnits.MINUTES, - status_map=MappingProxyType({ - 'heating': AirFryerCookStatus.HEATING, - 'cooking': AirFryerCookStatus.COOKING, - 'cookStop': AirFryerCookStatus.COOK_STOP, - 'heatStop': AirFryerCookStatus.PREHEAT_STOP, - 'heatEnd': AirFryerCookStatus.PREHEAT_END, - 'standby': AirFryerCookStatus.STANDBY, - }) + status_map=MappingProxyType( + { + 'heating': AirFryerCookStatus.HEATING, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'heatStop': AirFryerCookStatus.PREHEAT_STOP, + 'heatEnd': AirFryerCookStatus.PREHEAT_END, + 'standby': AirFryerCookStatus.STANDBY, + } + ), ), AirFryerMap( class_name='VeSyncTurboBlazeFryer', @@ -1134,14 +1136,16 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(90, 450), temperature_range_c=(30, 230), - status_map=MappingProxyType({ - 'ready': AirFryerCookStatus.COOK_STOP, - 'cooking': AirFryerCookStatus.COOKING, - 'heating': AirFryerCookStatus.HEATING, - 'cookStop': AirFryerCookStatus.COOK_STOP, - 'pullOut': AirFryerCookStatus.PULL_OUT, - 'cookEnd': AirFryerCookStatus.COOK_END, - }), + status_map=MappingProxyType( + { + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'heating': AirFryerCookStatus.HEATING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'cookEnd': AirFryerCookStatus.COOK_END, + } + ), ), AirFryerMap( class_name='VeSyncDualAirFryer', @@ -1166,13 +1170,15 @@ class ThermostatMap(DeviceMapTemplate): time_units=TimeUnits.SECONDS, temperature_range_f=(130, 450), temperature_range_c=(55, 240), - status_map=MappingProxyType({ - 'standby': AirFryerCookStatus.STANDBY, - 'ready': AirFryerCookStatus.COOK_STOP, - 'cooking': AirFryerCookStatus.COOKING, - 'cookStop': AirFryerCookStatus.COOK_STOP, - 'pullOut': AirFryerCookStatus.PULL_OUT, - }), + status_map=MappingProxyType( + { + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + } + ), ), ] """List of ['AirFryerMap'][pyvesync.device_map.AirFryerMap] configuration diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index d35a78f..1c69c35 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -301,9 +301,7 @@ async def end(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'end', self, resp) if r is None: return False @@ -322,9 +320,7 @@ async def stop(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'stop', self, resp) if r is None: return False @@ -346,9 +342,7 @@ async def resume(self, chamber: int = 1) -> bool: ) return False json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'resume', self, resp) if r is None: return False @@ -379,9 +373,7 @@ async def set_mode_from_recipe( ) cmd = {'cookMode': cook_req} json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp) if r is None: return False @@ -433,9 +425,7 @@ async def cook_from_preheat(self, chamber: int = 1) -> bool: } } json_cmd = {'jsonCmd': cmd} - resp = await self.call_158_api( - models.Fryer158RequestModel, update_dict=json_cmd - ) + resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd) r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp) if r is None: return False @@ -737,17 +727,11 @@ def _build_cook_configs( if self.sync_chambers: return [ - models.FryerDualCookConfig.from_dict( - {**config_dict, 'chamber': 1} - ), - models.FryerDualCookConfig.from_dict( - {**config_dict, 'chamber': 2} - ), + models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': 1}), + models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': 2}), ] return [ - models.FryerDualCookConfig.from_dict( - {**config_dict, 'chamber': chamber} - ), + models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': chamber}), ] async def set_mode_from_recipe( @@ -770,14 +754,16 @@ async def set_mode_from_recipe( work_chamber = self._get_work_chamber(chamber) sync_type = self._get_sync_type() - start_data = models.FryerDualStartCookData.from_dict({ - 'accountId': self.manager.account_id, - 'cookConfigs': [c.to_dict() for c in cook_configs], - 'readyStart': True, - 'syncType': sync_type, - 'tempUnit': self.temp_unit.code, - 'workChamber': work_chamber, - }) + start_data = models.FryerDualStartCookData.from_dict( + { + 'accountId': self.manager.account_id, + 'cookConfigs': [c.to_dict() for c in cook_configs], + 'readyStart': True, + 'syncType': sync_type, + 'tempUnit': self.temp_unit.code, + 'workChamber': work_chamber, + } + ) resp = await self.call_bypassv2_api( payload_method='startMultiCook', From 43b73e004a7462a80cce3080d46f4ccad203f2ec Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Wed, 25 Mar 2026 20:16:48 -0400 Subject: [PATCH 14/19] Fix fryer and auth bug --- .gitignore | 4 ++++ src/pyvesync/base_devices/fryer_base.py | 2 +- src/pyvesync/devices/vesynckitchen.py | 12 +++++++++++- src/pyvesync/vesync.py | 3 +-- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a151090..19b29ca 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,9 @@ creds.json docstest/* .claude/settings.local.json .mcp.json +<<<<<<< HEAD .vesync_auth **/CLAUDE.md +======= +.vesync_auth +>>>>>>> 0e29ad8 (Fix fryer and auth bug) diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index c48f4eb..1f19d5d 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -473,7 +473,7 @@ def __init__( # attempt to set temp unit from country code before first update self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS - if self.manager.measure_unit and self.manager.measure_unit.lower() == 'imperial': + if self.manager.country_code == 'US': self._temp_unit = TemperatureUnits.FAHRENHEIT # Set state to primary chamber (chamber 1) for base class compatibility diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index 1c69c35..c2ea9d7 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -28,6 +28,7 @@ AirFryerCookStatus, AirFryerPresetRecipe, ConnectionStatus, + TemperatureUnits, ) from pyvesync.models import fryer_models as models from pyvesync.utils.device_mixins import ( @@ -488,7 +489,7 @@ async def get_details(self) -> None: self.state.set_standby() return - self.state.set_state( + self.state_chamber_1.set_state( cook_status=self.status_map[resp_model.cookStatus], cook_time=cook_step.cookSetTime, cook_last_time=cook_step.cookLastTime, @@ -631,6 +632,8 @@ async def get_details(self) -> None: self.state_chamber_2.set_standby() return + self.temp_unit = TemperatureUnits.from_string(resp_model.tempUnit) + # Update sync state from API response self.sync_chambers = resp_model.syncType == self._SYNC_TYPE_SYNCED self.state_chamber_1.sync_chambers = self.sync_chambers @@ -648,6 +651,13 @@ async def get_details(self) -> None: status_item.cookStatus not in self.status_map or status_item.cookStatus == AirFryerCookStatus.STANDBY.value ): + if status_item.cookStatus not in self.status_map: + logger.warning( + 'Unknown cook status %s for %s chamber %d', + status_item.cookStatus, + self.device_name, + ch_num, + ) chamber_state.set_standby() continue diff --git a/src/pyvesync/vesync.py b/src/pyvesync/vesync.py index 75a67df..0e9f9bd 100644 --- a/src/pyvesync/vesync.py +++ b/src/pyvesync/vesync.py @@ -243,8 +243,7 @@ async def save_credentials(self, filename: str | Path | None) -> None: filename (str | Path | None): The name of the file to save credentials to. If None, no action is taken. """ - if filename is not None: - await self.auth.save_credentials_to_file(filename) + await self.auth.save_credentials_to_file(filename) async def load_credentials_from_file( self, filename: str | Path | None = None From e9459a46e0797154681037fca992a18a0fec63be Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 12 Apr 2026 20:28:27 -0400 Subject: [PATCH 15/19] Fix Dual Blaze get_details --- src/pyvesync/device_map.py | 2 +- src/pyvesync/devices/vesynckitchen.py | 1 + src/pyvesync/models/bypass_models.py | 3 +- src/pyvesync/models/fryer_models.py | 43 --------------------- src/tests/api/vesynckitchen/CAF-TF101S.yaml | 2 + 5 files changed, 6 insertions(+), 45 deletions(-) diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 1ee7c12..a7ac300 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1150,7 +1150,7 @@ class ThermostatMap(DeviceMapTemplate): AirFryerMap( class_name='VeSyncDualAirFryer', module=vesynckitchen, - dev_types=['CAF-TF101S-AEU'], + dev_types=['CAF-TF101S-AEU', 'CAF-TF101S'], setup_entry='CAF-TF101S', device_alias='Dual Air Fryer', model_display='CAF-TF101S Series', diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index c2ea9d7..b8963ab 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -618,6 +618,7 @@ async def get_details(self) -> None: """ resp = await self.call_bypassv2_api( payload_method='getAirfryerMultiStatus', + payload_update={'subDeviceNo': 0, 'subDeviceType': ''}, ) resp_model = process_bypassv2_result( self, diff --git a/src/pyvesync/models/bypass_models.py b/src/pyvesync/models/bypass_models.py index c72cb91..7c4f73c 100644 --- a/src/pyvesync/models/bypass_models.py +++ b/src/pyvesync/models/bypass_models.py @@ -55,11 +55,12 @@ class BypassV2RequestPayload(DataClassORJSONMixin): method: str source: str = 'APP' subDeviceNo: int | None = None + subDeviceType: str | None = None class Config(BaseModelConfig): """orjson config for dataclasses.""" - forbid_extra_keys = True + forbid_extra_keys = False orjson_options = orjson.OPT_NON_STR_KEYS omit_none = True diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index 81c4ae6..16246b5 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -273,46 +273,3 @@ class FryerDualStartCookData(RequestBaseModel): syncType: int tempUnit: str workChamber: int - - -# a = { -# 'cookMode': { -# 'accountId': '1221391', -# 'appointmentTs': 0, -# 'cookSetTemp': 350, -# 'cookSetTime': 15, -# 'cookStatus': 'cooking', -# 'customRecipe': 'Manual Cooking', -# 'mode': 'custom', -# 'readyStart': True, -# 'recipeId': 1, -# 'recipeType': 3, -# 'tempUnit': 'fahrenheit', -# }, -# 'preheat': { -# 'customRecipe': 'Manual', -# 'readyStart': False, -# 'cookSetTime': 15, -# 'tempUnit': 'fahrenheit', -# 'mode': 'custom', -# 'accountId': '1221391', -# 'targetTemp': 350, -# 'preheatSetTime': 4, -# 'preheatStatus': 'heating', -# 'recipeId': 1, -# 'recipeType': 3, -# }, -# 'cookMode': { -# 'accountId': '1221391', -# 'cookSetTemp': 400, -# 'recipeId': 1, -# 'mode': 'custom', -# 'readyStart': False, -# 'appointmentTs': 0, -# 'cookStatus': 'cooking', -# 'customRecipe': 'Manual', -# 'cookSetTime': 15, -# 'tempUnit': 'fahrenheit', -# 'recipeType': 3, -# }, -# } diff --git a/src/tests/api/vesynckitchen/CAF-TF101S.yaml b/src/tests/api/vesynckitchen/CAF-TF101S.yaml index 3ea1ad5..3fc1298 100644 --- a/src/tests/api/vesynckitchen/CAF-TF101S.yaml +++ b/src/tests/api/vesynckitchen/CAF-TF101S.yaml @@ -16,6 +16,8 @@ update: data: {} method: getAirfryerMultiStatus source: APP + subDeviceNo: 0 + subDeviceType: '' phoneBrand: pyvesync phoneOS: Android timeZone: America/New_York From db47dcd34a8f5befb086df694c24165565da2c26 Mon Sep 17 00:00:00 2001 From: Red Hodgerson <1190796+RedsGT@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:10:13 -0500 Subject: [PATCH 16/19] feat: add Cosori Dual Blaze (CAF-P583S) air fryer support (#517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for the Cosori Dual Blaze 6.8qt smart air fryer (CAF-P583S-KUS / CAF-P583S-KEU). Resolves the request in #477. The Dual Blaze is a single-chamber model with dual heating elements that uses the same bypassV2 protocol as TurboBlaze (`startCook`, `endCook`, `getAirfryerStatus`), so it can reuse `VeSyncTurboBlazeFryer` without needing a new device class. ## Commits 1. **Register CAF-P583S** in `device_map.py` (basic support, default AirFry preset). 2. **Add 10 Dual Blaze presets + 5 cook modes** — recipe IDs from packet captures of the official VeSync app, default temp/time per the CAF-P583S-KUS user manual. ## Tested on - CAF-P583S-KUS, firmware v1.0.15 - Verified: remote start, remote stop, status polling (cookStatus, currentTemp, cookSetTemp, totalTimeRemaining, stepArray), all 11 presets accepted by the device ## Notes - The Dual Blaze has no preheat function. - Temperature range per manual: 175–400°F / 80–205°C. 🤖 The patches in this PR were authored with assistance from Claude (Anthropic). --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Joe Trabulsy Co-authored-by: Darren Griffin --- .gitignore | 3 +- src/pyvesync/const.py | 97 +++++++++++++++++++++ src/pyvesync/device_map.py | 47 ++++++++++ src/pyvesync/devices/vesynckitchen.py | 16 +++- src/pyvesync/models/fryer_models.py | 6 +- src/tests/api/vesynckitchen/CAF-DC601S.yaml | 2 +- 6 files changed, 164 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 6b47113..da0c515 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ testing_scripts/yamltest.yaml testing_scripts/yamltest copy.yaml creds.json docstest/* -.claude/ +.claude/settings.local.json .mcp.json .vesync_auth **/CLAUDE.md +/docs/superpowers \ No newline at end of file diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index 61aaa98..481d68e 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -809,6 +809,98 @@ class AirFryerPresets: temp_unit='f', cook_time=10 * 60, ) + # Cosori Dual Blaze (CAF-P583S) presets — recipe IDs and default + # temp/time values per the CAF-P583S-KUS user manual. + broil: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Broil', + recipe_name='Broil', + recipe_id=17, + recipe_type=3, + target_temp=450, + temp_unit='f', + cook_time=12 * 60, + ) + roast: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Roast', + recipe_name='Roast', + recipe_id=13, + recipe_type=3, + target_temp=380, + temp_unit='f', + cook_time=30 * 60, + ) + bake: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Bake', + recipe_name='Bake', + recipe_id=9, + recipe_type=3, + target_temp=340, + temp_unit='f', + cook_time=30 * 60, + ) + reheat: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Reheat', + recipe_name='Reheat', + recipe_id=16, + recipe_type=3, + target_temp=280, + temp_unit='f', + cook_time=10 * 60, + ) + steak: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Steak', + recipe_name='Steak', + recipe_id=1, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=9 * 60, + ) + seafood: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Seafood', + recipe_name='Seafood', + recipe_id=3, + recipe_type=3, + target_temp=375, + temp_unit='f', + cook_time=6 * 60, + ) + veggies: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Veggies', + recipe_name='Veggies', + recipe_id=15, + recipe_type=3, + target_temp=375, + temp_unit='f', + cook_time=10 * 60, + ) + french_fries: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='FrenchFries', + recipe_name='French Fries', + recipe_id=6, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=18 * 60, + ) + frozen: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Frozen', + recipe_name='Frozen', + recipe_id=5, + recipe_type=3, + target_temp=400, + temp_unit='f', + cook_time=17 * 60, + ) + chicken: AirFryerPresetRecipe = AirFryerPresetRecipe( + cook_mode='Chicken', + recipe_name='Chicken', + recipe_id=2, + recipe_type=3, + target_temp=380, + temp_unit='f', + cook_time=25 * 60, + ) AIRFRYER_PRESET_MAP = { @@ -858,6 +950,11 @@ class AirFryerCookModes(StrEnum): DRY = 'dry' GRILL = 'grill' PREHEAT = 'preheat' + STEAK = 'steak' + SEAFOOD = 'seafood' + VEGGIES = 'veggies' + FRENCH_FRIES = 'french_fries' + CHICKEN = 'chicken' class AirFryerFeatures(Features): diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index a7ac300..01c609e 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -1147,6 +1147,53 @@ class ThermostatMap(DeviceMapTemplate): } ), ), + AirFryerMap( + # Cosori Dual Blaze (single-chamber model with dual heating elements). + # Uses the same bypassV2 protocol as TurboBlaze (startCook / endCook / + # getAirfryerStatus). The Dual Blaze has no preheat function. + class_name='VeSyncTurboBlazeFryer', + module=vesynckitchen, + dev_types=['CAF-P583S-KUS', 'CAF-P583S-KEU'], + setup_entry='CAF-P583S', + device_alias='Dual Blaze Air Fryer', + model_display='CAF-P583S Series', + model_name='Dual Blaze 6.8 Qt. Air Fryer', + temperature_step_f=5, + features=[AirFryerFeatures.RESUMABLE], + # 11 presets exposed by the VeSync app; recipe IDs/names defined in + # AirFryerPresets in const.py. + cook_modes={ + AirFryerCookModes.AIRFRY: 'AirFry', + AirFryerCookModes.BROIL: 'Broil', + AirFryerCookModes.ROAST: 'Roast', + AirFryerCookModes.BAKE: 'Bake', + AirFryerCookModes.REHEAT: 'Reheat', + AirFryerCookModes.STEAK: 'Steak', + AirFryerCookModes.SEAFOOD: 'Seafood', + AirFryerCookModes.VEGGIES: 'Veggies', + AirFryerCookModes.FRENCH_FRIES: 'FrenchFries', + AirFryerCookModes.FROZEN: 'Frozen', + AirFryerCookModes.CHICKEN: 'Chicken', + }, + default_cook_mode=AirFryerCookModes.AIRFRY, + default_preset=AirFryerPresets.air_fry, + time_units=TimeUnits.SECONDS, + temperature_range_f=(175, 400), + temperature_range_c=(80, 205), + status_map=MappingProxyType( + { + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'heating': AirFryerCookStatus.HEATING, + 'preheating': AirFryerCookStatus.HEATING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'cookEnd': AirFryerCookStatus.COOK_END, + 'keeping': AirFryerCookStatus.COOKING, + } + ), + ), AirFryerMap( class_name='VeSyncDualAirFryer', module=vesynckitchen, diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index b8963ab..8da4d2a 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -448,7 +448,7 @@ def _build_cook_request( cook_req['hasPreheat'] = int(True) cook_req['hasWarm'] = False cook_req['mode'] = recipe.cook_mode - cook_req['readyStart'] = True + cook_req['readyStart'] = False cook_req['recipeId'] = recipe.recipe_id cook_req['recipeName'] = recipe.recipe_name cook_req['recipeType'] = recipe.recipe_type @@ -462,7 +462,7 @@ def _build_cook_request( return models.FryerTurboBlazeRequestData.from_dict(cook_req) async def get_details(self) -> None: - resp = await self.call_bypassv2_api(payload_method='getAirfyerStatus') + resp = await self.call_bypassv2_api(payload_method='getAirfryerStatus') resp_model = process_bypassv2_result( self, logger, @@ -489,6 +489,16 @@ async def get_details(self) -> None: self.state.set_standby() return + # currentTemp from the bypassV2 getAirfryerStatus response is the + # device's hardware sensor reading, which the firmware always + # reports in Celsius regardless of resp_model.tempUnit. The + # tempUnit field governs only the echoed cookTemp/preheatTemp + # values. Normalize so consumers can compare cook_temp and + # current_temp without a unit-aware crutch. + _current_temp = resp_model.currentTemp + if _current_temp is not None and resp_model.tempUnit == 'f': + _current_temp = round(_current_temp * 9 / 5 + 32) + self.state_chamber_1.set_state( cook_status=self.status_map[resp_model.cookStatus], cook_time=cook_step.cookSetTime, @@ -498,7 +508,7 @@ async def get_details(self) -> None: cook_mode=cook_step.mode, preheat_set_time=resp_model.preheatSetTime, preheat_last_time=resp_model.preheatLastTime, - current_temp=resp_model.currentTemp, + current_temp=_current_temp, ) async def end(self, chamber: int = 1) -> bool: diff --git a/src/pyvesync/models/fryer_models.py b/src/pyvesync/models/fryer_models.py index 16246b5..0a8e671 100644 --- a/src/pyvesync/models/fryer_models.py +++ b/src/pyvesync/models/fryer_models.py @@ -198,14 +198,16 @@ class FryerTurboBlazeRequestData(RequestBaseModel): """Request model for TurboBlaze air fryer cooking commands.""" accountId: str - hasPreheat: int hasWarm: bool + mode: str readyStart: bool recipeId: int recipeName: str recipeType: int tempUnit: str - startAct: list[FryerTurboBlazeStartActItem] + startAct: FryerTurboBlazeStartActItem + hasPreheat: int = 0 + hasLinkage: bool = False @dataclass diff --git a/src/tests/api/vesynckitchen/CAF-DC601S.yaml b/src/tests/api/vesynckitchen/CAF-DC601S.yaml index 3d32bab..34435b3 100644 --- a/src/tests/api/vesynckitchen/CAF-DC601S.yaml +++ b/src/tests/api/vesynckitchen/CAF-DC601S.yaml @@ -14,7 +14,7 @@ update: method: bypassV2 payload: data: {} - method: getAirfyerStatus + method: getAirfryerStatus source: APP phoneBrand: pyvesync phoneOS: Android From 4f78bad60bb741d4f6ee3939c789738fb1339bdd Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 12 Jul 2026 01:44:53 -0400 Subject: [PATCH 17/19] feat: make device_map the source of truth for air fryer temperature units All air fryer temperatures (method arguments, state, and API payloads) are now consistently expressed in the device's own unit, with the device map as the single source of truth for each model's unit and step. - AirFryerMap gains a temp_unit field that seeds VeSyncFryer._temp_unit at init, replacing the country-code guess. The API-reported tempUnit still wins: each device's get_details sets device.temp_unit explicitly from the response, and FryerState.set_state no longer mutates device-level config. - The map declares a single Fahrenheit step (temperature_step_f); the Celsius step is derived from AIRFRYER_STEP_F_TO_C (5F -> 2C, 10F -> 5C) via a new temperature_step_c property, and temperature_step/min_temp/max_temp select by the device's current unit. round_temperature uses the declared step, dropping the imprecise 5/9-derived Celsius rounding. - The Dual Blaze is split into US (CAF-P583S, Fahrenheit) and EU (CAF-P583S-KEU, Celsius) entries so each device type declares one unit. - set_mode rounds-then-validates via a shared prepare_temperature helper, adding the validation TurboBlaze previously skipped and fixing validate-before-round edge cases. - FryerState exposes temp_unit as a read-through property; docstrings document that all temperatures are in the device's current unit. Adds src/tests/test_fryer_units.py covering unit seeding, API-wins updates, unit-aware steps/ranges, the F-to-C step map, and round-then-validate. Co-Authored-By: Claude Fable 5 --- src/pyvesync/base_devices/fryer_base.py | 130 +++++++++++++++++----- src/pyvesync/const.py | 7 ++ src/pyvesync/device_map.py | 67 +++++++++++- src/pyvesync/devices/vesynckitchen.py | 40 +++---- src/tests/test_fryer_units.py | 140 ++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 51 deletions(-) create mode 100644 src/tests/test_fryer_units.py diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index 1f19d5d..d1146aa 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -9,6 +9,7 @@ from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice from pyvesync.const import ( AIRFRYER_PID_MAP, + AIRFRYER_STEP_F_TO_C, COOK_STATUSES, PREHEAT_STATUSES, RESUMABLE_STATUSES, @@ -55,8 +56,11 @@ class FryerState(DeviceState): time_units (TimeUnits): The time units used by the device. cook_status (AirFryerCookStatus | None): The current cooking status. cook_mode (str | None): The current cooking mode. - current_temp (int | None): The current temperature of the fryer. - cook_set_temp (int | None): The set cooking temperature. + temp_unit (TemperatureUnits): Unit for all temperatures in this state + (read-through to the device's ``temp_unit``). + current_temp (int | None): The current temperature in ``temp_unit`` units. + cook_set_temp (int | None): The set cooking temperature in ``temp_unit`` + units. cook_set_time (int | None): The set cooking time in seconds. cook_last_time (int | None): The remaining cooking time in seconds. last_timestamp (datetime | None): The timestamp of the last status update. @@ -115,6 +119,15 @@ def __init__( self.recipe: str | None = None self.ready_start: bool = False + @property + def temp_unit(self) -> TemperatureUnits: + """Return the unit for all temperatures in this state. + + This is the device-level unit; all state temperatures + (``current_temp``, ``cook_set_temp``) are expressed in this unit. + """ + return self.device.temp_unit + @property def is_in_preheat_mode(self) -> bool: """Return True if the fryer has preheat feature.""" @@ -300,14 +313,13 @@ def set_preheating_state( self.cook_mode = cook_mode self.recipe = recipe - def set_state( # noqa: PLR0912, PLR0913, C901 + def set_state( # noqa: PLR0913, C901 self, *, cook_status: AirFryerCookStatus, cook_time: int | None = None, cook_last_time: int | None = None, cook_temp: int | None = None, - temp_unit: str | None = None, cook_mode: str | None = None, preheat_set_time: int | None = None, preheat_last_time: int | None = None, @@ -329,12 +341,13 @@ def set_state( # noqa: PLR0912, PLR0913, C901 cook_status (AirFryerCookStatus): The cooking status. cook_time (int | None): The cooking time in device units. cook_last_time (int | None): The remaining cooking time in device units. - cook_temp (int | None): The cooking temperature. - temp_unit (str | None): The temperature units (F or C). + cook_temp (int | None): The cooking temperature in the device's + ``temp_unit``. cook_mode (str | None): The cooking mode. preheat_set_time (int | None): The preheating time in device units. preheat_last_time (int | None): The remaining preheat time in device units. - current_temp (int | None): The current temperature. + current_temp (int | None): The current temperature in the device's + ``temp_unit``. recipe (str | None): The recipe name or cooking mode. """ # Stop/standby states delegate to helpers and return early @@ -383,8 +396,6 @@ def set_state( # noqa: PLR0912, PLR0913, C901 self.cook_mode = cook_mode if current_temp is not None: self.current_temp = current_temp - if temp_unit is not None: - self.device.temp_unit = TemperatureUnits.from_string(temp_unit) if recipe is not None: self.recipe = recipe @@ -429,7 +440,11 @@ class VeSyncFryer(VeSyncBaseDevice): state_chamber_1 (FryerState): The state object for chamber 1. state_chamber_2 (FryerState): The state object for chamber 2 (if dual chamber). sync_chambers (bool): Whether the chambers are synced for cooking. - temperature_interval (int): The available temperature step interval. + temp_unit (TemperatureUnits): The device's current temperature unit + (seeded from the device map, updated from the API on update()). + temperature_step_f (int): The temperature step interval in Fahrenheit. + temperature_step_c (int): The temperature step interval in Celsius, + derived from the Fahrenheit step via ``AIRFRYER_STEP_F_TO_C``. time_units (TimeUnits): The time units used by the device (seconds or minutes). """ @@ -445,7 +460,7 @@ class VeSyncFryer(VeSyncBaseDevice): 'state_chamber_2', 'status_map', 'sync_chambers', - 'temperature_interval', + 'temperature_step_f', 'time_units', ) @@ -467,14 +482,13 @@ def __init__( 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.temperature_interval: int = feature_map.temperature_step_f + self.temperature_step_f: int = feature_map.temperature_step_f self.time_units: TimeUnits = feature_map.time_units self.status_map = feature_map.status_map - # attempt to set temp unit from country code before first update - self._temp_unit: TemperatureUnits = TemperatureUnits.CELSIUS - if self.manager.country_code == 'US': - self._temp_unit = TemperatureUnits.FAHRENHEIT + # Device map declares the model's unit; get_details() updates it + # from the API response if the device reports a different unit. + self._temp_unit: TemperatureUnits = feature_map.temp_unit # Set state to primary chamber (chamber 1) for base class compatibility self.state = self.state_chamber_1 @@ -485,19 +499,59 @@ def temp_unit(self) -> TemperatureUnits: return self._temp_unit @temp_unit.setter - def temp_unit(self, value: TemperatureUnits) -> None: + def temp_unit(self, value: TemperatureUnits | str) -> None: """Set the temperature unit. Args: - value (TemperatureUnits): The temperature unit (F or C). + value (TemperatureUnits | str): The temperature unit + ('f'/'c'/'fahrenheit'/'celsius' or a TemperatureUnits member). """ self._temp_unit = TemperatureUnits.from_string(value) + @property + def temperature_step_c(self) -> int: + """Return the Celsius temperature step derived from the Fahrenheit step. + + The device map declares a single Fahrenheit step; the Celsius step is + looked up from ``AIRFRYER_STEP_F_TO_C``. + """ + return AIRFRYER_STEP_F_TO_C[self.temperature_step_f] + + @property + def temperature_step(self) -> int: + """Return the temperature step in the device's current unit.""" + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + return self.temperature_step_f + return self.temperature_step_c + + @property + def temperature_interval(self) -> int: + """Return the temperature step in the device's current unit. + + Deprecated: + Use ``temperature_step`` instead. + """ + return self.temperature_step + + @property + def min_temp(self) -> int: + """Return the minimum temperature in the device's current unit.""" + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + return self.min_temp_f + return self.min_temp_c + + @property + def max_temp(self) -> int: + """Return the maximum temperature in the device's current unit.""" + if self.temp_unit == TemperatureUnits.FAHRENHEIT: + return self.max_temp_f + return self.max_temp_c + def validate_temperature(self, temperature: int) -> bool: """Validate the temperature is within the allowed range. Args: - temperature (int): The temperature to validate. + temperature (int): The temperature in the device's ``temp_unit``. Returns: bool: True if the temperature is valid, False otherwise. @@ -510,16 +564,38 @@ def round_temperature(self, temperature: int) -> int: """Round the temperature to the nearest valid step. Args: - temperature (int): The temperature to round. + temperature (int): The temperature in the device's ``temp_unit``. Returns: - int: The rounded temperature. + int: The temperature rounded to the device's step for the + current unit. """ - if self.temp_unit == TemperatureUnits.FAHRENHEIT: - step: float = self.temperature_interval - return int(round(temperature / step) * step) - step = self.temperature_interval * 5 / 9 - return int(round(temperature / step) * step) + step = self.temperature_step + return round(temperature / step) * step + + def prepare_temperature(self, temperature: int) -> int | None: + """Round a requested temperature to the device step, then validate. + + Args: + temperature (int): Requested temperature in the device's + ``temp_unit``. + + Returns: + int | None: The rounded temperature, or None if the rounded + value is outside the device's range for the current unit. + """ + rounded = self.round_temperature(temperature) + if not self.validate_temperature(rounded): + logger.warning( + 'Cook temperature %s%s is outside the range %s-%s for %s', + temperature, + self.temp_unit.code.upper(), + self.min_temp, + self.max_temp, + self.device_name, + ) + return None + return rounded def convert_time_for_api(self, time_in_seconds: int) -> int: """Convert time in seconds to the device's time units. @@ -598,7 +674,7 @@ async def set_mode( Args: cook_time (int): The cooking time in seconds. - cook_temp (int): The cooking temperature. + cook_temp (int): The cooking temperature in the device's ``temp_unit``. preheat_time (int | None): The preheating time in seconds, if any. chamber (int): The chamber number to set cooking for. Default is 1. diff --git a/src/pyvesync/const.py b/src/pyvesync/const.py index 481d68e..7ca9a76 100644 --- a/src/pyvesync/const.py +++ b/src/pyvesync/const.py @@ -751,6 +751,13 @@ class FanModes(StrEnum): """PID's for VeSync Air Fryers based on ConfigModule.""" +AIRFRYER_STEP_F_TO_C: MappingProxyType[int, int] = MappingProxyType({5: 2, 10: 5}) +"""Maps an air fryer's Fahrenheit temperature step to its Celsius equivalent. + +The device map declares a single temperature step in Fahrenheit; the Celsius +step is derived from this mapping when the device operates in Celsius.""" + + CUSTOM_RECIPE_ID = 1 CUSTOM_RECIPE_TYPE = 3 CUSTOM_RECIPE_NAME = 'Manual Cook' diff --git a/src/pyvesync/device_map.py b/src/pyvesync/device_map.py index 01c609e..96df309 100644 --- a/src/pyvesync/device_map.py +++ b/src/pyvesync/device_map.py @@ -86,6 +86,7 @@ PurifierFeatures, PurifierModes, SwitchFeatures, + TemperatureUnits, ThermostatEcoTypes, ThermostatFanModes, ThermostatHoldOptions, @@ -334,9 +335,16 @@ class AirFryerMap(DeviceMapTemplate): features (list[str]): List of features for the device. product_type (str): Product type of the device. module (ModuleType): Module for the device. + temp_unit (TemperatureUnits): The temperature unit the device model + uses (map default; may be updated from the API on update()). + temperature_range_f (tuple[int, int]): Min/max temperature in Fahrenheit. + temperature_range_c (tuple[int, int]): Min/max temperature in Celsius. + temperature_step_f (int): Temperature step interval in Fahrenheit. The + Celsius step is derived from this via ``AIRFRYER_STEP_F_TO_C``. """ time_units: TimeUnits = TimeUnits.MINUTES + temp_unit: TemperatureUnits = TemperatureUnits.FAHRENHEIT temperature_range_f: tuple[int, int] = (200, 400) temperature_range_c: tuple[int, int] = (75, 200) temperature_step_f: int = 10 @@ -1099,6 +1107,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='CS158-AF', + temp_unit=TemperatureUnits.FAHRENHEIT, temperature_step_f=10, features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], cook_modes={ @@ -1126,6 +1135,7 @@ class ThermostatMap(DeviceMapTemplate): device_alias='TurboBlaze Air Fryer', model_display='CAF-DC601S Series', model_name='TurboBlaze 6 Qt. Air Fryer', + temp_unit=TemperatureUnits.FAHRENHEIT, temperature_step_f=5, features=[AirFryerFeatures.PREHEAT, AirFryerFeatures.RESUMABLE], cook_modes={ @@ -1148,16 +1158,17 @@ class ThermostatMap(DeviceMapTemplate): ), ), AirFryerMap( - # Cosori Dual Blaze (single-chamber model with dual heating elements). - # Uses the same bypassV2 protocol as TurboBlaze (startCook / endCook / - # getAirfryerStatus). The Dual Blaze has no preheat function. + # Cosori Dual Blaze US variant (single-chamber model with dual heating + # elements). Uses the same bypassV2 protocol as TurboBlaze (startCook / + # endCook / getAirfryerStatus). The Dual Blaze has no preheat function. class_name='VeSyncTurboBlazeFryer', module=vesynckitchen, - dev_types=['CAF-P583S-KUS', 'CAF-P583S-KEU'], + dev_types=['CAF-P583S-KUS'], setup_entry='CAF-P583S', device_alias='Dual Blaze Air Fryer', model_display='CAF-P583S Series', model_name='Dual Blaze 6.8 Qt. Air Fryer', + temp_unit=TemperatureUnits.FAHRENHEIT, temperature_step_f=5, features=[AirFryerFeatures.RESUMABLE], # 11 presets exposed by the VeSync app; recipe IDs/names defined in @@ -1194,14 +1205,60 @@ class ThermostatMap(DeviceMapTemplate): } ), ), + AirFryerMap( + # Cosori Dual Blaze EU variant — Celsius. Same bypassV2 protocol as the + # US variant above; only the reported/declared temperature unit differs. + class_name='VeSyncTurboBlazeFryer', + module=vesynckitchen, + dev_types=['CAF-P583S-KEU'], + setup_entry='CAF-P583S-KEU', + device_alias='Dual Blaze Air Fryer', + model_display='CAF-P583S Series', + model_name='Dual Blaze 6.8 Qt. Air Fryer', + temp_unit=TemperatureUnits.CELSIUS, + temperature_step_f=5, + features=[AirFryerFeatures.RESUMABLE], + cook_modes={ + AirFryerCookModes.AIRFRY: 'AirFry', + AirFryerCookModes.BROIL: 'Broil', + AirFryerCookModes.ROAST: 'Roast', + AirFryerCookModes.BAKE: 'Bake', + AirFryerCookModes.REHEAT: 'Reheat', + AirFryerCookModes.STEAK: 'Steak', + AirFryerCookModes.SEAFOOD: 'Seafood', + AirFryerCookModes.VEGGIES: 'Veggies', + AirFryerCookModes.FRENCH_FRIES: 'FrenchFries', + AirFryerCookModes.FROZEN: 'Frozen', + AirFryerCookModes.CHICKEN: 'Chicken', + }, + default_cook_mode=AirFryerCookModes.AIRFRY, + default_preset=AirFryerPresets.air_fry, + time_units=TimeUnits.SECONDS, + temperature_range_f=(175, 400), + temperature_range_c=(80, 205), + status_map=MappingProxyType( + { + 'standby': AirFryerCookStatus.STANDBY, + 'ready': AirFryerCookStatus.COOK_STOP, + 'cooking': AirFryerCookStatus.COOKING, + 'heating': AirFryerCookStatus.HEATING, + 'preheating': AirFryerCookStatus.HEATING, + 'cookStop': AirFryerCookStatus.COOK_STOP, + 'pullOut': AirFryerCookStatus.PULL_OUT, + 'cookEnd': AirFryerCookStatus.COOK_END, + 'keeping': AirFryerCookStatus.COOKING, + } + ), + ), AirFryerMap( class_name='VeSyncDualAirFryer', module=vesynckitchen, - dev_types=['CAF-TF101S-AEU', 'CAF-TF101S'], + dev_types=['CAF-TF101S-AEU', 'CAF-TF101S', 'CAF-TF102S'], setup_entry='CAF-TF101S', device_alias='Dual Air Fryer', model_display='CAF-TF101S Series', model_name='Cosori Dual Air Fryer', + temp_unit=TemperatureUnits.CELSIUS, temperature_step_f=5, features=[AirFryerFeatures.DUAL_CHAMBER], cook_modes={ diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index 8da4d2a..a2bffcd 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -207,10 +207,7 @@ def _build_base_request( cook_base['recipeType'] = recipe.recipe_type cook_base['accountId'] = self.manager.account_id - if self.temp_unit is not None: - cook_base['tempUnit'] = self.temp_unit.label - else: - cook_base['tempUnit'] = 'fahrenheit' + cook_base['tempUnit'] = self.temp_unit.label cook_base['readyStart'] = True return cook_base @@ -277,12 +274,13 @@ async def get_details(self) -> None: ) self.state.set_standby() return None + if return_status.tempUnit is not None: + self.temp_unit = return_status.tempUnit return self.state.set_state( cook_status=self.status_map[return_status.cookStatus], cook_time=return_status.cookSetTime, cook_last_time=return_status.cookLastTime, cook_temp=return_status.cookSetTemp, - temp_unit=return_status.tempUnit, cook_mode=return_status.mode, preheat_set_time=return_status.preheatSetTime, preheat_last_time=return_status.preheatLastTime, @@ -398,10 +396,10 @@ async def set_mode( cook_mode: str | None = None, chamber: int = 1, ) -> bool: - if self.validate_temperature(cook_temp) is False: - logger.warning('Invalid cook temperature for %s', self.device_name) + prepared_temp = self.prepare_temperature(cook_temp) + if prepared_temp is None: return False - cook_temp = self.round_temperature(cook_temp) + cook_temp = prepared_temp cook_time = self.convert_time_for_api(cook_time) preset_recipe = replace(self.default_preset) preset_recipe.cook_time = cook_time @@ -489,14 +487,17 @@ async def get_details(self) -> None: self.state.set_standby() return + if resp_model.tempUnit: + self.temp_unit = resp_model.tempUnit + # currentTemp from the bypassV2 getAirfryerStatus response is the # device's hardware sensor reading, which the firmware always - # reports in Celsius regardless of resp_model.tempUnit. The + # reports in Celsius regardless of the reported tempUnit. The # tempUnit field governs only the echoed cookTemp/preheatTemp # values. Normalize so consumers can compare cook_temp and # current_temp without a unit-aware crutch. _current_temp = resp_model.currentTemp - if _current_temp is not None and resp_model.tempUnit == 'f': + if _current_temp is not None and self.temp_unit == TemperatureUnits.FAHRENHEIT: _current_temp = round(_current_temp * 9 / 5 + 32) self.state_chamber_1.set_state( @@ -504,7 +505,6 @@ async def get_details(self) -> None: cook_time=cook_step.cookSetTime, cook_last_time=cook_step.cookLastTime, cook_temp=cook_step.cookTemp, - temp_unit=resp_model.tempUnit, cook_mode=cook_step.mode, preheat_set_time=resp_model.preheatSetTime, preheat_last_time=resp_model.preheatLastTime, @@ -552,9 +552,12 @@ async def set_mode( chamber: int = 1, ) -> bool: del chamber # chamber not used for this air fryer + prepared_temp = self.prepare_temperature(cook_temp) + if prepared_temp is None: + return False recipe = replace(self.default_preset) recipe.cook_time = self.convert_time_for_api(cook_time) - recipe.target_temp = self.round_temperature(cook_temp) + recipe.target_temp = prepared_temp if preheat_time is not None: recipe.preheat_time = self.convert_time_for_api(preheat_time) return await self.set_mode_from_recipe(recipe) @@ -643,7 +646,7 @@ async def get_details(self) -> None: self.state_chamber_2.set_standby() return - self.temp_unit = TemperatureUnits.from_string(resp_model.tempUnit) + self.temp_unit = resp_model.tempUnit # Update sync state from API response self.sync_chambers = resp_model.syncType == self._SYNC_TYPE_SYNCED @@ -677,7 +680,6 @@ async def get_details(self) -> None: cook_time=status_item.cookSetTime, cook_last_time=status_item.currentRemainingTime, cook_temp=status_item.cookTemp, - temp_unit=resp_model.tempUnit, cook_mode=status_item.mode, recipe=status_item.recipeName or None, ) @@ -824,7 +826,7 @@ async def set_mode( Args: cook_time: Cooking time in seconds. - cook_temp: Cooking temperature. + cook_temp: Cooking temperature in the device's ``temp_unit``. preheat_time: Not used for this device. cook_mode: Cooking mode string (e.g. 'AirFry'). chamber: Chamber number (1=left, 2=right, 3=whole). @@ -833,12 +835,12 @@ async def set_mode( True if the command was successful. """ del preheat_time # not supported by this device - if not self.validate_temperature(cook_temp): - logger.warning('Invalid cook temperature for %s', self.device_name) + prepared_temp = self.prepare_temperature(cook_temp) + if prepared_temp is None: return False - cook_temp = self.round_temperature(cook_temp) + cook_temp = prepared_temp recipe = replace(self.default_preset) - recipe.cook_time = cook_time + recipe.cook_time = self.convert_time_for_api(cook_time) recipe.target_temp = cook_temp if cook_mode is not None: recipe.cook_mode = cook_mode diff --git a/src/tests/test_fryer_units.py b/src/tests/test_fryer_units.py new file mode 100644 index 0000000..a60e6fd --- /dev/null +++ b/src/tests/test_fryer_units.py @@ -0,0 +1,140 @@ +"""Tests for air fryer temperature unit handling. + +Verifies that device_map is the source of truth for temperature units +and steps, that devices/state operate in device units, and that the +API-reported unit overrides the map default after update(). +""" + +import inspect + +import call_json_fryers +from base_test_cases import TestBase +from defaults import build_bypass_v1_response + +from pyvesync.base_devices.fryer_base import FryerState +from pyvesync.const import AIRFRYER_STEP_F_TO_C, TemperatureUnits +from pyvesync.device_map import air_fryer_modules + + +def test_map_declares_temp_unit_and_step(): + """Every AirFryerMap entry declares a unit and a single (Fahrenheit) step.""" + for entry in air_fryer_modules: + assert isinstance(entry.temp_unit, TemperatureUnits) + assert entry.temperature_step_f > 0 + # Celsius step is derived, not declared on the map. + assert not hasattr(entry, 'temperature_step_c') + assert entry.temperature_step_f in AIRFRYER_STEP_F_TO_C + + +def test_celsius_step_derived_from_map(): + """The F-to-C step map converts 5F to 2C and 10F to 5C.""" + assert AIRFRYER_STEP_F_TO_C[5] == 2 + assert AIRFRYER_STEP_F_TO_C[10] == 5 + + +def test_region_entries_have_expected_units(): + """Region-split entries declare the correct unit.""" + units = {m.setup_entry: m.temp_unit for m in air_fryer_modules} + assert units['CS158-AF'] == TemperatureUnits.FAHRENHEIT + assert units['CAF-DC601S'] == TemperatureUnits.FAHRENHEIT + assert units['CAF-P583S'] == TemperatureUnits.FAHRENHEIT + assert units['CAF-P583S-KEU'] == TemperatureUnits.CELSIUS + assert units['CAF-TF101S'] == TemperatureUnits.CELSIUS + + +def test_dual_blaze_split_by_region(): + """KUS and KEU Dual Blaze variants are separate entries.""" + kus = [m for m in air_fryer_modules if 'CAF-P583S-KUS' in m.dev_types] + keu = [m for m in air_fryer_modules if 'CAF-P583S-KEU' in m.dev_types] + assert len(kus) == 1 + assert len(keu) == 1 + assert kus[0] is not keu[0] + + +class TestFryerUnitHandling(TestBase): + """Device/state unit behavior driven by the device map.""" + + def test_unit_seeded_from_map(self): + us_fryer = self.get_device('air_fryers', 'CAF-P583S') + eu_fryer = self.get_device('air_fryers', 'CAF-P583S-KEU') + assert us_fryer.temp_unit == TemperatureUnits.FAHRENHEIT + assert eu_fryer.temp_unit == TemperatureUnits.CELSIUS + + def test_state_exposes_device_unit(self): + eu_fryer = self.get_device('air_fryers', 'CAF-P583S-KEU') + assert eu_fryer.state.temp_unit == TemperatureUnits.CELSIUS + assert eu_fryer.state_chamber_2.temp_unit == TemperatureUnits.CELSIUS + + def test_temperature_step_follows_unit(self): + eu_fryer = self.get_device('air_fryers', 'CAF-P583S-KEU') + assert eu_fryer.temperature_step == eu_fryer.temperature_step_c + eu_fryer.temp_unit = TemperatureUnits.FAHRENHEIT + assert eu_fryer.temperature_step == eu_fryer.temperature_step_f + + def test_round_temperature_celsius_whole_steps(self): + eu_fryer = self.get_device('air_fryers', 'CAF-P583S-KEU') + # EU Dual Blaze: 5F step derives a 2C step; temps round to nearest 2C. + assert eu_fryer.temperature_step == 2 + assert eu_fryer.round_temperature(184) == 184 + assert eu_fryer.round_temperature(200) == 200 + assert eu_fryer.round_temperature(183) == 184 + assert eu_fryer.round_temperature(181) == 180 + + def test_min_max_temp_follow_unit(self): + eu_fryer = self.get_device('air_fryers', 'CAF-P583S-KEU') + assert (eu_fryer.min_temp, eu_fryer.max_temp) == (80, 205) + eu_fryer.temp_unit = TemperatureUnits.FAHRENHEIT + assert (eu_fryer.min_temp, eu_fryer.max_temp) == (175, 400) + + def test_api_unit_overrides_map_default(self): + """CAF-TF101S map default is Celsius; fixture reports fahrenheit.""" + fryer = self.get_device('air_fryers', 'CAF-TF101S') + assert fryer.temp_unit == TemperatureUnits.CELSIUS + self.mock_api.return_value = ( + call_json_fryers.DETAILS_RESPONSES_COOKING['CAF-TF101S'], + 200, + ) + self.run_in_loop(fryer.update) + assert fryer.temp_unit == TemperatureUnits.FAHRENHEIT + assert fryer.state.temp_unit == TemperatureUnits.FAHRENHEIT + + def test_api_unit_overrides_for_158(self): + fryer = self.get_device('air_fryers', 'CS158-AF') + self.mock_api.return_value = ( + call_json_fryers.DETAILS_RESPONSES_COOKING['CS158-AF'], + 200, + ) + self.run_in_loop(fryer.update) + assert fryer.temp_unit == TemperatureUnits.FAHRENHEIT + + def test_set_state_has_no_temp_unit_param(self): + """set_state must not mutate device config; unit comes via get_details.""" + params = inspect.signature(FryerState.set_state).parameters + assert 'temp_unit' not in params + + def test_turboblaze_set_mode_rejects_out_of_range(self): + """TurboBlaze previously skipped validation entirely.""" + fryer = self.get_device('air_fryers', 'CAF-DC601S') + self.mock_api.reset_mock() + result = self.run_in_loop(fryer.set_mode, cook_time=600, cook_temp=500) + assert result is False + assert self.mock_api.call_count == 0 + + def test_set_mode_rounds_before_validating(self): + """402F rounds to 400F (max) and must be accepted, not rejected.""" + fryer = self.get_device('air_fryers', 'CS158-AF') + self.mock_api.return_value = ( + build_bypass_v1_response(result_dict={}), + 200, + ) + result = self.run_in_loop(fryer.set_mode, cook_time=600, cook_temp=402) + assert result is True + assert fryer.state.cook_set_temp == 400 + + def test_dual_set_mode_rejects_out_of_range(self): + fryer = self.get_device('air_fryers', 'CAF-TF101S') + self.mock_api.reset_mock() + # Map unit is Celsius; 300C is above the 240C max. + result = self.run_in_loop(fryer.set_mode, cook_time=600, cook_temp=300) + assert result is False + assert self.mock_api.call_count == 0 From f79cea536f53c645a646cac9828da63c7f3ac1ab Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 12 Jul 2026 10:34:52 -0400 Subject: [PATCH 18/19] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index da0c515..33354d7 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ docstest/* .mcp.json .vesync_auth **/CLAUDE.md -/docs/superpowers \ No newline at end of file +/docs/superpowers +/docs/development/reviews \ No newline at end of file From 98429e911d8c2c9a571effa34ecf83eba6699f05 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sun, 12 Jul 2026 11:40:54 -0400 Subject: [PATCH 19/19] Fix tests --- .github/workflows/RunTest.yaml | 3 --- .pre-commit-config.yaml | 2 +- pyproject.toml | 4 ++-- requirements-dev.txt | 2 +- src/pyvesync/base_devices/fryer_base.py | 10 +++++----- src/pyvesync/devices/vesynckitchen.py | 4 ++-- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.github/workflows/RunTest.yaml b/.github/workflows/RunTest.yaml index 856fb89..37a9f8b 100644 --- a/.github/workflows/RunTest.yaml +++ b/.github/workflows/RunTest.yaml @@ -33,19 +33,16 @@ jobs: - name: Lint with Ruff id: ruff run: | - pip install ruff ruff check --output-format=github src/pyvesync continue-on-error: true - name: Run pylint id: pylint run: | - pip install pylint pylint src/pyvesync --output-format=text --reports=n continue-on-error: true - name: Test with pytest id: pytest run: | - pip install pytest pytest -v - name: Build docs (mkdocs) id: docs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5d4850..0e24cbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: hooks: - id: mypy - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.10 + rev: v0.15.21 hooks: - id: ruff-check args: [ --fix, --config=ruff.toml ] diff --git a/pyproject.toml b/pyproject.toml index 82cf57b..764ae30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dev = [ "pyyaml", "pylint", "mypy", - "ruff", + "ruff==0.15.21", "pre-commit", "tox", "requests", @@ -45,7 +45,7 @@ dev = [ docs = [ "mkdocstrings-python", "mkdocs", - "ruff", + "ruff==0.15.21", "Pygments", "mkdocs-material", "mkdocstrings", diff --git a/requirements-dev.txt b/requirements-dev.txt index 1bc9bf9..cab400a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,7 @@ pytest requests pytest-cov -ruff +ruff==0.15.21 pylint mypy pyyaml diff --git a/src/pyvesync/base_devices/fryer_base.py b/src/pyvesync/base_devices/fryer_base.py index d1146aa..6e0cd79 100644 --- a/src/pyvesync/base_devices/fryer_base.py +++ b/src/pyvesync/base_devices/fryer_base.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import TYPE_CHECKING from pyvesync.base_devices.vesyncbasedevice import DeviceState, VeSyncBaseDevice @@ -179,7 +179,7 @@ def cook_status(self, value: AirFryerCookStatus | None) -> None: value (AirFryerCookStatus | None): The cooking status to set. """ if value in RUNNING_STATUSES: - self.last_timestamp = datetime.now(tz=timezone.utc) + self.last_timestamp = datetime.now(tz=UTC) self._cook_status = value @property @@ -196,7 +196,7 @@ def preheat_time_remaining(self) -> int | None: return max( 0, self.preheat_last_time - - int((datetime.now(timezone.utc) - self.last_timestamp).total_seconds()), + - int((datetime.now(UTC) - self.last_timestamp).total_seconds()), ) return None @@ -215,7 +215,7 @@ def cook_time_remaining(self) -> int | None: return max( 0, self.cook_last_time - - int((datetime.now(timezone.utc) - self.last_timestamp).total_seconds()), + - int((datetime.now(UTC) - self.last_timestamp).total_seconds()), ) return None @@ -267,7 +267,7 @@ def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None: if preheat_set_time is not None: self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time) self.preheat_last_time = self.preheat_time_remaining - self.last_timestamp = datetime.now(timezone.utc) + self.last_timestamp = datetime.now(UTC) def set_cooking_state( self, *, recipe: str, cook_set_time: int, cook_temp: int, cook_mode: str diff --git a/src/pyvesync/devices/vesynckitchen.py b/src/pyvesync/devices/vesynckitchen.py index a2bffcd..faa8b96 100644 --- a/src/pyvesync/devices/vesynckitchen.py +++ b/src/pyvesync/devices/vesynckitchen.py @@ -538,8 +538,8 @@ async def set_mode_from_recipe(self, recipe: AirFryerPresetRecipe) -> bool: cook_last_time=recipe.cook_time, cook_temp=recipe.target_temp, cook_mode=recipe.cook_mode, - preheat_set_time=recipe.preheat_time if recipe.preheat_time else None, - preheat_last_time=recipe.preheat_time if recipe.preheat_time else None, + preheat_set_time=recipe.preheat_time or None, + preheat_last_time=recipe.preheat_time or None, ) return True