mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +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
|
||||
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):
|
||||
|
@ -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"
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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])
|
||||
|
||||
|
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": [
|
||||
"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"],
|
||||
|
Loading…
x
Reference in New Issue
Block a user