"""Platform to locally control Tuya-based climate devices.""" import asyncio from enum import StrEnum import logging from functools import partial from .config_flow import col_to_select from homeassistant.helpers.selector import ObjectSelector import voluptuous as vol from homeassistant.components.climate import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, ClimateEntity, ) from homeassistant.components.climate.const import ( HVACMode, HVACAction, PRESET_AWAY, PRESET_ECO, PRESET_HOME, PRESET_NONE, ClimateEntityFeature, ) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_TEMPERATURE_UNIT, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE, UnitOfTemperature, ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .entity import LocalTuyaEntity, async_setup_entry from .const import ( CONF_CURRENT_TEMPERATURE_DP, CONF_ECO_DP, CONF_ECO_VALUE, CONF_HEURISTIC_ACTION, CONF_HVAC_ACTION_DP, CONF_HVAC_ACTION_SET, CONF_HVAC_MODE_DP, CONF_HVAC_MODE_SET, CONF_PRECISION, CONF_PRESET_DP, CONF_PRESET_SET, CONF_TARGET_PRECISION, CONF_TARGET_TEMPERATURE_DP, CONF_TEMPERATURE_STEP, CONF_MIN_TEMP, CONF_MAX_TEMP, CONF_HVAC_ADD_OFF, CONF_FAN_SPEED_DP, CONF_FAN_SPEED_LIST, CONF_SWING_MODE_DP, CONF_SWING_MODES, CONF_SWING_HORIZONTAL_DP, CONF_SWING_HORIZONTAL_MODES, DictSelector, ) _LOGGER = logging.getLogger(__name__) HVAC_OFF = {HVACMode.OFF.value: "off"} # Migrate to 3 RENAME_HVAC_MODE_SETS = { # Migrate to 3 ("manual", "Manual", "hot", "m", "True"): HVACMode.HEAT.value, ("auto", "0", "p", "Program"): HVACMode.AUTO.value, ("freeze", "cold", "1"): HVACMode.COOL.value, ("wet"): HVACMode.DRY.value, } RENAME_ACTION_SETS = { # Migrate to 3 ("open", "opened", "heating", "Heat", "True"): HVACAction.HEATING.value, ("closed", "close", "no_heating"): HVACAction.IDLE.value, ("Warming", "warming", "False"): HVACAction.IDLE.value, ("cooling"): HVACAction.COOLING.value, ("off"): HVACAction.OFF.value, } RENAME_PRESET_SETS = { # Migrate to 3 "Holiday": (PRESET_AWAY), "Program": (PRESET_HOME), "Manual": (PRESET_NONE, "manual"), "Auto": "auto", "Manual": "manual", "Smart": "smart", "Comfort": "comfortable", "ECO": "eco", } HVAC_MODE_SETS = { HVACMode.OFF: False, HVACMode.AUTO: "auto", HVACMode.COOL: "cold", HVACMode.HEAT: "hot", HVACMode.HEAT_COOL: "heat", HVACMode.DRY: "wet", HVACMode.FAN_ONLY: "wind", } HVAC_ACTION_SETS = { HVACAction.HEATING: "opened", HVACAction.IDLE: "closed", } class SupportedTemps(StrEnum): C = "celsius" F = "fahrenheit" C_F = f"celsius/fahrenheit" F_C = f"fahrenheit/celsius" SUPPORTED_TEMPERATURES = { UnitOfTemperature.CELSIUS: SupportedTemps.C, UnitOfTemperature.FAHRENHEIT: SupportedTemps.F, f"Target Temperature: {UnitOfTemperature.CELSIUS} | Current Temperature {UnitOfTemperature.FAHRENHEIT}": SupportedTemps.C_F, f"Current Temperature {UnitOfTemperature.CELSIUS} | Target Temperature: {UnitOfTemperature.FAHRENHEIT} ": SupportedTemps.F_C, } SUPPORTED_PRECISIONS = [0.01, PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] DEFAULT_TEMPERATURE_UNIT = SupportedTemps.C DEFAULT_PRECISION = PRECISION_TENTHS DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES # Empirically tested to work for AVATTO thermostat MODE_WAIT = 0.1 FAN_SPEEDS_DEFAULT = "auto,low,middle,high" def flow_schema(dps): """Return schema used in config flow.""" return { vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_TEMPERATURE_STEP): col_to_select( [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] ), vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select( SUPPORTED_PRECISIONS ), vol.Optional( CONF_TARGET_PRECISION, default=str(DEFAULT_PRECISION) ): col_to_select(SUPPORTED_PRECISIONS), vol.Optional(CONF_HVAC_MODE_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_HVAC_MODE_SET, default=HVAC_MODE_SETS): ObjectSelector(), vol.Optional(CONF_HVAC_ACTION_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_HVAC_ACTION_SET, default=HVAC_ACTION_SETS): ObjectSelector(), vol.Optional(CONF_ECO_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_ECO_VALUE): str, vol.Optional(CONF_PRESET_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_PRESET_SET, default={}): ObjectSelector(), vol.Optional(CONF_SWING_MODE_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_SWING_MODES, default={}): ObjectSelector(), vol.Optional(CONF_SWING_HORIZONTAL_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_SWING_HORIZONTAL_MODES, default={}): ObjectSelector(), vol.Optional(CONF_FAN_SPEED_DP): col_to_select(dps, is_dps=True), vol.Optional(CONF_FAN_SPEED_LIST, default=FAN_SPEEDS_DEFAULT): str, vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select(SUPPORTED_TEMPERATURES), vol.Optional(CONF_HEURISTIC_ACTION): bool, } # Converters def f_to_c(num): return (num - 32) * 5 / 9 if num else num def c_to_f(num): return (num * 1.8) + 32 if num else num def config_unit(unit): if unit == SupportedTemps.F: return UnitOfTemperature.FAHRENHEIT else: return UnitOfTemperature.CELSIUS class LocalTuyaClimate(LocalTuyaEntity, ClimateEntity): """Tuya climate device.""" _enable_turn_on_off_backwards_compatibility = False def __init__( self, device, config_entry, switchid, **kwargs, ): """Initialize a new LocalTuyaClimate.""" super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) self._state = None self._state_on, self._state_off = True, False self._target_temperature = None self._target_temp_forced_to_celsius = None self._current_temperature = None self._hvac_mode: HVACMode | None = None self._hvac_action: HVACAction | None = None self._preset_mode: str | None = None self._swing_mode: str | None = None self._swing_horizontal_mode: str | None = None self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION)) self._precision_target = float( self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION) ) # HVAC Modes self._hvac_mode_dp = self._config.get(CONF_HVAC_MODE_DP) if hvac_modes := self._config.get(CONF_HVAC_MODE_SET, {}): # HA HVAC Modes are all lower case. hvac_modes = {k.lower(): v for k, v in hvac_modes.copy().items()} self._preset_dp = self._config.get(CONF_PRESET_DP) preset_set: dict = self._config.get(CONF_PRESET_SET, {}) # Sort Modes If the HVAC isn't supported by HA then we add it as preset. if self._preset_dp == self._hvac_mode_dp or not self._preset_dp: for k in hvac_modes.copy().keys(): if k not in HVACMode: self._preset_dp = self._hvac_mode_dp preset_set[k] = hvac_modes.pop(k) self._preset_set = DictSelector(preset_set) self._hvac_mode_set = DictSelector(hvac_modes, reverse=True) # HVAC Actions self._hvac_action_dp = self._config.get(CONF_HVAC_ACTION_DP) if actions_set := self._config.get(CONF_HVAC_ACTION_SET, {}): actions_set = {k.lower(): v for k, v in actions_set.copy().items()} self._hvac_action_set = DictSelector(actions_set, reverse=True) # Fan self._fan_speed_dp = self._config.get(CONF_FAN_SPEED_DP) if fan_speeds := self._config.get(CONF_FAN_SPEED_LIST, []): fan_speeds = [v.lstrip() for v in fan_speeds.split(",")] self._fan_supported_speeds = fan_speeds # Swing configurations. self._swing_v_mode_dp = self._config.get(CONF_SWING_MODE_DP) self._swing_v_modes = DictSelector(self._config.get(CONF_SWING_MODES, {})) self._swing_h_mode_dp = self._config.get(CONF_SWING_HORIZONTAL_DP) self._swing_h_modes = DictSelector( self._config.get(CONF_SWING_HORIZONTAL_MODES, {}) ) # Eco!? self._eco_dp = self._config.get(CONF_ECO_DP) self._eco_value = self._config.get(CONF_ECO_VALUE, "ECO") self._has_presets = self._eco_dp or (self._preset_dp and self._preset_set) self._min_temp = self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP) self._max_temp = self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP) # Temperature unit config_temp_unit = self._config.get(CONF_TEMPERATURE_UNIT, "") target_unit, *current_unit = config_temp_unit.split("/") set_temp_unit = UnitOfTemperature.CELSIUS if current_unit: self._target_temp_forced_to_celsius = target_unit == SupportedTemps.F if self._target_temp_forced_to_celsius: self._min_temp = f_to_c(self._min_temp) self._max_temp = f_to_c(self._max_temp) else: set_temp_unit = config_unit(config_temp_unit) self._temperature_unit = set_temp_unit @property def _is_on(self): """Return if the device is on.""" return self._state and self._state != self._state_off @property def supported_features(self): """Flag supported features.""" supported_features = ClimateEntityFeature(0) if self.has_config(CONF_TARGET_TEMPERATURE_DP): supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if self._has_presets: supported_features |= ClimateEntityFeature.PRESET_MODE if self._fan_speed_dp and self._fan_supported_speeds: supported_features |= ClimateEntityFeature.FAN_MODE if self._swing_v_mode_dp and self._swing_v_modes: supported_features |= ClimateEntityFeature.SWING_MODE if self._swing_h_mode_dp and self._swing_h_modes: supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE supported_features |= ClimateEntityFeature.TURN_OFF supported_features |= ClimateEntityFeature.TURN_ON return supported_features @property def precision(self): """Return the precision of the system.""" return self._precision @property def temperature_unit(self): """Return the unit of measurement used by the platform.""" return self._temperature_unit @property def min_temp(self): """Return the minimum temperature.""" # DEFAULT_MIN_TEMP is in C return self._min_temp @property def max_temp(self): """Return the maximum temperature.""" # DEFAULT_MAX_TEMP is in C return self._max_temp @property def hvac_mode(self): """Return current operation ie. heat, cool, idle.""" if not self._is_on: return HVACMode.OFF if not self._hvac_mode_dp: return HVACMode.HEAT return self._hvac_mode @property def hvac_modes(self): """Return the list of available operation modes.""" if not self.has_config(CONF_HVAC_MODE_DP): return [HVACMode.OFF] modes = self._hvac_mode_set.names if self._config.get(CONF_HVAC_ADD_OFF, True) and HVACMode.OFF not in modes: modes.append(HVACMode.OFF) return modes @property def hvac_action(self): """Return the current running hvac operation if supported.""" if not self._is_on: return HVACAction.OFF hvac_action = self._hvac_action hvac_mode = self._hvac_mode if ( (self._config.get(CONF_HEURISTIC_ACTION) or not self._hvac_action_dp) and (target_temperature := self._target_temperature) is not None and (current_temperature := self._current_temperature) is not None ): # This function assumes that action changes based on target step different from current. target_step = self.target_temperature_step is_heating = current_temperature < (target_temperature - target_step) is_cooling = current_temperature > (target_temperature + target_step) if hvac_mode == HVACMode.HEAT: if is_heating: hvac_action = HVACAction.HEATING elif current_temperature >= target_temperature: hvac_action = HVACAction.IDLE elif hvac_mode == HVACMode.COOL: if is_cooling: hvac_action = HVACAction.COOLING elif current_temperature <= target_temperature: hvac_action = HVACAction.IDLE elif hvac_mode == HVACMode.HEAT_COOL: if is_heating: hvac_action = HVACAction.HEATING elif is_cooling: hvac_action = HVACAction.COOLING elif current_temperature == target_temperature: hvac_action = HVACAction.IDLE elif hvac_mode == HVACMode.DRY: hvac_action = HVACAction.DRYING elif hvac_mode == HVACMode.FAN_ONLY: hvac_action = HVACAction.FAN self._hvac_action = hvac_action return hvac_action @property def preset_mode(self): """Return current preset.""" mode = self.dp_value(CONF_HVAC_MODE_DP) if self._preset_dp == self._hvac_mode_dp and ( mode in self._hvac_mode_set.values ): return None return self._preset_mode @property def preset_modes(self): """Return the list of available presets modes.""" if not self._has_presets: return None presets = self._preset_set.names if self._eco_dp: presets.append(PRESET_ECO) return presets @property def current_temperature(self): """Return the current temperature.""" return self._current_temperature @property def target_temperature(self): """Return the temperature we try to reach.""" return self._target_temperature @property def target_temperature_step(self): """Return the supported step of target temperature.""" target_step = self._config.get(CONF_TEMPERATURE_STEP, DEFAULT_TEMPERATURE_STEP) return float(target_step) @property def fan_mode(self): """Return the fan setting.""" if not (fan_value := self.dp_value(self._fan_speed_dp)): return None return fan_value @property def fan_modes(self) -> list: """Return the list of available fan modes.""" return self._fan_supported_speeds @property def swing_mode(self) -> str | None: """Return the swing setting.""" return self._swing_mode @property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" return self._swing_v_modes.names @property def swing_horizontal_mode(self) -> str | None: """Return the horizontal swing setting.""" return self._swing_horizontal_mode @property def swing_horizontal_modes(self) -> list[str] | None: """Return the list of available horizontal swing modes.""" return self._swing_h_modes.names async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" await self._device.set_dp( self._swing_v_modes.to_tuya(swing_mode), self._swing_v_mode_dp ) async def async_set_swing_horizontal_mode(self, swing_mode): """Set new target horizontal swing operation.""" await self._device.set_dp( self._swing_h_modes.to_tuya(swing_mode), self._swing_h_mode_dp ) async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP): temperature = kwargs[ATTR_TEMPERATURE] if self._target_temp_forced_to_celsius: # Revert Temperature to Fahrenheit it was forced to celsius temperature = round(c_to_f(temperature)) temperature = round(temperature / self._precision_target) await self._device.set_dp( temperature, self._config[CONF_TARGET_TEMPERATURE_DP] ) async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" if not self._is_on: await self._device.set_dp(self._state_on, self._dp_id) await self._device.set_dp(fan_mode, self._fan_speed_dp) async def async_set_hvac_mode(self, hvac_mode: HVACMode): """Set new target operation mode.""" new_states = {} if not self._is_on: new_states[self._dp_id] = self._state_on if hvac_mode in self._hvac_mode_set.names: new_states[self._hvac_mode_dp] = self._hvac_mode_set.to_tuya(hvac_mode) elif hvac_mode == HVACMode.OFF: new_states[self._dp_id] = self._state_off await self._device.set_dps(new_states) async def async_turn_on(self) -> None: """Turn the entity on.""" await self._device.set_dp(self._state_on, self._dp_id) async def async_turn_off(self) -> None: """Turn the entity off.""" await self._device.set_dp(self._state_off, self._dp_id) async def async_set_preset_mode(self, preset_mode): """Set new target preset mode.""" if preset_mode == PRESET_ECO: await self._device.set_dp(self._eco_value, self._eco_dp) return preset_value = self._preset_set.to_tuya(preset_mode) await self._device.set_dp(preset_value, self._preset_dp) def connection_made(self): """The connection has made with the device and status retrieved. configure entity based on it.""" super().connection_made() match self.dp_value(self._dp_id): case str() as v if v.lower() in ("on", "off"): self._state_on = "ON" if v.isupper() else "on" self._state_off = "OFF" if v.isupper() else "off" case int() as v if not isinstance(v, bool) and v in (0, 1): self._state_on = 1 self._state_off = 0 def status_updated(self): """Device status was updated.""" self._state = self.dp_value(self._dp_id) # Update target temperature if self.has_config(CONF_TARGET_TEMPERATURE_DP) and ( target_dp_value := self.dp_value(CONF_TARGET_TEMPERATURE_DP) ): self._target_temperature = target_dp_value * self._precision_target # Update current temperature if self.has_config(CONF_CURRENT_TEMPERATURE_DP) and ( current_dp_temp := self.dp_value(CONF_CURRENT_TEMPERATURE_DP) ): self._current_temperature = current_dp_temp * self._precision # Force the Current temperature and Target temperature to matching the unit. if self._target_temp_forced_to_celsius: self._target_temperature = f_to_c(self._target_temperature) elif self._target_temp_forced_to_celsius is False: self._current_temperature = f_to_c(self._current_temperature) # Update preset states if self._has_presets: if self.dp_value(CONF_ECO_DP) == self._eco_value: self._preset_mode = PRESET_ECO else: self._preset_mode = self._preset_set.to_ha( self.dp_value(CONF_PRESET_DP), PRESET_NONE ) # Update swing actions if (swing_v := self.dp_value(self._swing_v_mode_dp)) is not None: self._swing_mode = self._swing_v_modes.to_ha(swing_v) if (swing_h := self.dp_value(self._swing_h_mode_dp)) is not None: self._swing_horizontal_mode = self._swing_h_modes.to_ha(swing_h) # If device is off there is no needs to check the states. if not self._is_on: return # Update the HVAC Mode if (mode := self.dp_value(CONF_HVAC_MODE_DP)) is not None: self._hvac_mode = self._hvac_mode_set.to_ha(mode) # Update the current action if (action := self.dp_value(CONF_HVAC_ACTION_DP)) is not None: self._hvac_action = self._hvac_action_set.to_ha(action) async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaClimate, flow_schema)