Files
rikku-home-assistant/custom_components/localtuya/climate.py
T
2025-04-21 21:03:30 -04:00

580 lines
21 KiB
Python

"""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)