Add ZHA HVAC Action sensor (#57021)

* 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
This commit is contained in:
Alexei Chetroi 2021-10-04 12:57:58 -04:00 committed by GitHub
parent 69875cbd11
commit 723596076d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 266 additions and 61 deletions

View File

@ -60,8 +60,6 @@ from .core.const import (
from .core.registries import ZHA_ENTITIES from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity from .entity import ZhaEntity
DEPENDENCIES = ["zha"]
ATTR_SYS_MODE = "system_mode" ATTR_SYS_MODE = "system_mode"
ATTR_RUNNING_MODE = "running_mode" ATTR_RUNNING_MODE = "running_mode"
ATTR_SETPT_CHANGE_SRC = "setpoint_change_source" ATTR_SETPT_CHANGE_SRC = "setpoint_change_source"
@ -76,6 +74,7 @@ ATTR_UNOCCP_COOL_SETPT = "unoccupied_cooling_setpoint"
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) 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} RUNNING_MODE = {0x00: HVAC_MODE_OFF, 0x03: HVAC_MODE_COOL, 0x04: HVAC_MODE_HEAT}
@ -164,16 +163,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) 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): class Thermostat(ZhaEntity, ClimateEntity):
"""Representation of a ZHA Thermostat device.""" """Representation of a ZHA Thermostat device."""
DEFAULT_MAX_TEMP = 35 DEFAULT_MAX_TEMP = 35
DEFAULT_MIN_TEMP = 7 DEFAULT_MIN_TEMP = 7
_domain = DOMAIN
value_attribute = 0x0000
def __init__(self, unique_id, zha_device, channels, **kwargs): def __init__(self, unique_id, zha_device, channels, **kwargs):
"""Initialize ZHA Thermostat instance.""" """Initialize ZHA Thermostat instance."""
super().__init__(unique_id, zha_device, channels, **kwargs) super().__init__(unique_id, zha_device, channels, **kwargs)
@ -519,9 +515,10 @@ class Thermostat(ZhaEntity, ClimateEntity):
return await handler(enable) return await handler(enable)
@STRICT_MATCH( @MULTI_MATCH(
channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"}, channel_names={CHANNEL_THERMOSTAT, "sinope_manufacturer_specific"},
manufacturers="Sinope Technologies", manufacturers="Sinope Technologies",
stop_on_match=True,
) )
class SinopeTechnologiesThermostat(Thermostat): class SinopeTechnologiesThermostat(Thermostat):
"""Sinope Technologies Thermostat.""" """Sinope Technologies Thermostat."""
@ -570,10 +567,11 @@ class SinopeTechnologiesThermostat(Thermostat):
return res return res
@STRICT_MATCH( @MULTI_MATCH(
channel_names=CHANNEL_THERMOSTAT, channel_names=CHANNEL_THERMOSTAT,
aux_channels=CHANNEL_FAN, aux_channels=CHANNEL_FAN,
manufacturers="Zen Within", manufacturers="Zen Within",
stop_on_match=True,
) )
class ZenWithinThermostat(Thermostat): class ZenWithinThermostat(Thermostat):
"""Zen Within Thermostat implementation.""" """Zen Within Thermostat implementation."""
@ -599,11 +597,12 @@ class ZenWithinThermostat(Thermostat):
return CURRENT_HVAC_OFF return CURRENT_HVAC_OFF
@STRICT_MATCH( @MULTI_MATCH(
channel_names=CHANNEL_THERMOSTAT, channel_names=CHANNEL_THERMOSTAT,
aux_channels=CHANNEL_FAN, aux_channels=CHANNEL_FAN,
manufacturers="Centralite", manufacturers="Centralite",
models="3157100", models="3157100",
stop_on_match=True,
) )
class CentralitePearl(ZenWithinThermostat): class CentralitePearl(ZenWithinThermostat):
"""Centralite Pearl Thermostat implementation.""" """Centralite Pearl Thermostat implementation."""

