mirror of
https://github.com/home-assistant/core.git
synced 2025-07-24 21:57:51 +00:00
Add zha climate (#35682)
* Initial ZHA climate implementation. * Add retryable_request decorator. sort helpers imports. * Check manufacturer for Climate implementation. * Default zha.climate.operation_list to [Off] * Migrate to climate 1.0 * Sort imports, properties and methods. * Handle 'PRESET_NONE' presets. * Use pi_heating/cooling_demand for HVAC action prop. * Implement `running_state` HVAC channel property. For ZHA thermostats which don't support `pi_heating_demand` or `pi_cooling_demand' attributes. * wip fan support * Refactor retryable request logging. * Rebase cleanup. * Update climate discovery. * Fix ZHA climate restoration. * Bulk configure attribute reports. * Use configure_reporting_multiple command for Light More detailed response parsing of configure_reporting_multiple. * Use ordered list for HVAC cluster attribute reports. * Don't mutilate HVAC mode list. * Add fan_mode property to fan channel. * Fix type hinting. * Expose fan mode only. * Implement fan mode setting. Drop support for HVAC_FAN_ONLY mode. * Use ClimateEntity as base class. * Cleanup debug code. * Update time display for Sinope. * Don't do many retries. * Don't use multi attr reporting configuration. * Make tests pass. * Drop support for setpoint change source/amount. * Cleanups. * Drop aux heat * Update tests. * Drop Sinope temperature display code. * Update tests. * Refactor temperature setting. * Update tests. * Update Fan tests. * Lint * Black. * Use correct logging levels
This commit is contained in:
parent
b3459d9190
commit
9907e95c34
550
homeassistant/components/zha/climate.py
Normal file
550
homeassistant/components/zha/climate.py
Normal file
@ -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
|
@ -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
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
from zigpy.exceptions import ZigbeeException
|
from zigpy.exceptions import ZigbeeException
|
||||||
import zigpy.zcl.clusters.hvac as hvac
|
import zigpy.zcl.clusters.hvac as hvac
|
||||||
|
from zigpy.zcl.foundation import Status
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
|
|
||||||
from .. import registries
|
from .. import registries, typing as zha_typing
|
||||||
from ..const import REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED
|
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
|
from .base import ZigbeeChannel
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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)
|
@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Dehumidification.cluster_id)
|
||||||
class Dehumidification(ZigbeeChannel):
|
class Dehumidification(ZigbeeChannel):
|
||||||
@ -26,6 +46,18 @@ class FanChannel(ZigbeeChannel):
|
|||||||
|
|
||||||
REPORT_CONFIG = ({"attr": "fan_mode", "config": REPORT_CONFIG_OP},)
|
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:
|
async def async_set_speed(self, value) -> None:
|
||||||
"""Set the speed of the fan."""
|
"""Set the speed of the fan."""
|
||||||
|
|
||||||
@ -35,41 +67,390 @@ class FanChannel(ZigbeeChannel):
|
|||||||
self.error("Could not set speed: %s", ex)
|
self.error("Could not set speed: %s", ex)
|
||||||
return
|
return
|
||||||
|
|
||||||
async def async_update(self):
|
async def async_update(self) -> None:
|
||||||
"""Retrieve latest state."""
|
"""Retrieve latest state."""
|
||||||
result = await self.get_attribute_value("fan_mode", from_cache=True)
|
result = await self.get_attribute_value("fan_mode", from_cache=True)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
|
self._fan_mode = result
|
||||||
self.async_send_signal(
|
self.async_send_signal(
|
||||||
f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result
|
f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def attribute_updated(self, attrid, value):
|
def attribute_updated(self, attrid: int, value: Any) -> None:
|
||||||
"""Handle attribute update from fan cluster."""
|
"""Handle attribute update from fan cluster."""
|
||||||
attr_name = self.cluster.attributes.get(attrid, [attrid])[0]
|
attr_name = self.cluster.attributes.get(attrid, [attrid])[0]
|
||||||
self.debug(
|
self.debug(
|
||||||
"Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value
|
"Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value
|
||||||
)
|
)
|
||||||
if attrid == self._value_attribute:
|
if attrid == self._value_attribute:
|
||||||
|
self._fan_mode = value
|
||||||
self.async_send_signal(
|
self.async_send_signal(
|
||||||
f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value
|
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)
|
@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Pump.cluster_id)
|
||||||
class Pump(ZigbeeChannel):
|
class Pump(ZigbeeChannel):
|
||||||
"""Pump channel."""
|
"""Pump channel."""
|
||||||
|
|
||||||
|
|
||||||
|
@registries.CLIMATE_CLUSTERS.register(hvac.Thermostat.cluster_id)
|
||||||
@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Thermostat.cluster_id)
|
@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.Thermostat.cluster_id)
|
||||||
class Thermostat(ZigbeeChannel):
|
class ThermostatChannel(ZigbeeChannel):
|
||||||
"""Thermostat channel."""
|
"""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)
|
@registries.ZIGBEE_CHANNEL_REGISTRY.register(hvac.UserInterface.cluster_id)
|
||||||
class UserInterface(ZigbeeChannel):
|
class UserInterface(ZigbeeChannel):
|
||||||
|
@ -11,6 +11,7 @@ import zigpy_xbee.zigbee.application
|
|||||||
import zigpy_zigate.zigbee.application
|
import zigpy_zigate.zigbee.application
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
|
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.cover import DOMAIN as COVER
|
||||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
|
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
|
||||||
from homeassistant.components.fan import DOMAIN as FAN
|
from homeassistant.components.fan import DOMAIN as FAN
|
||||||
@ -87,6 +88,7 @@ CHANNEL_POWER_CONFIGURATION = "power"
|
|||||||
CHANNEL_PRESSURE = "pressure"
|
CHANNEL_PRESSURE = "pressure"
|
||||||
CHANNEL_SMARTENERGY_METERING = "smartenergy_metering"
|
CHANNEL_SMARTENERGY_METERING = "smartenergy_metering"
|
||||||
CHANNEL_TEMPERATURE = "temperature"
|
CHANNEL_TEMPERATURE = "temperature"
|
||||||
|
CHANNEL_THERMOSTAT = "thermostat"
|
||||||
CHANNEL_ZDO = "zdo"
|
CHANNEL_ZDO = "zdo"
|
||||||
CHANNEL_ZONE = ZONE = "ias_zone"
|
CHANNEL_ZONE = ZONE = "ias_zone"
|
||||||
|
|
||||||
@ -96,7 +98,17 @@ CLUSTER_COMMANDS_SERVER = "server_commands"
|
|||||||
CLUSTER_TYPE_IN = "in"
|
CLUSTER_TYPE_IN = "in"
|
||||||
CLUSTER_TYPE_OUT = "out"
|
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_BAUDRATE = "baudrate"
|
||||||
CONF_DATABASE = "database_path"
|
CONF_DATABASE = "database_path"
|
||||||
|
@ -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 const as zha_const, registries as zha_regs, typing as zha_typing
|
||||||
from .. import ( # noqa: F401 pylint: disable=unused-import,
|
from .. import ( # noqa: F401 pylint: disable=unused-import,
|
||||||
binary_sensor,
|
binary_sensor,
|
||||||
|
climate,
|
||||||
cover,
|
cover,
|
||||||
device_tracker,
|
device_tracker,
|
||||||
fan,
|
fan,
|
||||||
|
@ -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 collections
|
||||||
|
import functools
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
from random import uniform
|
||||||
from typing import Any, Callable, Iterator, List, Optional
|
from typing import Any, Callable, Iterator, List, Optional
|
||||||
|
|
||||||
|
import zigpy.exceptions
|
||||||
import zigpy.types
|
import zigpy.types
|
||||||
|
|
||||||
from homeassistant.core import State, callback
|
from homeassistant.core import State, callback
|
||||||
@ -147,3 +158,50 @@ class LogMixin:
|
|||||||
def error(self, msg, *args):
|
def error(self, msg, *args):
|
||||||
"""Error level log."""
|
"""Error level log."""
|
||||||
return self.log(logging.ERROR, msg, *args)
|
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
|
||||||
|
@ -8,6 +8,7 @@ import zigpy.profiles.zll
|
|||||||
import zigpy.zcl as zcl
|
import zigpy.zcl as zcl
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
|
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.cover import DOMAIN as COVER
|
||||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
|
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
|
||||||
from homeassistant.components.fan import DOMAIN as FAN
|
from homeassistant.components.fan import DOMAIN as FAN
|
||||||
@ -84,11 +85,13 @@ BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER)
|
|||||||
|
|
||||||
BINDABLE_CLUSTERS = SetRegistry()
|
BINDABLE_CLUSTERS = SetRegistry()
|
||||||
CHANNEL_ONLY_CLUSTERS = SetRegistry()
|
CHANNEL_ONLY_CLUSTERS = SetRegistry()
|
||||||
|
CLIMATE_CLUSTERS = SetRegistry()
|
||||||
CUSTOM_CLUSTER_MAPPINGS = {}
|
CUSTOM_CLUSTER_MAPPINGS = {}
|
||||||
|
|
||||||
DEVICE_CLASS = {
|
DEVICE_CLASS = {
|
||||||
zigpy.profiles.zha.PROFILE_ID: {
|
zigpy.profiles.zha.PROFILE_ID: {
|
||||||
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: DEVICE_TRACKER,
|
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_DIMMABLE_LIGHT: LIGHT,
|
||||||
zigpy.profiles.zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT,
|
zigpy.profiles.zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT,
|
||||||
zigpy.profiles.zha.DeviceType.DIMMABLE_BALLAST: LIGHT,
|
zigpy.profiles.zha.DeviceType.DIMMABLE_BALLAST: LIGHT,
|
||||||
@ -120,6 +123,7 @@ CLIENT_CHANNELS_REGISTRY = DictRegistry()
|
|||||||
|
|
||||||
COMPONENT_CLUSTERS = {
|
COMPONENT_CLUSTERS = {
|
||||||
BINARY_SENSOR: BINARY_SENSOR_CLUSTERS,
|
BINARY_SENSOR: BINARY_SENSOR_CLUSTERS,
|
||||||
|
CLIMATE: CLIMATE_CLUSTERS,
|
||||||
DEVICE_TRACKER: DEVICE_TRACKER_CLUSTERS,
|
DEVICE_TRACKER: DEVICE_TRACKER_CLUSTERS,
|
||||||
LIGHT: LIGHT_CLUSTERS,
|
LIGHT: LIGHT_CLUSTERS,
|
||||||
SWITCH: SWITCH_CLUSTERS,
|
SWITCH: SWITCH_CLUSTERS,
|
||||||
|
@ -75,7 +75,9 @@ def patch_cluster(cluster):
|
|||||||
cluster.read_attributes = AsyncMock(return_value=[{}, {}])
|
cluster.read_attributes = AsyncMock(return_value=[{}, {}])
|
||||||
cluster.read_attributes_raw = Mock()
|
cluster.read_attributes_raw = Mock()
|
||||||
cluster.unbind = AsyncMock(return_value=[0])
|
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:
|
if cluster.cluster_id == 4:
|
||||||
cluster.add = AsyncMock(return_value=[0])
|
cluster.add = AsyncMock(return_value=[0])
|
||||||
|
|
||||||
|
1060
tests/components/zha/test_climate.py
Normal file
1060
tests/components/zha/test_climate.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -3100,10 +3100,16 @@ DEVICES = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"entities": [
|
"entities": [
|
||||||
|
"climate.sinope_technologies_th1123zb_77665544_thermostat",
|
||||||
"sensor.sinope_technologies_th1123zb_77665544_electrical_measurement",
|
"sensor.sinope_technologies_th1123zb_77665544_electrical_measurement",
|
||||||
"sensor.sinope_technologies_th1123zb_77665544_temperature",
|
"sensor.sinope_technologies_th1123zb_77665544_temperature",
|
||||||
],
|
],
|
||||||
"entity_map": {
|
"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"): {
|
("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
|
||||||
"channels": ["temperature"],
|
"channels": ["temperature"],
|
||||||
"entity_class": "Temperature",
|
"entity_class": "Temperature",
|
||||||
@ -3142,8 +3148,14 @@ DEVICES = [
|
|||||||
"entities": [
|
"entities": [
|
||||||
"sensor.sinope_technologies_th1124zb_77665544_electrical_measurement",
|
"sensor.sinope_technologies_th1124zb_77665544_electrical_measurement",
|
||||||
"sensor.sinope_technologies_th1124zb_77665544_temperature",
|
"sensor.sinope_technologies_th1124zb_77665544_temperature",
|
||||||
|
"climate.sinope_technologies_th1124zb_77665544_thermostat",
|
||||||
],
|
],
|
||||||
"entity_map": {
|
"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"): {
|
("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
|
||||||
"channels": ["temperature"],
|
"channels": ["temperature"],
|
||||||
"entity_class": "Temperature",
|
"entity_class": "Temperature",
|
||||||
@ -3326,7 +3338,7 @@ DEVICES = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entities": [
|
"entities": [
|
||||||
"fan.zen_within_zen_01_77665544_fan",
|
"climate.zen_within_zen_01_77665544_fan_thermostat",
|
||||||
"sensor.zen_within_zen_01_77665544_power",
|
"sensor.zen_within_zen_01_77665544_power",
|
||||||
],
|
],
|
||||||
"entity_map": {
|
"entity_map": {
|
||||||
@ -3335,10 +3347,10 @@ DEVICES = [
|
|||||||
"entity_class": "Battery",
|
"entity_class": "Battery",
|
||||||
"entity_id": "sensor.zen_within_zen_01_77665544_power",
|
"entity_id": "sensor.zen_within_zen_01_77665544_power",
|
||||||
},
|
},
|
||||||
("fan", "00:11:22:33:44:55:66:77-1-514"): {
|
("climate", "00:11:22:33:44:55:66:77-1"): {
|
||||||
"channels": ["fan"],
|
"channels": ["thermostat", "fan"],
|
||||||
"entity_class": "ZhaFan",
|
"entity_class": "Thermostat",
|
||||||
"entity_id": "fan.zen_within_zen_01_77665544_fan",
|
"entity_id": "climate.zen_within_zen_01_77665544_fan_thermostat",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"event_channels": ["1:0x0019"],
|
"event_channels": ["1:0x0019"],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user