diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py new file mode 100644 index 00000000000..92f17e70af0 --- /dev/null +++ b/homeassistant/components/zha/climate.py @@ -0,0 +1,550 @@ +""" +Climate on Zigbee Home Automation networks. + +For more details on this platform, please refer to the documentation +at https://home-assistant.io/components/zha.climate/ +""" +from datetime import datetime, timedelta +import enum +import functools +import logging +from random import randint +from typing import List, Optional, Tuple + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + DOMAIN, + FAN_AUTO, + FAN_ON, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util + +from .core import discovery +from .core.const import ( + CHANNEL_FAN, + CHANNEL_THERMOSTAT, + DATA_ZHA, + DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, + SIGNAL_ATTR_UPDATED, +) +from .core.registries import ZHA_ENTITIES +from .entity import ZhaEntity + +DEPENDENCIES = ["zha"] + +ATTR_SYS_MODE = "system_mode" +ATTR_RUNNING_MODE = "running_mode" +ATTR_SETPT_CHANGE_SRC = "setpoint_change_source" +ATTR_SETPT_CHANGE_AMT = "setpoint_change_amount" +ATTR_OCCUPANCY = "occupancy" +ATTR_PI_COOLING_DEMAND = "pi_cooling_demand" +ATTR_PI_HEATING_DEMAND = "pi_heating_demand" +ATTR_OCCP_COOL_SETPT = "occupied_cooling_setpoint" +ATTR_OCCP_HEAT_SETPT = "occupied_heating_setpoint" +ATTR_UNOCCP_HEAT_SETPT = "unoccupied_heating_setpoint" +ATTR_UNOCCP_COOL_SETPT = "unoccupied_cooling_setpoint" + + +STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +RUNNING_MODE = {0x00: HVAC_MODE_OFF, 0x03: HVAC_MODE_COOL, 0x04: HVAC_MODE_HEAT} + + +class ThermostatFanMode(enum.IntEnum): + """Fan channel enum for thermostat Fans.""" + + OFF = 0x00 + ON = 0x04 + AUTO = 0x05 + + +class RunningState(enum.IntFlag): + """ZCL Running state enum.""" + + HEAT = 0x0001 + COOL = 0x0002 + FAN = 0x0004 + HEAT_STAGE_2 = 0x0008 + COOL_STAGE_2 = 0x0010 + FAN_STAGE_2 = 0x0020 + FAN_STAGE_3 = 0x0040 + + +SEQ_OF_OPERATION = { + 0x00: (HVAC_MODE_OFF, HVAC_MODE_COOL), # cooling only + 0x01: (HVAC_MODE_OFF, HVAC_MODE_COOL), # cooling with reheat + 0x02: (HVAC_MODE_OFF, HVAC_MODE_HEAT), # heating only + 0x03: (HVAC_MODE_OFF, HVAC_MODE_HEAT), # heating with reheat + # cooling and heating 4-pipes + 0x04: (HVAC_MODE_OFF, HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT), + # cooling and heating 4-pipes + 0x05: (HVAC_MODE_OFF, HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_HEAT), +} + + +class SystemMode(enum.IntEnum): + """ZCL System Mode attribute enum.""" + + OFF = 0x00 + HEAT_COOL = 0x01 + COOL = 0x03 + HEAT = 0x04 + AUX_HEAT = 0x05 + PRE_COOL = 0x06 + FAN_ONLY = 0x07 + DRY = 0x08 + SLEEP = 0x09 + + +HVAC_MODE_2_SYSTEM = { + HVAC_MODE_OFF: SystemMode.OFF, + HVAC_MODE_HEAT_COOL: SystemMode.HEAT_COOL, + HVAC_MODE_COOL: SystemMode.COOL, + HVAC_MODE_HEAT: SystemMode.HEAT, + HVAC_MODE_FAN_ONLY: SystemMode.FAN_ONLY, + HVAC_MODE_DRY: SystemMode.DRY, +} + +SYSTEM_MODE_2_HVAC = { + SystemMode.OFF: HVAC_MODE_OFF, + SystemMode.HEAT_COOL: HVAC_MODE_HEAT_COOL, + SystemMode.COOL: HVAC_MODE_COOL, + SystemMode.HEAT: HVAC_MODE_HEAT, + SystemMode.AUX_HEAT: HVAC_MODE_HEAT, + SystemMode.PRE_COOL: HVAC_MODE_COOL, # this is 'precooling'. is it the same? + SystemMode.FAN_ONLY: HVAC_MODE_FAN_ONLY, + SystemMode.DRY: HVAC_MODE_DRY, + SystemMode.SLEEP: HVAC_MODE_OFF, +} + +ZCL_TEMP = 100 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation sensor from config entry.""" + entities_to_create = hass.data[DATA_ZHA][DOMAIN] + unsub = async_dispatcher_connect( + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), + ) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + +@STRICT_MATCH(channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN) +class Thermostat(ZhaEntity, ClimateEntity): + """Representation of a ZHA Thermostat device.""" + + DEFAULT_MAX_TEMP = 35 + DEFAULT_MIN_TEMP = 7 + + _domain = DOMAIN + value_attribute = 0x0000 + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._thrm = self.cluster_channels.get(CHANNEL_THERMOSTAT) + self._preset = PRESET_NONE + self._presets = [] + self._supported_flags = SUPPORT_TARGET_TEMPERATURE + self._fan = self.cluster_channels.get(CHANNEL_FAN) + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._thrm.local_temp is None: + return None + return self._thrm.local_temp / ZCL_TEMP + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + data = {} + if self.hvac_mode: + mode = SYSTEM_MODE_2_HVAC.get(self._thrm.system_mode, "unknown") + data[ATTR_SYS_MODE] = f"[{self._thrm.system_mode}]/{mode}" + if self._thrm.occupancy is not None: + data[ATTR_OCCUPANCY] = self._thrm.occupancy + if self._thrm.occupied_cooling_setpoint is not None: + data[ATTR_OCCP_COOL_SETPT] = self._thrm.occupied_cooling_setpoint + if self._thrm.occupied_heating_setpoint is not None: + data[ATTR_OCCP_HEAT_SETPT] = self._thrm.occupied_heating_setpoint + + unoccupied_cooling_setpoint = self._thrm.unoccupied_cooling_setpoint + if unoccupied_cooling_setpoint is not None: + data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_cooling_setpoint + + unoccupied_heating_setpoint = self._thrm.unoccupied_heating_setpoint + if unoccupied_heating_setpoint is not None: + data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_heating_setpoint + return data + + @property + def fan_mode(self) -> Optional[str]: + """Return current FAN mode.""" + if self._thrm.running_state is None: + return FAN_AUTO + + if self._thrm.running_state & ( + RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3 + ): + return FAN_ON + return FAN_AUTO + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return supported FAN modes.""" + if not self._fan: + return None + return [FAN_AUTO, FAN_ON] + + @property + def hvac_action(self) -> Optional[str]: + """Return the current HVAC action.""" + if ( + self._thrm.pi_heating_demand is None + and self._thrm.pi_cooling_demand is None + ): + self.debug("Running mode: %s", self._thrm.running_mode) + self.debug("Running state: %s", self._thrm.running_state) + running_state = self._thrm.running_state + if running_state is None: + return None + if running_state & (RunningState.HEAT | RunningState.HEAT_STAGE_2): + return CURRENT_HVAC_HEAT + if running_state & (RunningState.COOL | RunningState.COOL_STAGE_2): + return CURRENT_HVAC_COOL + if running_state & ( + RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3 + ): + return CURRENT_HVAC_FAN + else: + heating_demand = self._thrm.pi_heating_demand + if heating_demand is not None and heating_demand > 0: + return CURRENT_HVAC_HEAT + cooling_demand = self._thrm.pi_cooling_demand + if cooling_demand is not None and cooling_demand > 0: + return CURRENT_HVAC_COOL + if self.hvac_mode != HVAC_MODE_OFF: + return CURRENT_HVAC_IDLE + return CURRENT_HVAC_OFF + + @property + def hvac_mode(self) -> Optional[str]: + """Return HVAC operation mode.""" + try: + return SYSTEM_MODE_2_HVAC[self._thrm.system_mode] + except KeyError: + self.error( + "can't map 'system_mode: %s' to a HVAC mode", self._thrm.system_mode + ) + return None + + @property + def hvac_modes(self) -> Tuple[str, ...]: + """Return the list of available HVAC operation modes.""" + return SEQ_OF_OPERATION.get(self._thrm.ctrl_seqe_of_oper, (HVAC_MODE_OFF,)) + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_HALVES + + @property + def preset_mode(self) -> Optional[str]: + """Return current preset mode.""" + return self._preset + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return supported preset modes.""" + return self._presets + + @property + def supported_features(self): + """Return the list of supported features.""" + features = self._supported_flags + if HVAC_MODE_HEAT_COOL in self.hvac_modes: + features |= SUPPORT_TARGET_TEMPERATURE_RANGE + if self._fan is not None: + self._supported_flags |= SUPPORT_FAN_MODE + return features + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + temp = None + if self.hvac_mode == HVAC_MODE_COOL: + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_cooling_setpoint + else: + temp = self._thrm.occupied_cooling_setpoint + elif self.hvac_mode == HVAC_MODE_HEAT: + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_heating_setpoint + else: + temp = self._thrm.occupied_heating_setpoint + if temp is None: + return temp + return round(temp / ZCL_TEMP, 1) + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + if self.hvac_mode != HVAC_MODE_HEAT_COOL: + return None + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_cooling_setpoint + else: + temp = self._thrm.occupied_cooling_setpoint + + if temp is None: + return temp + + return round(temp / ZCL_TEMP, 1) + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self.hvac_mode != HVAC_MODE_HEAT_COOL: + return None + if self.preset_mode == PRESET_AWAY: + temp = self._thrm.unoccupied_heating_setpoint + else: + temp = self._thrm.occupied_heating_setpoint + + if temp is None: + return temp + return round(temp / ZCL_TEMP, 1) + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + temps = [] + if HVAC_MODE_HEAT in self.hvac_modes: + temps.append(self._thrm.max_heat_setpoint_limit) + if HVAC_MODE_COOL in self.hvac_modes: + temps.append(self._thrm.max_cool_setpoint_limit) + + if not temps: + return self.DEFAULT_MAX_TEMP + return round(max(temps) / ZCL_TEMP, 1) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + temps = [] + if HVAC_MODE_HEAT in self.hvac_modes: + temps.append(self._thrm.min_heat_setpoint_limit) + if HVAC_MODE_COOL in self.hvac_modes: + temps.append(self._thrm.min_cool_setpoint_limit) + + if not temps: + return self.DEFAULT_MIN_TEMP + return round(min(temps) / ZCL_TEMP, 1) + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + await self.async_accept_signal( + self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated + ) + + async def async_attribute_updated(self, record): + """Handle attribute update from device.""" + if ( + record.attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) + and self.preset_mode == PRESET_AWAY + ): + # occupancy attribute is an unreportable attribute, but if we get + # an attribute update for an "occupied" setpoint, there's a chance + # occupancy has changed + occupancy = await self._thrm.get_occupancy() + if occupancy is True: + self._preset = PRESET_NONE + + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + if self.fan_modes is None: + self.warning("Fan is not supported") + return + + if fan_mode not in self.fan_modes: + self.warning("Unsupported '%s' fan mode", fan_mode) + return + + if fan_mode == FAN_ON: + mode = ThermostatFanMode.ON + else: + mode = ThermostatFanMode.AUTO + + await self._fan.async_set_speed(mode) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target operation mode.""" + if hvac_mode not in self.hvac_modes: + self.warning( + "can't set '%s' mode. Supported modes are: %s", + hvac_mode, + self.hvac_modes, + ) + return + + if await self._thrm.async_set_operation_mode(HVAC_MODE_2_SYSTEM[hvac_mode]): + self.async_write_ha_state() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode not in self.preset_modes: + self.debug("preset mode '%s' is not supported", preset_mode) + return + + if self.preset_mode not in (preset_mode, PRESET_NONE): + if not await self.async_preset_handler(self.preset_mode, enable=False): + self.debug("Couldn't turn off '%s' preset", self.preset_mode) + return + + if preset_mode != PRESET_NONE: + if not await self.async_preset_handler(preset_mode, enable=True): + self.debug("Couldn't turn on '%s' preset", preset_mode) + return + self._preset = preset_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + + if hvac_mode is not None: + await self.async_set_hvac_mode(hvac_mode) + + thrm = self._thrm + if self.hvac_mode == HVAC_MODE_HEAT_COOL: + success = True + if low_temp is not None: + low_temp = int(low_temp * ZCL_TEMP) + success = success and await thrm.async_set_heating_setpoint( + low_temp, self.preset_mode == PRESET_AWAY + ) + self.debug("Setting heating %s setpoint: %s", low_temp, success) + if high_temp is not None: + high_temp = int(high_temp * ZCL_TEMP) + success = success and await thrm.async_set_cooling_setpoint( + high_temp, self.preset_mode == PRESET_AWAY + ) + self.debug("Setting cooling %s setpoint: %s", low_temp, success) + elif temp is not None: + temp = int(temp * ZCL_TEMP) + if self.hvac_mode == HVAC_MODE_COOL: + success = await thrm.async_set_cooling_setpoint( + temp, self.preset_mode == PRESET_AWAY + ) + elif self.hvac_mode == HVAC_MODE_HEAT: + success = await thrm.async_set_heating_setpoint( + temp, self.preset_mode == PRESET_AWAY + ) + else: + self.debug("Not setting temperature for '%s' mode", self.hvac_mode) + return + else: + self.debug("incorrect %s setting for '%s' mode", kwargs, self.hvac_mode) + return + + if success: + self.async_write_ha_state() + + async def async_preset_handler(self, preset: str, enable: bool = False) -> bool: + """Set the preset mode via handler.""" + + handler = getattr(self, f"async_preset_handler_{preset}") + return await handler(enable) + + +@STRICT_MATCH( + channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"}, + manufacturers="Sinope Technologies", +) +class SinopeTechnologiesThermostat(Thermostat): + """Sinope Technologies Thermostat.""" + + manufacturer = 0x119C + update_time_interval = timedelta(minutes=randint(45, 75)) + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._presets = [PRESET_AWAY, PRESET_NONE] + self._supported_flags |= SUPPORT_PRESET_MODE + self._manufacturer_ch = self.cluster_channels["sinope_manufacturer_specific"] + + @callback + def _async_update_time(self, timestamp=None) -> None: + """Update thermostat's time display.""" + + secs_2k = ( + dt_util.now().replace(tzinfo=None) - datetime(2000, 1, 1, 0, 0, 0, 0) + ).total_seconds() + + self.debug("Updating time: %s", secs_2k) + self._manufacturer_ch.cluster.create_catching_task( + self._manufacturer_ch.cluster.write_attributes( + {"secs_since_2k": secs_2k}, manufacturer=self.manufacturer + ) + ) + + async def async_added_to_hass(self): + """Run when about to be added to Hass.""" + await super().async_added_to_hass() + async_track_time_interval( + self.hass, self._async_update_time, self.update_time_interval + ) + self._async_update_time() + + async def async_preset_handler_away(self, is_away: bool = False) -> bool: + """Set occupancy.""" + mfg_code = self._zha_device.manufacturer_code + res = await self._thrm.write_attributes( + {"set_occupancy": 0 if is_away else 1}, manufacturer=mfg_code + ) + + self.debug("set occupancy to %s. Status: %s", 0 if is_away else 1, res) + return res diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 3c58ff946b9..56345916cd3 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -1,17 +1,37 @@ -"""HVAC channels module for Zigbee Home Automation.""" +""" +HVAC channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/integrations/zha/ +""" +import asyncio +from collections import namedtuple import logging +from typing import Any, Dict, List, Optional, Tuple, Union from zigpy.exceptions import ZigbeeException import zigpy.zcl.clusters.hvac as hvac +from zigpy.zcl.foundation import Status from homeassistant.core import callback -from .. import registries -from ..const import REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED +from .. import registries, typing as zha_typing +from ..const import ( + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_OP, + SIGNAL_ATTR_UPDATED, +) +from ..helpers import retryable_req from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) +AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") +REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) +REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) +REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) + @registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Dehumidification.cluster_id) class Dehumidification(ZigbeeChannel): @@ -26,6 +46,18 @@ class FanChannel(ZigbeeChannel): REPORT_CONFIG = ({"attr": "fan_mode", "config": REPORT_CONFIG_OP},) + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + ): + """Init Thermostat channel instance.""" + super().__init__(cluster, ch_pool) + self._fan_mode = None + + @property + def fan_mode(self) -> Optional[int]: + """Return current fan mode.""" + return self._fan_mode + async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" @@ -35,41 +67,390 @@ class FanChannel(ZigbeeChannel): self.error("Could not set speed: %s", ex) return - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest state.""" result = await self.get_attribute_value("fan_mode", from_cache=True) if result is not None: + self._fan_mode = result self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result ) @callback - def attribute_updated(self, attrid, value): + def attribute_updated(self, attrid: int, value: Any) -> None: """Handle attribute update from fan cluster.""" attr_name = self.cluster.attributes.get(attrid, [attrid])[0] self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: + self._fan_mode = value self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) - async def async_initialize(self, from_cache): - """Initialize channel.""" - await self.get_attribute_value(self._value_attribute, from_cache=from_cache) - await super().async_initialize(from_cache) - @registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Pump.cluster_id) class Pump(ZigbeeChannel): """Pump channel.""" +@registries.CLIMATE_CLUSTERS.register(hvac.Thermostat.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Thermostat.cluster_id) -class Thermostat(ZigbeeChannel): +class ThermostatChannel(ZigbeeChannel): """Thermostat channel.""" + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + ) -> None: + """Init Thermostat channel instance.""" + super().__init__(cluster, ch_pool) + self._init_attrs = { + "abs_min_heat_setpoint_limit": True, + "abs_max_heat_setpoint_limit": True, + "abs_min_cool_setpoint_limit": True, + "abs_max_cool_setpoint_limit": True, + "ctrl_seqe_of_oper": False, + "local_temp": False, + "max_cool_setpoint_limit": True, + "max_heat_setpoint_limit": True, + "min_cool_setpoint_limit": True, + "min_heat_setpoint_limit": True, + "occupancy": False, + "occupied_cooling_setpoint": False, + "occupied_heating_setpoint": False, + "pi_cooling_demand": False, + "pi_heating_demand": False, + "running_mode": False, + "running_state": False, + "system_mode": False, + "unoccupied_heating_setpoint": False, + "unoccupied_cooling_setpoint": False, + } + self._abs_max_cool_setpoint_limit = 3200 # 32C + self._abs_min_cool_setpoint_limit = 1600 # 16C + self._ctrl_seqe_of_oper = 0xFF + self._abs_max_heat_setpoint_limit = 3000 # 30C + self._abs_min_heat_setpoint_limit = 700 # 7C + self._running_mode = None + self._max_cool_setpoint_limit = None + self._max_heat_setpoint_limit = None + self._min_cool_setpoint_limit = None + self._min_heat_setpoint_limit = None + self._local_temp = None + self._occupancy = None + self._occupied_cooling_setpoint = None + self._occupied_heating_setpoint = None + self._pi_cooling_demand = None + self._pi_heating_demand = None + self._running_state = None + self._system_mode = None + self._unoccupied_cooling_setpoint = None + self._unoccupied_heating_setpoint = None + self._report_config = [ + {"attr": "local_temp", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, + {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, + {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + ] + + @property + def abs_max_cool_setpoint_limit(self) -> int: + """Absolute maximum cooling setpoint.""" + return self._abs_max_cool_setpoint_limit + + @property + def abs_min_cool_setpoint_limit(self) -> int: + """Absolute minimum cooling setpoint.""" + return self._abs_min_cool_setpoint_limit + + @property + def abs_max_heat_setpoint_limit(self) -> int: + """Absolute maximum heating setpoint.""" + return self._abs_max_heat_setpoint_limit + + @property + def abs_min_heat_setpoint_limit(self) -> int: + """Absolute minimum heating setpoint.""" + return self._abs_min_heat_setpoint_limit + + @property + def ctrl_seqe_of_oper(self) -> int: + """Control Sequence of operations attribute.""" + return self._ctrl_seqe_of_oper + + @property + def max_cool_setpoint_limit(self) -> int: + """Maximum cooling setpoint.""" + if self._max_cool_setpoint_limit is None: + return self.abs_max_cool_setpoint_limit + return self._max_cool_setpoint_limit + + @property + def min_cool_setpoint_limit(self) -> int: + """Minimum cooling setpoint.""" + if self._min_cool_setpoint_limit is None: + return self.abs_min_cool_setpoint_limit + return self._min_cool_setpoint_limit + + @property + def max_heat_setpoint_limit(self) -> int: + """Maximum heating setpoint.""" + if self._max_heat_setpoint_limit is None: + return self.abs_max_heat_setpoint_limit + return self._max_heat_setpoint_limit + + @property + def min_heat_setpoint_limit(self) -> int: + """Minimum heating setpoint.""" + if self._min_heat_setpoint_limit is None: + return self.abs_min_heat_setpoint_limit + return self._min_heat_setpoint_limit + + @property + def local_temp(self) -> Optional[int]: + """Thermostat temperature.""" + return self._local_temp + + @property + def occupancy(self) -> Optional[int]: + """Is occupancy detected.""" + return self._occupancy + + @property + def occupied_cooling_setpoint(self) -> Optional[int]: + """Temperature when room is occupied.""" + return self._occupied_cooling_setpoint + + @property + def occupied_heating_setpoint(self) -> Optional[int]: + """Temperature when room is occupied.""" + return self._occupied_heating_setpoint + + @property + def pi_cooling_demand(self) -> int: + """Cooling demand.""" + return self._pi_cooling_demand + + @property + def pi_heating_demand(self) -> int: + """Heating demand.""" + return self._pi_heating_demand + + @property + def running_mode(self) -> Optional[int]: + """Thermostat running mode.""" + return self._running_mode + + @property + def running_state(self) -> Optional[int]: + """Thermostat running state, state of heat, cool, fan relays.""" + return self._running_state + + @property + def system_mode(self) -> Optional[int]: + """System mode.""" + return self._system_mode + + @property + def unoccupied_cooling_setpoint(self) -> Optional[int]: + """Temperature when room is not occupied.""" + return self._unoccupied_cooling_setpoint + + @property + def unoccupied_heating_setpoint(self) -> Optional[int]: + """Temperature when room is not occupied.""" + return self._unoccupied_heating_setpoint + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute update cluster.""" + attr_name = self.cluster.attributes.get(attrid, [attrid])[0] + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + setattr(self, f"_{attr_name}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + AttributeUpdateRecord(attrid, attr_name, value), + ) + + async def _chunk_attr_read(self, attrs, cached=False): + chunk, attrs = attrs[:4], attrs[4:] + while chunk: + res, fail = await self.cluster.read_attributes(chunk, allow_cache=cached) + self.debug("read attributes: Success: %s. Failed: %s", res, fail) + for attr in chunk: + self._init_attrs.pop(attr, None) + if attr in fail: + continue + if isinstance(attr, str): + setattr(self, f"_{attr}", res[attr]) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + AttributeUpdateRecord(None, attr, res[attr]), + ) + + chunk, attrs = attrs[:4], attrs[4:] + + async def configure_reporting(self): + """Configure attribute reporting for a cluster. + + This also swallows DeliveryError exceptions that are thrown when + devices are unreachable. + """ + kwargs = {} + if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: + kwargs["manufacturer"] = self._ch_pool.manufacturer_code + + chunk, rest = self._report_config[:4], self._report_config[4:] + while chunk: + attrs = {record["attr"]: record["config"] for record in chunk} + try: + res = await self.cluster.configure_reporting_multiple(attrs, **kwargs) + self._configure_reporting_status(attrs, res[0]) + except (ZigbeeException, asyncio.TimeoutError) as ex: + self.debug( + "failed to set reporting on '%s' cluster for: %s", + self.cluster.ep_attribute, + str(ex), + ) + break + chunk, rest = rest[:4], rest[4:] + + def _configure_reporting_status( + self, attrs: Dict[Union[int, str], Tuple], res: Union[List, Tuple] + ) -> None: + """Parse configure reporting result.""" + if not isinstance(res, list): + # assume default response + self.debug( + "attr reporting for '%s' on '%s': %s", attrs, self.name, res, + ) + return + if res[0].status == Status.SUCCESS and len(res) == 1: + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster: %s", + attrs, + self.name, + res, + ) + return + + failed = [ + self.cluster.attributes.get(r.attrid, [r.attrid])[0] + for r in res + if r.status != Status.SUCCESS + ] + attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} + self.debug( + "Successfully configured reporting for '%s' on '%s' cluster", + attrs - set(failed), + self.name, + ) + self.debug( + "Failed to configure reporting for '%s' on '%s' cluster: %s", + failed, + self.name, + res, + ) + + @retryable_req(delays=(1, 1, 3)) + async def async_initialize(self, from_cache): + """Initialize channel.""" + + cached = [a for a, cached in self._init_attrs.items() if cached] + uncached = [a for a, cached in self._init_attrs.items() if not cached] + + await self._chunk_attr_read(cached, cached=True) + await self._chunk_attr_read(uncached, cached=False) + await super().async_initialize(from_cache) + + async def async_set_operation_mode(self, mode) -> bool: + """Set Operation mode.""" + if not await self.write_attributes({"system_mode": mode}): + self.debug("couldn't set '%s' operation mode", mode) + return False + + self._system_mode = mode + self.debug("set system to %s", mode) + return True + + async def async_set_heating_setpoint( + self, temperature: int, is_away: bool = False + ) -> bool: + """Set heating setpoint.""" + if is_away: + data = {"unoccupied_heating_setpoint": temperature} + else: + data = {"occupied_heating_setpoint": temperature} + if not await self.write_attributes(data): + self.debug("couldn't set heating setpoint") + return False + + if is_away: + self._unoccupied_heating_setpoint = temperature + else: + self._occupied_heating_setpoint = temperature + self.debug("set heating setpoint to %s", temperature) + return True + + async def async_set_cooling_setpoint( + self, temperature: int, is_away: bool = False + ) -> bool: + """Set cooling setpoint.""" + if is_away: + data = {"unoccupied_cooling_setpoint": temperature} + else: + data = {"occupied_cooling_setpoint": temperature} + if not await self.write_attributes(data): + self.debug("couldn't set cooling setpoint") + return False + if is_away: + self._unoccupied_cooling_setpoint = temperature + else: + self._occupied_cooling_setpoint = temperature + self.debug("set cooling setpoint to %s", temperature) + return True + + async def get_occupancy(self) -> Optional[bool]: + """Get unreportable occupancy attribute.""" + try: + res, fail = await self.cluster.read_attributes(["occupancy"]) + self.debug("read 'occupancy' attr, success: %s, fail: %s", res, fail) + if "occupancy" not in res: + return None + self._occupancy = res["occupancy"] + return bool(self.occupancy) + except ZigbeeException as ex: + self.debug("Couldn't read 'occupancy' attribute: %s", ex) + + async def write_attributes(self, data, **kwargs): + """Write attributes helper.""" + try: + res = await self.cluster.write_attributes(data, **kwargs) + except ZigbeeException as exc: + self.debug("couldn't write %s: %s", data, exc) + return False + + self.debug("wrote %s attrs, Status: %s", data, res) + return self.check_result(res) + + @staticmethod + def check_result(res: list) -> bool: + """Normalize the result.""" + if not isinstance(res, list): + return False + + return all([record.status == Status.SUCCESS for record in res[0]]) + @registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.UserInterface.cluster_id) class UserInterface(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 08252ab1c97..6bc93354af7 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -11,6 +11,7 @@ import zigpy_xbee.zigbee.application import zigpy_zigate.zigbee.application from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.climate import DOMAIN as CLIMATE from homeassistant.components.cover import DOMAIN as COVER from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.fan import DOMAIN as FAN @@ -87,6 +88,7 @@ CHANNEL_POWER_CONFIGURATION = "power" CHANNEL_PRESSURE = "pressure" CHANNEL_SMARTENERGY_METERING = "smartenergy_metering" CHANNEL_TEMPERATURE = "temperature" +CHANNEL_THERMOSTAT = "thermostat" CHANNEL_ZDO = "zdo" CHANNEL_ZONE = ZONE = "ias_zone" @@ -96,7 +98,17 @@ CLUSTER_COMMANDS_SERVER = "server_commands" CLUSTER_TYPE_IN = "in" CLUSTER_TYPE_OUT = "out" -COMPONENTS = (BINARY_SENSOR, COVER, DEVICE_TRACKER, FAN, LIGHT, LOCK, SENSOR, SWITCH) +COMPONENTS = ( + BINARY_SENSOR, + CLIMATE, + COVER, + DEVICE_TRACKER, + FAN, + LIGHT, + LOCK, + SENSOR, + SWITCH, +) CONF_BAUDRATE = "baudrate" CONF_DATABASE = "database_path" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index f72ac2161ec..25f320b0bf1 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -16,6 +16,7 @@ from homeassistant.helpers.typing import HomeAssistantType from . import const as zha_const, registries as zha_regs, typing as zha_typing from .. import ( # noqa: F401 pylint: disable=unused-import, binary_sensor, + climate, cover, device_tracker, fan, diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index bb8a202e789..7813c7133ad 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -1,8 +1,19 @@ -"""Helpers for Zigbee Home Automation.""" +""" +Helpers for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/integrations/zha/ +""" + +import asyncio import collections +import functools +import itertools import logging +from random import uniform from typing import Any, Callable, Iterator, List, Optional +import zigpy.exceptions import zigpy.types from homeassistant.core import State, callback @@ -147,3 +158,50 @@ class LogMixin: def error(self, msg, *args): """Error level log.""" return self.log(logging.ERROR, msg, *args) + + +def retryable_req( + delays=(1, 5, 10, 15, 30, 60, 120, 180, 360, 600, 900, 1800), raise_=False +): + """Make a method with ZCL requests retryable. + + This adds delays keyword argument to function. + len(delays) is number of tries. + raise_ if the final attempt should raise the exception. + """ + + def decorator(func): + @functools.wraps(func) + async def wrapper(channel, *args, **kwargs): + + exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) + try_count, errors = 1, [] + for delay in itertools.chain(delays, [None]): + try: + return await func(channel, *args, **kwargs) + except exceptions as ex: + errors.append(ex) + if delay: + delay = uniform(delay * 0.75, delay * 1.25) + channel.debug( + ( + "%s: retryable request #%d failed: %s. " + "Retrying in %ss" + ), + func.__name__, + try_count, + ex, + round(delay, 1), + ) + try_count += 1 + await asyncio.sleep(delay) + else: + channel.warning( + "%s: all attempts have failed: %s", func.__name__, errors + ) + if raise_: + raise + + return wrapper + + return decorator diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 6ddf48de5aa..4b68b9675a9 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -8,6 +8,7 @@ import zigpy.profiles.zll import zigpy.zcl as zcl from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR +from homeassistant.components.climate import DOMAIN as CLIMATE from homeassistant.components.cover import DOMAIN as COVER from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.fan import DOMAIN as FAN @@ -84,11 +85,13 @@ BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER) BINDABLE_CLUSTERS = SetRegistry() CHANNEL_ONLY_CLUSTERS = SetRegistry() +CLIMATE_CLUSTERS = SetRegistry() CUSTOM_CLUSTER_MAPPINGS = {} DEVICE_CLASS = { zigpy.profiles.zha.PROFILE_ID: { SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: DEVICE_TRACKER, + zigpy.profiles.zha.DeviceType.THERMOSTAT: CLIMATE, zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT, zigpy.profiles.zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, zigpy.profiles.zha.DeviceType.DIMMABLE_BALLAST: LIGHT, @@ -120,6 +123,7 @@ CLIENT_CHANNELS_REGISTRY = DictRegistry() COMPONENT_CLUSTERS = { BINARY_SENSOR: BINARY_SENSOR_CLUSTERS, + CLIMATE: CLIMATE_CLUSTERS, DEVICE_TRACKER: DEVICE_TRACKER_CLUSTERS, LIGHT: LIGHT_CLUSTERS, SWITCH: SWITCH_CLUSTERS, diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index f10ee25018f..fd5621137ae 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -75,7 +75,9 @@ def patch_cluster(cluster): cluster.read_attributes = AsyncMock(return_value=[{}, {}]) cluster.read_attributes_raw = Mock() cluster.unbind = AsyncMock(return_value=[0]) - cluster.write_attributes = AsyncMock(return_value=[0]) + cluster.write_attributes = AsyncMock( + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]] + ) if cluster.cluster_id == 4: cluster.add = AsyncMock(return_value=[0]) diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py new file mode 100644 index 00000000000..81050dc63fb --- /dev/null +++ b/tests/components/zha/test_climate.py @@ -0,0 +1,1060 @@ +"""Test zha climate.""" +import logging + +import pytest +import zigpy.zcl.clusters +from zigpy.zcl.clusters.hvac import Thermostat +import zigpy.zcl.foundation as zcl_f + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + FAN_AUTO, + FAN_LOW, + FAN_ON, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_NONE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.zha.climate import ( + DOMAIN, + HVAC_MODE_2_SYSTEM, + SEQ_OF_OPERATION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNKNOWN + +from .common import async_enable_traffic, find_entity_id, send_attributes_report + +from tests.async_mock import patch + +CLIMATE = { + 1: { + "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, + "in_clusters": [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + ], + "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +CLIMATE_FAN = { + 1: { + "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, + "in_clusters": [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Fan.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + ], + "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +CLIMATE_SINOPE = { + 1: { + "device_type": zigpy.profiles.zha.DeviceType.THERMOSTAT, + "in_clusters": [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + 65281, + ], + "out_clusters": [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], + "profile_id": 260, + }, +} +SINOPE = "Sinope Technologies" + +ZCL_ATTR_PLUG = { + "abs_min_heat_setpoint_limit": 800, + "abs_max_heat_setpoint_limit": 3000, + "abs_min_cool_setpoint_limit": 2000, + "abs_max_cool_setpoint_limit": 4000, + "ctrl_seqe_of_oper": Thermostat.ControlSequenceOfOperation.Cooling_and_Heating, + "local_temp": None, + "max_cool_setpoint_limit": 3900, + "max_heat_setpoint_limit": 2900, + "min_cool_setpoint_limit": 2100, + "min_heat_setpoint_limit": 700, + "occupancy": 1, + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2200, + "pi_cooling_demand": None, + "pi_heating_demand": None, + "running_mode": Thermostat.RunningMode.Off, + "running_state": None, + "system_mode": Thermostat.SystemMode.Off, + "unoccupied_heating_setpoint": 2200, + "unoccupied_cooling_setpoint": 2300, +} + + +@pytest.fixture +def device_climate_mock(hass, zigpy_device_mock, zha_device_joined): + """Test regular thermostat device.""" + + async def _dev(clusters, plug=None, manuf=None): + if plug is None: + plugged_attrs = ZCL_ATTR_PLUG + else: + plugged_attrs = {**ZCL_ATTR_PLUG, **plug} + + async def _read_attr(attrs, *args, **kwargs): + res = {} + failed = {} + + for attr in attrs: + if attr in plugged_attrs: + res[attr] = plugged_attrs[attr] + else: + failed[attr] = zcl_f.Status.UNSUPPORTED_ATTRIBUTE + return res, failed + + zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf) + zigpy_device.endpoints[1].thermostat.read_attributes.side_effect = _read_attr + zha_device = await zha_device_joined(zigpy_device) + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + return zha_device + + return _dev + + +@pytest.fixture +async def device_climate(device_climate_mock): + """Plain Climate device.""" + + return await device_climate_mock(CLIMATE) + + +@pytest.fixture +async def device_climate_fan(device_climate_mock): + """Test thermostat with fan device.""" + + return await device_climate_mock(CLIMATE_FAN) + + +@pytest.fixture +@patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", +) +async def device_climate_sinope(device_climate_mock): + """Sinope thermostat.""" + + return await device_climate_mock(CLIMATE_SINOPE, manuf=SINOPE) + + +def test_sequence_mappings(): + """Test correct mapping between control sequence -> HVAC Mode -> Sysmode.""" + + for hvac_modes in SEQ_OF_OPERATION.values(): + for hvac_mode in hvac_modes: + assert hvac_mode in HVAC_MODE_2_SYSTEM + assert Thermostat.SystemMode(HVAC_MODE_2_SYSTEM[hvac_mode]) is not None + + +async def test_climate_local_temp(hass, device_climate): + """Test local temperature.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None + + await send_attributes_report(hass, thrm_cluster, {0: 2100}) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 + + +async def test_climate_hvac_action_running_state(hass, device_climate): + """Test hvac action via running state.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + + state = hass.states.get(entity_id) + assert ATTR_HVAC_ACTION not in state.attributes + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_2nd_Stage_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_2nd_Stage_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_2nd_Stage_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_State_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_3rd_Stage_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_State_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + await send_attributes_report( + hass, thrm_cluster, {0x0029: Thermostat.RunningState.Idle} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + await send_attributes_report( + hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + +async def test_climate_hvac_action_pi_demand(hass, device_climate): + """Test hvac action based on pi_heating/cooling_demand attrs.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + + state = hass.states.get(entity_id) + assert ATTR_HVAC_ACTION not in state.attributes + + await send_attributes_report(hass, thrm_cluster, {0x0007: 10}) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + + await send_attributes_report(hass, thrm_cluster, {0x0008: 20}) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + await send_attributes_report(hass, thrm_cluster, {0x0007: 0}) + await send_attributes_report(hass, thrm_cluster, {0x0008: 0}) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + await send_attributes_report( + hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + await send_attributes_report( + hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Cool} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE + + +@pytest.mark.parametrize( + "sys_mode, hvac_mode", + ( + (Thermostat.SystemMode.Auto, HVAC_MODE_HEAT_COOL), + (Thermostat.SystemMode.Cool, HVAC_MODE_COOL), + (Thermostat.SystemMode.Heat, HVAC_MODE_HEAT), + (Thermostat.SystemMode.Pre_cooling, HVAC_MODE_COOL), + (Thermostat.SystemMode.Fan_only, HVAC_MODE_FAN_ONLY), + (Thermostat.SystemMode.Dry, HVAC_MODE_DRY), + ), +) +async def test_hvac_mode(hass, device_climate, sys_mode, hvac_mode): + """Test HVAC modee.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_OFF + + await send_attributes_report(hass, thrm_cluster, {0x001C: sys_mode}) + state = hass.states.get(entity_id) + assert state.state == hvac_mode + + await send_attributes_report( + hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Off} + ) + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_OFF + + await send_attributes_report(hass, thrm_cluster, {0x001C: 0xFF}) + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "seq_of_op, modes", + ( + (0xFF, {HVAC_MODE_OFF}), + (0x00, {HVAC_MODE_OFF, HVAC_MODE_COOL}), + (0x01, {HVAC_MODE_OFF, HVAC_MODE_COOL}), + (0x02, {HVAC_MODE_OFF, HVAC_MODE_HEAT}), + (0x03, {HVAC_MODE_OFF, HVAC_MODE_HEAT}), + (0x04, {HVAC_MODE_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL}), + (0x05, {HVAC_MODE_OFF, HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL}), + ), +) +async def test_hvac_modes(hass, device_climate_mock, seq_of_op, modes): + """Test HVAC modes from sequence of operations.""" + + device_climate = await device_climate_mock( + CLIMATE, {"ctrl_seqe_of_oper": seq_of_op} + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + state = hass.states.get(entity_id) + assert set(state.attributes[ATTR_HVAC_MODES]) == modes + + +@pytest.mark.parametrize( + "sys_mode, preset, target_temp", + ( + (Thermostat.SystemMode.Heat, None, 22), + (Thermostat.SystemMode.Heat, PRESET_AWAY, 16), + (Thermostat.SystemMode.Cool, None, 25), + (Thermostat.SystemMode.Cool, PRESET_AWAY, 27), + ), +) +async def test_target_temperature( + hass, device_climate_mock, sys_mode, preset, target_temp +): + """Test target temperature property.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2200, + "system_mode": sys_mode, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + if preset: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == target_temp + + +@pytest.mark.parametrize( + "preset, unoccupied, target_temp", + ((None, 1800, 17), (PRESET_AWAY, 1800, 18), (PRESET_AWAY, None, None),), +) +async def test_target_temperature_high( + hass, device_climate_mock, preset, unoccupied, target_temp +): + """Test target temperature high property.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 1700, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_cooling_setpoint": unoccupied, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + if preset: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == target_temp + + +@pytest.mark.parametrize( + "preset, unoccupied, target_temp", + ((None, 1600, 21), (PRESET_AWAY, 1600, 16), (PRESET_AWAY, None, None),), +) +async def test_target_temperature_low( + hass, device_climate_mock, preset, unoccupied, target_temp +): + """Test target temperature low property.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_heating_setpoint": 2100, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": unoccupied, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + if preset: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: preset}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] == target_temp + + +@pytest.mark.parametrize( + "hvac_mode, sys_mode", + ( + (HVAC_MODE_AUTO, None), + (HVAC_MODE_COOL, Thermostat.SystemMode.Cool), + (HVAC_MODE_DRY, None), + (HVAC_MODE_FAN_ONLY, None), + (HVAC_MODE_HEAT, Thermostat.SystemMode.Heat), + (HVAC_MODE_HEAT_COOL, Thermostat.SystemMode.Auto), + ), +) +async def test_set_hvac_mode(hass, device_climate, hvac_mode, sys_mode): + """Test setting hvac mode.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + state = hass.states.get(entity_id) + if sys_mode is not None: + assert state.state == hvac_mode + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": sys_mode + } + else: + assert thrm_cluster.write_attributes.call_count == 0 + assert state.state == HVAC_MODE_OFF + + # turn off + thrm_cluster.write_attributes.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVAC_MODE_OFF}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_OFF + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": Thermostat.SystemMode.Off + } + + +async def test_preset_setting(hass, device_climate_sinope): + """Test preset setting.""" + + entity_id = await find_entity_id(DOMAIN, device_climate_sinope, hass) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + # unsuccessful occupancy change + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse.deserialize(b"\x01\x00\x00")[0] + ] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 0} + + # successful occupancy change + thrm_cluster.write_attributes.reset_mock() + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] + ] + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 0} + + # unsuccessful occupancy change + thrm_cluster.write_attributes.reset_mock() + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse.deserialize(b"\x01\x01\x01")[0] + ] + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 1} + + # successful occupancy change + thrm_cluster.write_attributes.reset_mock() + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] + ] + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 1} + + +async def test_preset_setting_invalid(hass, device_climate_sinope): + """Test invalid preset setting.""" + + entity_id = await find_entity_id(DOMAIN, device_climate_sinope, hass) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert thrm_cluster.write_attributes.call_count == 0 + + +async def test_set_temperature_hvac_mode(hass, device_climate): + """Test setting HVAC mode in temperature service call.""" + + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + thrm_cluster = device_climate.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_HVAC_MODE: HVAC_MODE_HEAT_COOL, + ATTR_TEMPERATURE: 20, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_HEAT_COOL + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": Thermostat.SystemMode.Auto + } + + +async def test_set_temperature_heat_cool(hass, device_climate_mock): + """Test setting temperature service call in heating/cooling HVAC mode.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + thrm_cluster = device_climate.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_HEAT_COOL + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 21}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.0 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.0 + assert thrm_cluster.write_attributes.await_count == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_HIGH: 26, + ATTR_TARGET_TEMP_LOW: 19, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 19.0 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 26.0 + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "occupied_heating_setpoint": 1900 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "occupied_cooling_setpoint": 2600 + } + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + thrm_cluster.write_attributes.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 15, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 15.0 + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30.0 + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "unoccupied_heating_setpoint": 1500 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "unoccupied_cooling_setpoint": 3000 + } + + +async def test_set_temperature_heat(hass, device_climate_mock): + """Test setting temperature service call in heating HVAC mode.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Heat, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + thrm_cluster = device_climate.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_HEAT + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 15, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] == 20.0 + assert thrm_cluster.write_attributes.await_count == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 21}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] == 21.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "occupied_heating_setpoint": 2100 + } + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + thrm_cluster.write_attributes.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "unoccupied_heating_setpoint": 2200 + } + + +async def test_set_temperature_cool(hass, device_climate_mock): + """Test setting temperature service call in cooling HVAC mode.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Cool, + "unoccupied_cooling_setpoint": 1600, + "unoccupied_heating_setpoint": 2700, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + thrm_cluster = device_climate.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_COOL + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_HIGH: 30, + ATTR_TARGET_TEMP_LOW: 15, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] == 25.0 + assert thrm_cluster.write_attributes.await_count == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 21}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] == 21.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "occupied_cooling_setpoint": 2100 + } + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + thrm_cluster.write_attributes.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 22}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "unoccupied_cooling_setpoint": 2200 + } + + +async def test_set_temperature_wrong_mode(hass, device_climate_mock): + """Test setting temperature service call for wrong HVAC mode.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Dry, + "unoccupied_cooling_setpoint": 1600, + "unoccupied_heating_setpoint": 2700, + }, + manuf=SINOPE, + ) + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + thrm_cluster = device_climate.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_DRY + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 24}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TARGET_TEMP_LOW] is None + assert state.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert state.attributes[ATTR_TEMPERATURE] is None + assert thrm_cluster.write_attributes.await_count == 0 + + +async def test_occupancy_reset(hass, device_climate_sinope): + """Test away preset reset.""" + + entity_id = await find_entity_id(DOMAIN, device_climate_sinope, hass) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + thrm_cluster.write_attributes.reset_mock() + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_AWAY + + thrm_cluster.read_attributes.return_value = [True], {} + await send_attributes_report( + hass, thrm_cluster, {"occupied_heating_setpoint": 1950} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + +async def test_fan_mode(hass, device_climate_fan): + """Test fan mode.""" + + entity_id = await find_entity_id(DOMAIN, device_climate_fan, hass) + thrm_cluster = device_climate_fan.device.endpoints[1].thermostat + + state = hass.states.get(entity_id) + assert set(state.attributes[ATTR_FAN_MODES]) == {FAN_AUTO, FAN_ON} + assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO + + await send_attributes_report( + hass, thrm_cluster, {"running_state": Thermostat.RunningState.Fan_State_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_FAN_MODE] == FAN_ON + + await send_attributes_report( + hass, thrm_cluster, {"running_state": Thermostat.RunningState.Idle} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO + + await send_attributes_report( + hass, thrm_cluster, {"running_state": Thermostat.RunningState.Fan_2nd_Stage_On} + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_FAN_MODE] == FAN_ON + + +async def test_set_fan_mode_no_fan(hass, device_climate, caplog): + """Test setting fan mode on fun less climate.""" + + entity_id = await find_entity_id(DOMAIN, device_climate, hass) + + with caplog.at_level(logging.DEBUG): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_ON}, + blocking=True, + ) + assert "Fan is not supported" in caplog.text + + +async def test_set_fan_mode_not_supported(hass, device_climate_fan, caplog): + """Test fan setting unsupported mode.""" + + entity_id = await find_entity_id(DOMAIN, device_climate_fan, hass) + + with caplog.at_level(logging.DEBUG): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + assert "Unsupported 'low' fan mode" in caplog.text + + +async def test_set_fan_mode(hass, device_climate_fan): + """Test fan mode setting.""" + + entity_id = await find_entity_id(DOMAIN, device_climate_fan, hass) + fan_cluster = device_climate_fan.device.endpoints[1].fan + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_FAN_MODE] == FAN_AUTO + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_ON}, + blocking=True, + ) + assert fan_cluster.write_attributes.await_count == 1 + assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 4} + + fan_cluster.write_attributes.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + assert fan_cluster.write_attributes.await_count == 1 + assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5} diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 1d88ba69e8d..01144cde694 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -3100,10 +3100,16 @@ DEVICES = [ }, }, "entities": [ + "climate.sinope_technologies_th1123zb_77665544_thermostat", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1123zb_77665544_temperature", ], "entity_map": { + ("climate", "00:11:22:33:44:55:66:77-1"): { + "channels": ["thermostat"], + "entity_class": "Thermostat", + "entity_id": "climate.sinope_technologies_th1123zb_77665544_thermostat", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { "channels": ["temperature"], "entity_class": "Temperature", @@ -3142,8 +3148,14 @@ DEVICES = [ "entities": [ "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement", "sensor.sinope_technologies_th1124zb_77665544_temperature", + "climate.sinope_technologies_th1124zb_77665544_thermostat", ], "entity_map": { + ("climate", "00:11:22:33:44:55:66:77-1"): { + "channels": ["thermostat"], + "entity_class": "Thermostat", + "entity_id": "climate.sinope_technologies_th1124zb_77665544_thermostat", + }, ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { "channels": ["temperature"], "entity_class": "Temperature", @@ -3326,7 +3338,7 @@ DEVICES = [ } }, "entities": [ - "fan.zen_within_zen_01_77665544_fan", + "climate.zen_within_zen_01_77665544_fan_thermostat", "sensor.zen_within_zen_01_77665544_power", ], "entity_map": { @@ -3335,10 +3347,10 @@ DEVICES = [ "entity_class": "Battery", "entity_id": "sensor.zen_within_zen_01_77665544_power", }, - ("fan", "00:11:22:33:44:55:66:77-1-514"): { - "channels": ["fan"], - "entity_class": "ZhaFan", - "entity_id": "fan.zen_within_zen_01_77665544_fan", + ("climate", "00:11:22:33:44:55:66:77-1"): { + "channels": ["thermostat", "fan"], + "entity_class": "Thermostat", + "entity_id": "climate.zen_within_zen_01_77665544_fan_thermostat", }, }, "event_channels": ["1:0x0019"],