View File

@ -63,8 +63,8 @@ class ProbeEndpoint:
def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None: def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None:
"""Process an endpoint on a zigpy device.""" """Process an endpoint on a zigpy device."""
self.discover_by_device_type(channel_pool) self.discover_by_device_type(channel_pool)
self.discover_by_cluster_id(channel_pool)
self.discover_multi_entities(channel_pool) self.discover_multi_entities(channel_pool)
self.discover_by_cluster_id(channel_pool)
@callback @callback
def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None:
@ -166,25 +166,42 @@ class ProbeEndpoint:
def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None: def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None:
"""Process an endpoint on and discover multiple entities.""" """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() 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( matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity(
channel_pool.manufacturer, channel_pool.manufacturer, channel_pool.model, remaining_channels
channel_pool.model, )
channel,
remaining_channels,
)
if not claimed:
continue
channel_pool.claim_channels(claimed) channel_pool.claim_channels(claimed)
for component, ent_classes_list in matches.items(): for component, ent_n_chan_list in matches.items():
for entity_class in ent_classes_list: 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( 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: def initialize(self, hass: HomeAssistant) -> None:
"""Update device overrides config.""" """Update device overrides config."""

View File

@ -3,7 +3,9 @@ from __future__ import annotations
import collections import collections
from collections.abc import Callable from collections.abc import Callable
from typing import Dict import dataclasses
import logging
from typing import Dict, List
import attr import attr
from zigpy import zcl from zigpy import zcl
@ -27,6 +29,7 @@ from . import channels as zha_channels # noqa: F401 pylint: disable=unused-impo
from .decorators import CALLABLE_T, DictRegistry, SetRegistry from .decorators import CALLABLE_T, DictRegistry, SetRegistry
from .typing import ChannelType from .typing import ChannelType
_LOGGER = logging.getLogger(__name__)
GROUP_ENTITY_DOMAINS = [LIGHT, SWITCH, FAN] GROUP_ENTITY_DOMAINS = [LIGHT, SWITCH, FAN]
PHILLIPS_REMOTE_CLUSTER = 0xFC00 PHILLIPS_REMOTE_CLUSTER = 0xFC00
@ -157,6 +160,8 @@ class MatchRule:
aux_channels: Callable | set[str] | str = attr.ib( aux_channels: Callable | set[str] | str = attr.ib(
factory=frozenset, converter=set_or_callable 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 @property
def weight(self) -> int: def weight(self) -> int:
@ -234,8 +239,16 @@ class MatchRule:
return matches 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] GroupRegistryDictType = Dict[str, CALLABLE_T]
@ -245,7 +258,7 @@ class ZHAEntityRegistry:
def __init__(self): def __init__(self):
"""Initialize Registry instance.""" """Initialize Registry instance."""
self._strict_registry: RegistryDictType = collections.defaultdict(dict) 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) lambda: collections.defaultdict(list)
) )
self._group_registry: GroupRegistryDictType = {} self._group_registry: GroupRegistryDictType = {}
@ -271,22 +284,26 @@ class ZHAEntityRegistry:
self, self,
manufacturer: str, manufacturer: str,
model: str, model: str,
primary_channel: ChannelType, channels: list[ChannelType],
aux_channels: list[ChannelType],
components: set | None = None, 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.""" """Match ZHA Channels to potentially multiple ZHA Entity classes."""
result: dict[str, list[CALLABLE_T]] = collections.defaultdict(list) result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list)
claimed: set[ChannelType] = set() all_claimed: set[ChannelType] = set()
for component in components or self._multi_entity_registry: for component in components or self._multi_entity_registry:
matches = self._multi_entity_registry[component] matches = self._multi_entity_registry[component]
for match in sorted(matches, key=lambda x: x.weight, reverse=True): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True)
if match.strict_matched(manufacturer, model, [primary_channel]): for match in sorted_matches:
claimed |= set(match.claim_channels(aux_channels)) if match.strict_matched(manufacturer, model, channels):
ent_classes = self._multi_entity_registry[component][match] claimed = match.claim_channels(channels)
result[component].extend(ent_classes) 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(claimed) return result, list(all_claimed)
def get_group_entity(self, component: str) -> CALLABLE_T: def get_group_entity(self, component: str) -> CALLABLE_T:
"""Match a ZHA group to a ZHA Entity class.""" """Match a ZHA group to a ZHA Entity class."""
@ -325,11 +342,17 @@ class ZHAEntityRegistry:
manufacturers: Callable | set[str] | str = None, manufacturers: Callable | set[str] | str = None,
models: Callable | set[str] | str = None, models: Callable | set[str] | str = None,
aux_channels: Callable | set[str] | str = None, aux_channels: Callable | set[str] | str = None,
stop_on_match: bool = False,
) -> Callable[[CALLABLE_T], CALLABLE_T]: ) -> Callable[[CALLABLE_T], CALLABLE_T]:
"""Decorate a loose match rule.""" """Decorate a loose match rule."""
rule = MatchRule( 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: def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T:

View File

@ -5,6 +5,13 @@ import functools
import numbers import numbers
from typing import Any 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 ( from homeassistant.components.sensor import (
DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY,
DEVICE_CLASS_CO, DEVICE_CLASS_CO,
@ -57,6 +64,7 @@ from .core.const import (
CHANNEL_PRESSURE, CHANNEL_PRESSURE,
CHANNEL_SMARTENERGY_METERING, CHANNEL_SMARTENERGY_METERING,
CHANNEL_TEMPERATURE, CHANNEL_TEMPERATURE,
CHANNEL_THERMOSTAT,
DATA_ZHA, DATA_ZHA,
DATA_ZHA_DISPATCHERS, DATA_ZHA_DISPATCHERS,
SIGNAL_ADD_ENTITIES, SIGNAL_ADD_ENTITIES,
@ -482,3 +490,120 @@ class FormaldehydeConcentration(Sensor):
_decimals = 0 _decimals = 0
_multiplier = 1e6 _multiplier = 1e6
_unit = CONCENTRATION_PARTS_PER_MILLION _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

View File

@ -45,6 +45,7 @@ from homeassistant.components.climate.const import (
SERVICE_SET_PRESET_MODE, SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE, SERVICE_SET_TEMPERATURE,
) )
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.zha.climate import ( from homeassistant.components.zha.climate import (
DOMAIN, DOMAIN,
HVAC_MODE_2_SYSTEM, HVAC_MODE_2_SYSTEM,
@ -174,6 +175,7 @@ def device_climate_mock(hass, zigpy_device_mock, zha_device_joined):
plugged_attrs = {**ZCL_ATTR_PLUG, **plug} plugged_attrs = {**ZCL_ATTR_PLUG, **plug}
zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf, quirk=quirk) zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf, quirk=quirk)
zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100
zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = plugged_attrs zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = plugged_attrs
zha_device = await zha_device_joined(zigpy_device) zha_device = await zha_device_joined(zigpy_device)
await async_enable_traffic(hass, [zha_device]) await async_enable_traffic(hass, [zha_device])
@ -257,45 +259,60 @@ async def test_climate_hvac_action_running_state(hass, device_climate):
thrm_cluster = device_climate.device.endpoints[1].thermostat thrm_cluster = device_climate.device.endpoints[1].thermostat
entity_id = await find_entity_id(DOMAIN, device_climate, hass) entity_id = await find_entity_id(DOMAIN, device_climate, hass)
sensor_entity_id = await find_entity_id(SENSOR_DOMAIN, device_climate, hass)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_OFF
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Off} hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Off}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_OFF
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Auto} hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Auto}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_IDLE
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Cool} hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Cool}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_COOL
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Heat} hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Heat}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_HEAT
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Off} hass, thrm_cluster, {0x001E: Thermostat.RunningMode.Off}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_IDLE
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_FAN
async def test_climate_hvac_action_running_state_zen(hass, device_climate_zen): async def test_climate_hvac_action_running_state_zen(hass, device_climate_zen):
@ -303,63 +320,84 @@ async def test_climate_hvac_action_running_state_zen(hass, device_climate_zen):
thrm_cluster = device_climate_zen.device.endpoints[1].thermostat thrm_cluster = device_climate_zen.device.endpoints[1].thermostat
entity_id = await find_entity_id(DOMAIN, device_climate_zen, hass) entity_id = await find_entity_id(DOMAIN, device_climate_zen, hass)
sensor_entity_id = await find_entity_id(SENSOR_DOMAIN, device_climate_zen, hass)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert ATTR_HVAC_ACTION not in state.attributes assert ATTR_HVAC_ACTION not in state.attributes
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == "unknown"
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_2nd_Stage_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_2nd_Stage_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_COOL
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_FAN
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_2nd_Stage_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_2nd_Stage_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_HEAT
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_2nd_Stage_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_2nd_Stage_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_FAN
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_State_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_State_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_COOL
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_3rd_Stage_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_3rd_Stage_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_FAN
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_FAN
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_State_On} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_State_On}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_HEAT
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x0029: Thermostat.RunningState.Idle} hass, thrm_cluster, {0x0029: Thermostat.RunningState.Idle}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_OFF
await send_attributes_report( await send_attributes_report(
hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat} hass, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat}
) )
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE
hvac_sensor_state = hass.states.get(sensor_entity_id)
assert hvac_sensor_state.state == CURRENT_HVAC_IDLE
async def test_climate_hvac_action_pi_demand(hass, device_climate): async def test_climate_hvac_action_pi_demand(hass, device_climate):

