Skip to content

Commit

Permalink
Add ZHA HVAC Action sensor (home-assistant#57021)
Browse files Browse the repository at this point in the history
* WIP

* Refactor multi-entity matching

Eliminate the notion on primary channel.

* Cleanup climate tests

* Refactor multi-entity match

Remove the "primary channel" in multiple entity matches

* Cleanup

* Add HVAC Action sensor

* Add a "stop_on_match" option for multi entities matches

Nominally working HVAC state sensors

* Add id_suffix for HVAC action sensor

* Fix Zen HVAC action sensor

* Pylint
  • Loading branch information
Adminiuga authored Oct 4, 2021
1 parent 69875cb commit 7235960
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 62 deletions.
17 changes: 8 additions & 9 deletions homeassistant/components/zha/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@
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"
Expand All @@ -76,6 +74,7 @@


STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN)
RUNNING_MODE = {0x00: HVAC_MODE_OFF, 0x03: HVAC_MODE_COOL, 0x04: HVAC_MODE_HEAT}


Expand Down Expand Up @@ -164,16 +163,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)


@STRICT_MATCH(channel_names=CHANNEL_THERMOSTAT, aux_channels=CHANNEL_FAN)
@MULTI_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)
Expand Down Expand Up @@ -519,9 +515,10 @@ async def async_preset_handler(self, preset: str, enable: bool = False) -> bool:
return await handler(enable)


