""" 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), 0x06: (HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF), # centralite specific 0x07: (HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF), # centralite specific } 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 if self._thrm.pi_heating_demand is not None: data[ATTR_PI_HEATING_DEMAND] = self._thrm.pi_heating_demand if self._thrm.pi_cooling_demand is not None: data[ATTR_PI_COOLING_DEMAND] = self._thrm.pi_cooling_demand 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 ): return self._rm_rs_action return self._pi_demand_action @property def _rm_rs_action(self) -> Optional[str]: """Return the current HVAC action based on running mode and running state.""" running_mode = self._thrm.running_mode if running_mode == SystemMode.HEAT: return CURRENT_HVAC_HEAT if running_mode == SystemMode.COOL: return CURRENT_HVAC_COOL running_state = self._thrm.running_state if running_state and running_state & ( RunningState.FAN | RunningState.FAN_STAGE_2 | RunningState.FAN_STAGE_3 ): return CURRENT_HVAC_FAN if self.hvac_mode != HVAC_MODE_OFF and running_mode == SystemMode.OFF: return CURRENT_HVAC_IDLE return CURRENT_HVAC_OFF @property def _pi_demand_action(self) -> Optional[str]: """Return the current HVAC action based on pi_demands.""" 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.""" return SYSTEM_MODE_2_HVAC.get(self._thrm.system_mode) @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() 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.debug("Attribute '%s' = %s update", record.attr_name, record.value) self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" 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 @STRICT_MATCH( channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN, manufacturers="Zen Within", ) class ZenWithinThermostat(Thermostat): """Zen Within Thermostat implementation.""" @property def _rm_rs_action(self) -> Optional[str]: """Return the current HVAC action based on running mode and 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 if self.hvac_mode != HVAC_MODE_OFF: return CURRENT_HVAC_IDLE return CURRENT_HVAC_OFF @STRICT_MATCH( channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN, manufacturers="Centralite", models="3157100", ) class CentralitePearl(ZenWithinThermostat): """Centralite Pearl Thermostat implementation."""