View File

@ -332,27 +332,13 @@ def test_multi_sensor_match(channel, entity_registry):
ch_illuminati = channel("illuminance", 0x0401) ch_illuminati = channel("illuminance", 0x0401)
match, claimed = entity_registry.get_multi_entity( match, claimed = entity_registry.get_multi_entity(
"manufacturer", "manufacturer", "model", channels=[ch_se, ch_illuminati]
"model",
primary_channel=ch_illuminati,
aux_channels=[ch_se, ch_illuminati],
)
assert s.binary_sensor not in match
assert s.component not in match
assert set(claimed) == set()
match, claimed = entity_registry.get_multi_entity(
"manufacturer",
"model",
primary_channel=ch_se,
aux_channels=[ch_se, ch_illuminati],
) )
assert s.binary_sensor in match assert s.binary_sensor in match
assert s.component not in match assert s.component not in match
assert set(claimed) == {ch_se} assert set(claimed) == {ch_se}
assert {cls.__name__ for cls in match[s.binary_sensor]} == { assert {cls.entity_class.__name__ for cls in match[s.binary_sensor]} == {
SmartEnergySensor2.__name__ SmartEnergySensor2.__name__
} }
@ -371,17 +357,16 @@ def test_multi_sensor_match(channel, entity_registry):
pass pass
match, claimed = entity_registry.get_multi_entity( match, claimed = entity_registry.get_multi_entity(
"manufacturer", "manufacturer", "model", channels={ch_se, ch_illuminati}
"model",
primary_channel=ch_se,
aux_channels={ch_se, ch_illuminati},
) )
assert s.binary_sensor in match assert s.binary_sensor in match
assert s.component in match assert s.component in match
assert set(claimed) == {ch_se, ch_illuminati} assert set(claimed) == {ch_se, ch_illuminati}
assert {cls.__name__ for cls in match[s.binary_sensor]} == { assert {cls.entity_class.__name__ for cls in match[s.binary_sensor]} == {
SmartEnergySensor2.__name__, SmartEnergySensor2.__name__,
SmartEnergySensor3.__name__, SmartEnergySensor3.__name__,
} }
assert {cls.__name__ for cls in match[s.component]} == {SmartEnergySensor1.__name__} assert {cls.entity_class.__name__ for cls in match[s.component]} == {
SmartEnergySensor1.__name__
}