@STRICT_MATCH(
@MULTI_MATCH(
channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"},
manufacturers="Sinope Technologies",
stop_on_match=True,
)
class SinopeTechnologiesThermostat(Thermostat):
"""Sinope Technologies Thermostat."""
Expand Down Expand Up @@ -570,10 +567,11 @@ async def async_preset_handler_away(self, is_away: bool = False) -> bool:
return res


@STRICT_MATCH(
@MULTI_MATCH(
channel_names=CHANNEL_THERMOSTAT,
aux_channels=CHANNEL_FAN,
manufacturers="Zen Within",
stop_on_match=True,
)
class ZenWithinThermostat(Thermostat):
"""Zen Within Thermostat implementation."""
Expand All @@ -599,11 +597,12 @@ def _rm_rs_action(self) -> str | None:
return CURRENT_HVAC_OFF


@STRICT_MATCH(
@MULTI_MATCH(
channel_names=CHANNEL_THERMOSTAT,
aux_channels=CHANNEL_FAN,
manufacturers="Centralite",
models="3157100",
stop_on_match=True,
)
class CentralitePearl(ZenWithinThermostat):
"""Centralite Pearl Thermostat implementation."""
Expand Down
47 changes: 32 additions & 15 deletions homeassistant/components/zha/core/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ def __init__(self):
def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None:
"""Process an endpoint on a zigpy device."""
self.discover_by_device_type(channel_pool)
self.discover_by_cluster_id(channel_pool)
self.discover_multi_entities(channel_pool)
self.discover_by_cluster_id(channel_pool)

@callback
def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None:
Expand Down Expand Up @@ -166,25 +166,42 @@ def handle_on_off_output_cluster_exception(
def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None:
"""Process an endpoint on and discover multiple entities."""

ep_profile_id = channel_pool.endpoint.profile_id
ep_device_type = channel_pool.endpoint.device_type
cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type)
remaining_channels = channel_pool.unclaimed_channels()
for channel in remaining_channels:
unique_id = f"{channel_pool.unique_id}-{channel.cluster.cluster_id}"

matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity(
channel_pool.manufacturer,
channel_pool.model,
channel,
remaining_channels,
)
if not claimed:
continue
matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity(
channel_pool.manufacturer, channel_pool.model, remaining_channels
)

channel_pool.claim_channels(claimed)
for component, ent_classes_list in matches.items():
for entity_class in ent_classes_list:
channel_pool.claim_channels(claimed)
for component, ent_n_chan_list in matches.items():
for entity_and_channel in ent_n_chan_list:
_LOGGER.debug(
"'%s' component -> '%s' using %s",
component,
entity_and_channel.entity_class.__name__,
[ch.name for ch in entity_and_channel.claimed_channel],
)
for component, ent_n_chan_list in matches.items():
for entity_and_channel in ent_n_chan_list:
if component == cmpt_by_dev_type:
# for well known device types, like thermostats we'll take only 1st class
channel_pool.async_new_entity(
component, entity_class, unique_id, claimed
component,
entity_and_channel.entity_class,
channel_pool.unique_id,
entity_and_channel.claimed_channel,
)
break
first_ch = entity_and_channel.claimed_channel[0]
channel_pool.async_new_entity(
component,
entity_and_channel.entity_class,
f"{channel_pool.unique_id}-{first_ch.cluster.cluster_id}",
entity_and_channel.claimed_channel,
)

def initialize(self, hass: HomeAssistant) -> None:
"""Update device overrides config."""
Expand Down
55 changes: 39 additions & 16 deletions homeassistant/components/zha/core/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

import collections
from collections.abc import Callable
from typing import Dict
import dataclasses
import logging
from typing import Dict, List

import attr
from zigpy import zcl
Expand All @@ -27,6 +29,7 @@
from .decorators import CALLABLE_T, DictRegistry, SetRegistry
from .typing import ChannelType

_LOGGER = logging.getLogger(__name__)
GROUP_ENTITY_DOMAINS = [LIGHT, SWITCH, FAN]

PHILLIPS_REMOTE_CLUSTER = 0xFC00
Expand Down Expand Up @@ -157,6 +160,8 @@ class MatchRule:
aux_channels: Callable | set[str] | str = attr.ib(
factory=frozenset, converter=set_or_callable
)
# for multi entities, stop further processing on a match for a component
stop_on_match: bool = attr.ib(default=False)

@property
def weight(self) -> int:
Expand Down Expand Up @@ -234,8 +239,16 @@ def _matched(self, manufacturer: str, model: str, channels: list) -> list:
return matches


RegistryDictType = Dict[str, Dict[MatchRule, CALLABLE_T]]
@dataclasses.dataclass
class EntityClassAndChannels:
"""Container for entity class and corresponding channels."""

entity_class: CALLABLE_T
claimed_channel: list[ChannelType]


RegistryDictType = Dict[str, Dict[MatchRule, CALLABLE_T]]
MultiRegistryDictType = Dict[str, Dict[MatchRule, List[CALLABLE_T]]]
GroupRegistryDictType = Dict[str, CALLABLE_T]


Expand All @@ -245,7 +258,7 @@ class ZHAEntityRegistry:
def __init__(self):
"""Initialize Registry instance."""
self._strict_registry: RegistryDictType = collections.defaultdict(dict)
self._multi_entity_registry: RegistryDictType = collections.defaultdict(
self._multi_entity_registry: MultiRegistryDictType = collections.defaultdict(
lambda: collections.defaultdict(list)
)
self._group_registry: GroupRegistryDictType = {}
Expand All @@ -271,22 +284,26 @@ def get_multi_entity(
self,
manufacturer: str,
model: str,
primary_channel: ChannelType,
aux_channels: list[ChannelType],
channels: list[ChannelType],
components: set | None = None,
) -> tuple[dict[str, list[CALLABLE_T]], list[ChannelType]]:
) -> tuple[dict[str, list[EntityClassAndChannels]], list[ChannelType]]:
"""Match ZHA Channels to potentially multiple ZHA Entity classes."""
result: dict[str, list[CALLABLE_T]] = collections.defaultdict(list)
claimed: set[ChannelType] = set()
result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list)
all_claimed: set[ChannelType] = set()
for component in components or self._multi_entity_registry:
matches = self._multi_entity_registry[component]
for match in sorted(matches, key=lambda x: x.weight, reverse=True):
if match.strict_matched(manufacturer, model, [primary_channel]):
claimed |= set(match.claim_channels(aux_channels))
ent_classes = self._multi_entity_registry[component][match]
result[component].extend(ent_classes)

return result, list(claimed)
sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True)
for match in sorted_matches:
if match.strict_matched(manufacturer, model, channels):
claimed = match.claim_channels(channels)
for ent_class in self._multi_entity_registry[component][match]:
ent_n_channels = EntityClassAndChannels(ent_class, claimed)
result[component].append(ent_n_channels)
all_claimed |= set(claimed)
if match.stop_on_match:
break

return result, list(all_claimed)

def get_group_entity(self, component: str) -> CALLABLE_T:
"""Match a ZHA group to a ZHA Entity class."""
Expand Down Expand Up @@ -325,11 +342,17 @@ def multipass_match(
manufacturers: Callable | set[str] | str = None,
models: Callable | set[str] | str = None,
aux_channels: Callable | set[str] | str = None,
stop_on_match: bool = False,
) -> Callable[[CALLABLE_T], CALLABLE_T]:
"""Decorate a loose match rule."""

rule = MatchRule(
channel_names, generic_ids, manufacturers, models, aux_channels
channel_names,
generic_ids,
manufacturers,
models,
aux_channels,
stop_on_match,
)

def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T:
Expand Down
125 changes: 125 additions & 0 deletions homeassistant/components/zha/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
import numbers
from typing import Any

from homeassistant.components.climate.const import (
CURRENT_HVAC_COOL,
CURRENT_HVAC_FAN,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
)
from homeassistant.components.sensor import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CO,
Expand Down Expand Up @@ -57,6 +64,7 @@
CHANNEL_PRESSURE,
CHANNEL_SMARTENERGY_METERING,
CHANNEL_TEMPERATURE,
CHANNEL_THERMOSTAT,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
SIGNAL_ADD_ENTITIES,
Expand Down Expand Up @@ -482,3 +490,120 @@ class FormaldehydeConcentration(Sensor):
_decimals = 0
_multiplier = 1e6
_unit = CONCENTRATION_PARTS_PER_MILLION


@MULTI_MATCH(channel_names=CHANNEL_THERMOSTAT)
class ThermostatHVACAction(Sensor, id_suffix="hvac_action"):
"""Thermostat HVAC action sensor."""

@classmethod
def create_entity(
cls,
unique_id: str,
zha_device: ZhaDeviceType,
channels: list[ChannelType],
**kwargs,
) -> ZhaEntity | None:
"""Entity Factory.
Return entity if it is a supported configuration, otherwise return None
"""

return cls(unique_id, zha_device, channels, **kwargs)

@property
def native_value(self) -> str | None:
"""Return the current HVAC action."""
if (
self._channel.pi_heating_demand is None
and self._channel.pi_cooling_demand is None
):
return self._rm_rs_action
return self._pi_demand_action

@property
def _rm_rs_action(self) -> str | None:
"""Return the current HVAC action based on running mode and running state."""

running_mode = self._channel.running_mode
if running_mode == self._channel.RunningMode.Heat:
return CURRENT_HVAC_HEAT
if running_mode == self._channel.RunningMode.Cool:
return CURRENT_HVAC_COOL

running_state = self._channel.running_state
if running_state and running_state & (
self._channel.RunningState.Fan_State_On
| self._channel.RunningState.Fan_2nd_Stage_On
| self._channel.RunningState.Fan_3rd_Stage_On
):
return CURRENT_HVAC_FAN
if (
self._channel.system_mode != self._channel.SystemMode.Off
and running_mode == self._channel.SystemMode.Off
):
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF

@property
def _pi_demand_action(self) -> str | None:
"""Return the current HVAC action based on pi_demands."""

heating_demand = self._channel.pi_heating_demand
if heating_demand is not None and heating_demand > 0:
return CURRENT_HVAC_HEAT
cooling_demand = self._channel.pi_cooling_demand
if cooling_demand is not None and cooling_demand > 0:
return CURRENT_HVAC_COOL

if self._channel.system_mode != self._channel.SystemMode.Off:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF

@callback
def async_set_state(self, *args, **kwargs) -> None:
"""Handle state update from channel."""
self.async_write_ha_state()


@MULTI_MATCH(
channel_names=CHANNEL_THERMOSTAT,
manufacturers="Zen Within",
stop_on_match=True,
)
class ZenHVACAction(ThermostatHVACAction):
"""Zen Within Thermostat HVAC Action."""

@property
def _rm_rs_action(self) -> str | None:
"""Return the current HVAC action based on running mode and running state."""

running_state = self._channel.running_state
if running_state is None:
return None

rs_heat = (
self._channel.RunningState.Heat_State_On
| self._channel.RunningState.Heat_2nd_Stage_On
)
if running_state & rs_heat:
return CURRENT_HVAC_HEAT

rs_cool = (
self._channel.RunningState.Cool_State_On
| self._channel.RunningState.Cool_2nd_Stage_On
)
if running_state & rs_cool:
return CURRENT_HVAC_COOL

running_state = self._channel.running_state
if running_state and running_state & (
self._channel.RunningState.Fan_State_On
| self._channel.RunningState.Fan_2nd_Stage_On
| self._channel.RunningState.Fan_3rd_Stage_On
):
return CURRENT_HVAC_FAN

if self._channel.system_mode != self._channel.SystemMode.Off:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_OFF
Loading

0 comments on commit 7235960

Please sign in to comment.