Add select entities to ZHA (#62718)

pull/63377/head
David F. Mulcahey 2022-01-04 07:40:29 -05:00 committed by GitHub
parent cfb47b9195
commit 6a685f0315
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 439 additions and 8 deletions

View File

@ -116,6 +116,7 @@ class ZigbeeChannel(LogMixin):
self.value_attribute = attr
self._status = ChannelStatus.CREATED
self._cluster.add_listener(self)
self.data_cache = {}
@property
def id(self) -> str:

View File

@ -7,6 +7,7 @@ import logging
import bellows.zigbee.application
import voluptuous as vol
from zigpy.config import CONF_DEVICE_PATH # noqa: F401 # pylint: disable=unused-import
import zigpy.types as t
import zigpy_deconz.zigbee.application
import zigpy_xbee.zigbee.application
import zigpy_zigate.zigbee.application
@ -110,6 +111,7 @@ PLATFORMS = (
Platform.LIGHT,
Platform.LOCK,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
@ -387,3 +389,10 @@ EFFECT_BREATHE = 0x01
EFFECT_OKAY = 0x02
EFFECT_DEFAULT_VARIANT = 0x00
class Strobe(t.enum8):
"""Strobe enum."""
No_Strobe = 0x00
Strobe = 0x01

View File

@ -25,6 +25,7 @@ from .. import ( # noqa: F401 pylint: disable=unused-import,
light,
lock,
number,
select,
sensor,
siren,
switch,

View File

@ -0,0 +1,133 @@
"""Support for ZHA controls using the select platform."""
from __future__ import annotations
from enum import Enum
import functools
from zigpy.zcl.clusters.security import IasWd
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .core import discovery
from .core.const import CHANNEL_IAS_WD, DATA_ZHA, SIGNAL_ADD_ENTITIES, Strobe
from .core.registries import ZHA_ENTITIES
from .core.typing import ChannelType, ZhaDeviceType
from .entity import ZhaEntity
MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SELECT)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation siren from config entry."""
entities_to_create = hass.data[DATA_ZHA][Platform.SELECT]
unsub = async_dispatcher_connect(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
discovery.async_add_entities,
async_add_entities,
entities_to_create,
update_before_add=False,
),
)
config_entry.async_on_unload(unsub)
class ZHAEnumSelectEntity(ZhaEntity, SelectEntity):
"""Representation of a ZHA select entity."""
_attr_entity_category = ENTITY_CATEGORY_CONFIG
_enum: Enum = None
def __init__(
self,
unique_id: str,
zha_device: ZhaDeviceType,
channels: list[ChannelType],
**kwargs,
) -> None:
"""Init this select entity."""
self._attr_name = self._enum.__name__
self._attr_options = [entry.name.replace("_", " ") for entry in self._enum]
self._channel: ChannelType = channels[0]
super().__init__(unique_id, zha_device, channels, **kwargs)
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
option = self._channel.data_cache.get(self._attr_name)
if option is None:
return None
return option.name.replace("_", " ")
async def async_select_option(self, option: str | int) -> None:
"""Change the selected option."""
self._channel.data_cache[self._attr_name] = self._enum[option.replace(" ", "_")]
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass."""
await super().async_added_to_hass()
if last_state := await self.async_get_last_state():
self.async_restore_last_state(last_state)
@callback
def async_restore_last_state(self, last_state) -> None:
"""Restore previous state."""
if last_state.state and last_state.state != STATE_UNKNOWN:
self._channel.data_cache[self._attr_name] = self._enum[
last_state.state.replace(" ", "_")
]
class ZHANonZCLSelectEntity(ZHAEnumSelectEntity):
"""Representation of a ZHA select entity with no ZCL interaction."""
@property
def available(self) -> bool:
"""Return entity availability."""
return True
@MULTI_MATCH(channel_names=CHANNEL_IAS_WD)
class ZHADefaultToneSelectEntity(
ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.WarningMode.__name__
):
"""Representation of a ZHA default siren tone select entity."""
_enum: Enum = IasWd.Warning.WarningMode
@MULTI_MATCH(channel_names=CHANNEL_IAS_WD)
class ZHADefaultSirenLevelSelectEntity(
ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.SirenLevel.__name__
):
"""Representation of a ZHA default siren level select entity."""
_enum: Enum = IasWd.Warning.SirenLevel
@MULTI_MATCH(channel_names=CHANNEL_IAS_WD)
class ZHADefaultStrobeLevelSelectEntity(
ZHANonZCLSelectEntity, id_suffix=IasWd.StrobeLevel.__name__
):
"""Representation of a ZHA default siren strobe level select entity."""
_enum: Enum = IasWd.StrobeLevel
@MULTI_MATCH(channel_names=CHANNEL_IAS_WD)
class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__):
"""Representation of a ZHA default siren strobe select entity."""
_enum: Enum = Strobe

View File

@ -5,6 +5,8 @@ from __future__ import annotations
import functools
from typing import Any
from zigpy.zcl.clusters.security import IasWd as WD
from homeassistant.components.siren import (
ATTR_DURATION,
SUPPORT_DURATION,
@ -39,13 +41,15 @@ from .core.const import (
WARNING_DEVICE_MODE_POLICE_PANIC,
WARNING_DEVICE_MODE_STOP,
WARNING_DEVICE_SOUND_HIGH,
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_NO,
Strobe,
)
from .core.registries import ZHA_ENTITIES
from .core.typing import ChannelType, ZhaDeviceType
from .entity import ZhaEntity
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SIREN)
MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN)
DEFAULT_DURATION = 5 # seconds
@ -70,7 +74,7 @@ async def async_setup_entry(
config_entry.async_on_unload(unsub)
@STRICT_MATCH(channel_names=CHANNEL_IAS_WD)
@MULTI_MATCH(channel_names=CHANNEL_IAS_WD)
class ZHASiren(ZhaEntity, SirenEntity):
"""Representation of a ZHA siren."""
@ -107,9 +111,27 @@ class ZHASiren(ZhaEntity, SirenEntity):
if self._off_listener:
self._off_listener()
self._off_listener = None
siren_tone = WARNING_DEVICE_MODE_EMERGENCY
tone_cache = self._channel.data_cache.get(WD.Warning.WarningMode.__name__)
siren_tone = (
tone_cache.value
if tone_cache is not None
else WARNING_DEVICE_MODE_EMERGENCY
)
siren_duration = DEFAULT_DURATION
siren_level = WARNING_DEVICE_SOUND_HIGH
level_cache = self._channel.data_cache.get(WD.Warning.SirenLevel.__name__)
siren_level = (
level_cache.value if level_cache is not None else WARNING_DEVICE_SOUND_HIGH
)
strobe_cache = self._channel.data_cache.get(Strobe.__name__)
should_strobe = (
strobe_cache.value if strobe_cache is not None else Strobe.No_Strobe
)
strobe_level_cache = self._channel.data_cache.get(WD.StrobeLevel.__name__)
strobe_level = (
strobe_level_cache.value
if strobe_level_cache is not None
else WARNING_DEVICE_STROBE_HIGH
)
if (duration := kwargs.get(ATTR_DURATION)) is not None:
siren_duration = duration
if (tone := kwargs.get(ATTR_TONE)) is not None:
@ -117,7 +139,12 @@ class ZHASiren(ZhaEntity, SirenEntity):
if (level := kwargs.get(ATTR_VOLUME_LEVEL)) is not None:
siren_level = int(level)
await self._channel.issue_start_warning(
mode=siren_tone, warning_duration=siren_duration, siren_level=siren_level
mode=siren_tone,
warning_duration=siren_duration,
siren_level=siren_level,
strobe=should_strobe,
strobe_duty_cycle=50 if should_strobe else 0,
strobe_intensity=strobe_level,
)
self._attr_is_on = True
self._off_listener = async_call_later(

View File

@ -10,6 +10,7 @@ import zigpy.zcl.foundation as zcl_f
import homeassistant.components.automation as automation
from homeassistant.components.device_automation import DeviceAutomationType
from homeassistant.components.zha import DOMAIN
from homeassistant.const import Platform
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@ -59,6 +60,30 @@ async def test_get_actions(hass, device_ias):
expected_actions = [
{"domain": DOMAIN, "type": "squawk", "device_id": reg_device.id},
{"domain": DOMAIN, "type": "warn", "device_id": reg_device.id},
{
"domain": Platform.SELECT,
"type": "select_option",
"device_id": reg_device.id,
"entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_warningmode",
},
{
"domain": Platform.SELECT,
"type": "select_option",
"device_id": reg_device.id,
"entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_sirenlevel",
},
{
"domain": Platform.SELECT,
"type": "select_option",
"device_id": reg_device.id,
"entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_strobelevel",
},
{
"domain": Platform.SELECT,
"type": "select_option",
"device_id": reg_device.id,
"entity_id": "select.fakemanufacturer_fakemodel_e769900a_ias_wd_strobe",
},
]
assert actions == expected_actions

View File

@ -0,0 +1,151 @@
"""Test ZHA select entities."""
import pytest
from zigpy.const import SIG_EP_PROFILE
import zigpy.profiles.zha as zha
import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.security as security
from homeassistant.const import ENTITY_CATEGORY_CONFIG, STATE_UNKNOWN, Platform
from homeassistant.helpers import entity_registry as er, restore_state
from homeassistant.util import dt as dt_util
from .common import find_entity_id
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE
@pytest.fixture
async def siren(hass, zigpy_device_mock, zha_device_joined_restored):
"""Siren fixture."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = await zha_device_joined_restored(zigpy_device)
return zha_device, zigpy_device.endpoints[1].ias_wd
@pytest.fixture
def core_rs(hass_storage):
"""Core.restore_state fixture."""
def _storage(entity_id, state):
now = dt_util.utcnow().isoformat()
hass_storage[restore_state.STORAGE_KEY] = {
"version": restore_state.STORAGE_VERSION,
"key": restore_state.STORAGE_KEY,
"data": [
{
"state": {
"entity_id": entity_id,
"state": str(state),
"last_changed": now,
"last_updated": now,
"context": {
"id": "3c2243ff5f30447eb12e7348cfd5b8ff",
"user_id": None,
},
},
"last_seen": now,
}
],
}
return
return _storage
async def test_select(hass, siren):
"""Test zha select platform."""
entity_registry = er.async_get(hass)
zha_device, cluster = siren
assert cluster is not None
select_name = security.IasWd.Warning.WarningMode.__name__
entity_id = await find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier=select_name.lower(),
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN
assert state.attributes["options"] == [
"Stop",
"Burglar",
"Fire",
"Emergency",
"Police Panic",
"Fire Panic",
"Emergency Panic",
]
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == ENTITY_CATEGORY_CONFIG
# Test select option with string value
await hass.services.async_call(
"select",
"select_option",
{
"entity_id": entity_id,
"option": security.IasWd.Warning.WarningMode.Burglar.name,
},
blocking=True,
)
state = hass.states.get(entity_id)
assert state
assert state.state == security.IasWd.Warning.WarningMode.Burglar.name
async def test_select_restore_state(
hass,
zigpy_device_mock,
core_rs,
zha_device_restored,
):
"""Test zha select entity restore state."""
entity_id = "select.fakemanufacturer_fakemodel_e769900a_ias_wd_warningmode"
core_rs(entity_id, state="Burglar")
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
)
zha_device = await zha_device_restored(zigpy_device)
cluster = zigpy_device.endpoints[1].ias_wd
assert cluster is not None
select_name = security.IasWd.Warning.WarningMode.__name__
entity_id = await find_entity_id(
Platform.SELECT,
zha_device,
hass,
qualifier=select_name.lower(),
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state
assert state.state == security.IasWd.Warning.WarningMode.Burglar.name

View File

@ -77,7 +77,7 @@ async def test_siren(hass, siren):
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0
assert cluster.request.call_args[0][3] == 54 # bitmask for default args
assert cluster.request.call_args[0][3] == 50 # bitmask for default args
assert cluster.request.call_args[0][4] == 5 # duration in seconds
assert cluster.request.call_args[0][5] == 0
assert cluster.request.call_args[0][6] == 2
@ -125,7 +125,7 @@ async def test_siren(hass, siren):
assert len(cluster.request.mock_calls) == 1
assert cluster.request.call_args[0][0] is False
assert cluster.request.call_args[0][1] == 0
assert cluster.request.call_args[0][3] == 101 # bitmask for passed args
assert cluster.request.call_args[0][3] == 97 # bitmask for passed args
assert cluster.request.call_args[0][4] == 10 # duration in seconds
assert cluster.request.call_args[0][5] == 0
assert cluster.request.call_args[0][6] == 2

View File

@ -637,6 +637,11 @@ DEVICES = [
"binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_zone",
"sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_rssi",
"sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_lqi",
"select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_warningmode",
"select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_sirenlevel",
"select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobelevel",
"select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobe",
"siren.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd",
],
DEV_SIG_ENT_MAP: {
("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
@ -659,6 +664,31 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "LQISensor",
DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_77665544_basic_lqi",
},
("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_warningmode",
},
("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_sirenlevel",
},
("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobelevel",
},
("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd_strobe",
},
("siren", "00:11:22:33:44:55:66:77-1-1282"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHASiren",
DEV_SIG_ENT_MAP_ID: "siren.climaxtechnology_sd8sc_00_00_03_12tc_77665544_ias_wd",
},
},
},
{
@ -777,6 +807,11 @@ DEVICES = [
"binary_sensor.heiman_smokesensor_em_77665544_ias_zone",
"sensor.heiman_smokesensor_em_77665544_basic_rssi",
"sensor.heiman_smokesensor_em_77665544_basic_lqi",
"select.heiman_smokesensor_em_77665544_ias_wd_warningmode",
"select.heiman_smokesensor_em_77665544_ias_wd_sirenlevel",
"select.heiman_smokesensor_em_77665544_ias_wd_strobelevel",
"select.heiman_smokesensor_em_77665544_ias_wd_strobe",
"siren.heiman_smokesensor_em_77665544_ias_wd",
],
DEV_SIG_ENT_MAP: {
("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): {
@ -804,6 +839,31 @@ DEVICES = [
DEV_SIG_ENT_MAP_CLASS: "LQISensor",
DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_77665544_basic_lqi",
},
("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_warningmode",
},
("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_sirenlevel",
},
("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_strobelevel",
},
("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_smokesensor_em_77665544_ias_wd_strobe",
},
("siren", "00:11:22:33:44:55:66:77-1-1282"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHASiren",
DEV_SIG_ENT_MAP_ID: "siren.heiman_smokesensor_em_77665544_ias_wd",
},
},
},
{
@ -867,12 +927,36 @@ DEVICES = [
DEV_SIG_EVT_CHANNELS: ["1:0x0019"],
DEV_SIG_ENTITIES: [
"button.heiman_warningdevice_77665544_identify",
"siren.heiman_warningdevice_77665544_ias_wd",
"binary_sensor.heiman_warningdevice_77665544_ias_zone",
"sensor.heiman_warningdevice_77665544_basic_rssi",
"sensor.heiman_warningdevice_77665544_basic_lqi",
"select.heiman_warningdevice_77665544_ias_wd_warningmode",
"select.heiman_warningdevice_77665544_ias_wd_sirenlevel",
"select.heiman_warningdevice_77665544_ias_wd_strobelevel",
"select.heiman_warningdevice_77665544_ias_wd_strobe",
"siren.heiman_warningdevice_77665544_ias_wd",
],
DEV_SIG_ENT_MAP: {
("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultToneSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_warningmode",
},
("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultSirenLevelSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_sirenlevel",
},
("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeLevelSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_strobelevel",
},
("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHADefaultStrobeSelectEntity",
DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_77665544_ias_wd_strobe",
},
("siren", "00:11:22:33:44:55:66:77-1"): {
DEV_SIG_CHANNELS: ["ias_wd"],
DEV_SIG_ENT_MAP_CLASS: "ZHASiren",