View File

@ -3174,6 +3174,7 @@ DEVICES = [
"sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_current",
"sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage", "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage",
"sensor.sinope_technologies_th1123zb_77665544_temperature", "sensor.sinope_technologies_th1123zb_77665544_temperature",
"sensor.sinope_technologies_th1123zb_77665544_thermostat_hvac_action",
], ],
DEV_SIG_ENT_MAP: { DEV_SIG_ENT_MAP: {
("climate", "00:11:22:33:44:55:66:77-1"): { ("climate", "00:11:22:33:44:55:66:77-1"): {
@ -3201,6 +3202,11 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage", DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_electrical_measurement_rms_voltage",
}, },
("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): {
DEV_SIG_CHANNELS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_77665544_thermostat_hvac_action",
},
}, },
DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_EVT_CHANNELS: ["1:0x0019"],
SIG_MANUFACTURER: "Sinope Technologies", SIG_MANUFACTURER: "Sinope Technologies",
@ -3231,6 +3237,7 @@ DEVICES = [
"sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_current", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_current",
"sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_voltage", "sensor.sinope_technologies_th1124zb_77665544_electrical_measurement_rms_voltage",
"sensor.sinope_technologies_th1124zb_77665544_temperature", "sensor.sinope_technologies_th1124zb_77665544_temperature",
"sensor.sinope_technologies_th1124zb_77665544_thermostat_hvac_action",
"climate.sinope_technologies_th1124zb_77665544_thermostat", "climate.sinope_technologies_th1124zb_77665544_thermostat",
], ],
DEV_SIG_ENT_MAP: { DEV_SIG_ENT_MAP: {
@ -3239,6 +3246,11 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "Thermostat", DEV_SIG_ENT_MAP_CLASS: "Thermostat",
DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_77665544_thermostat", DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_77665544_thermostat",
}, },
("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): {
DEV_SIG_CHANNELS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction",
DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_77665544_thermostat_hvac_action",
},
("sensor", "00:11:22:33:44:55:66:77-1-1026"): { ("sensor", "00:11:22:33:44:55:66:77-1-1026"): {
DEV_SIG_CHANNELS: ["temperature"], DEV_SIG_CHANNELS: ["temperature"],
DEV_SIG_ENT_MAP_CLASS: "Temperature", DEV_SIG_ENT_MAP_CLASS: "Temperature",
@ -3454,6 +3466,7 @@ DEVICES = [
}, },
DEV_SIG_ENTITIES: [ DEV_SIG_ENTITIES: [
"climate.zen_within_zen_01_77665544_fan_thermostat", "climate.zen_within_zen_01_77665544_fan_thermostat",
"sensor.zen_within_zen_01_77665544_thermostat_hvac_action",
"sensor.zen_within_zen_01_77665544_power", "sensor.zen_within_zen_01_77665544_power",
], ],
DEV_SIG_ENT_MAP: { DEV_SIG_ENT_MAP: {
@ -3467,6 +3480,11 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat", DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat",
DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_77665544_fan_thermostat", DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_77665544_fan_thermostat",
}, },
("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): {
DEV_SIG_CHANNELS: ["thermostat"],
DEV_SIG_ENT_MAP_CLASS: "ZenHVACAction",
DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_77665544_thermostat_hvac_action",
},
}, },
DEV_SIG_EVT_CHANNELS: ["1:0x0019"], DEV_SIG_EVT_CHANNELS: ["1:0x0019"],
SIG_MANUFACTURER: "Zen Within", SIG_MANUFACTURER: "Zen Within",