1
mirror of https://github.com/home-assistant/core synced 2024-08-31 05:57:13 +02:00

Add alarm control panel support to ZHA (#49080)

* start implementation of IAS ACE

* starting alarm control panel

* enums

* use new enums from zigpy

* fix import

* write state

* fix registries after rebase

* remove extra line

* cleanup

* fix deprecation warning

* updates to catch up with codebase evolution

* minor updates

* cleanup

* implement more ias ace functionality

* cleanup

* make config helper work for supplied section

* connect to configuration

* use ha async_create_task

* add some tests

* remove unused restore method

* update tests

* add tests from panel POV

* dynamically include alarm control panel config

* fix import

Co-authored-by: Alexei Chetroi <lexoid@gmail.com>
This commit is contained in:
David F. Mulcahey 2021-04-27 10:58:59 -04:00 committed by GitHub
parent d4ed65e0f5
commit a644c2e8ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 719 additions and 10 deletions

View File

@ -0,0 +1,174 @@
"""Alarm control panels on Zigbee Home Automation networks."""
import functools
import logging
from zigpy.zcl.clusters.security import IasAce
from homeassistant.components.alarm_control_panel import (
ATTR_CHANGED_BY,
ATTR_CODE_ARM_REQUIRED,
ATTR_CODE_FORMAT,
DOMAIN,
FORMAT_TEXT,
SUPPORT_ALARM_ARM_AWAY,
SUPPORT_ALARM_ARM_HOME,
SUPPORT_ALARM_ARM_NIGHT,
SUPPORT_ALARM_TRIGGER,
AlarmControlPanelEntity,
)
from homeassistant.components.zha.core.typing import ZhaDeviceType
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .core import discovery
from .core.channels.security import (
SIGNAL_ALARM_TRIGGERED,
SIGNAL_ARMED_STATE_CHANGED,
IasAce as AceChannel,
)
from .core.const import (
CHANNEL_IAS_ACE,
CONF_ALARM_ARM_REQUIRES_CODE,
CONF_ALARM_FAILED_TRIES,
CONF_ALARM_MASTER_CODE,
DATA_ZHA,
DATA_ZHA_DISPATCHERS,
SIGNAL_ADD_ENTITIES,
ZHA_ALARM_OPTIONS,
)
from .core.helpers import async_get_zha_config_value
from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity
_LOGGER = logging.getLogger(__name__)
STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN)
IAS_ACE_STATE_MAP = {
IasAce.PanelStatus.Panel_Disarmed: STATE_ALARM_DISARMED,
IasAce.PanelStatus.Armed_Stay: STATE_ALARM_ARMED_HOME,
IasAce.PanelStatus.Armed_Night: STATE_ALARM_ARMED_NIGHT,
IasAce.PanelStatus.Armed_Away: STATE_ALARM_ARMED_AWAY,
IasAce.PanelStatus.In_Alarm: STATE_ALARM_TRIGGERED,
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation alarm control panel 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_IAS_ACE)
class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity):
"""Entity for ZHA alarm control devices."""
def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs):
"""Initialize the ZHA alarm control device."""
super().__init__(unique_id, zha_device, channels, **kwargs)
cfg_entry = zha_device.gateway.config_entry
self._channel: AceChannel = channels[0]
self._channel.panel_code = async_get_zha_config_value(
cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234"
)
self._channel.code_required_arm_actions = async_get_zha_config_value(
cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_ARM_REQUIRES_CODE, False
)
self._channel.max_invalid_tries = async_get_zha_config_value(
cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3
)
async def async_added_to_hass(self):
"""Run when about to be added to hass."""
await super().async_added_to_hass()
self.async_accept_signal(
self._channel, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode
)
self.async_accept_signal(
self._channel, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger
)
@callback
def async_set_armed_mode(self) -> None:
"""Set the entity state."""
self.async_write_ha_state()
@property
def code_format(self):
"""Regex for code format or None if no code is required."""
return FORMAT_TEXT
@property
def changed_by(self):
"""Last change triggered by."""
return None
@property
def code_arm_required(self):
"""Whether the code is required for arm actions."""
return self._channel.code_required_arm_actions
async def async_alarm_disarm(self, code=None):
"""Send disarm command."""
self._channel.arm(IasAce.ArmMode.Disarm, code, 0)
self.async_write_ha_state()
async def async_alarm_arm_home(self, code=None):
"""Send arm home command."""
self._channel.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0)
self.async_write_ha_state()
async def async_alarm_arm_away(self, code=None):
"""Send arm away command."""
self._channel.arm(IasAce.ArmMode.Arm_All_Zones, code, 0)
self.async_write_ha_state()
async def async_alarm_arm_night(self, code=None):
"""Send arm night command."""
self._channel.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0)
self.async_write_ha_state()
async def async_alarm_trigger(self, code=None):
"""Send alarm trigger command."""
self.async_write_ha_state()
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
return (
SUPPORT_ALARM_ARM_HOME
| SUPPORT_ALARM_ARM_AWAY
| SUPPORT_ALARM_ARM_NIGHT
| SUPPORT_ALARM_TRIGGER
)
@property
def state(self):
"""Return the state of the entity."""
return IAS_ACE_STATE_MAP.get(self._channel.armed_state)
@property
def state_attributes(self):
"""Return the state attributes."""
state_attr = {
ATTR_CODE_FORMAT: self.code_format,
ATTR_CHANGED_BY: self.changed_by,
ATTR_CODE_ARM_REQUIRED: self.code_arm_required,
}
return state_attr

View File

@ -9,6 +9,7 @@ from typing import Any
import voluptuous as vol
from zigpy.config.validators import cv_boolean
from zigpy.types.named import EUI64
from zigpy.zcl.clusters.security import IasAce
import zigpy.zdo.types as zdo_types
from homeassistant.components import websocket_api
@ -54,11 +55,13 @@ from .core.const import (
WARNING_DEVICE_SQUAWK_MODE_ARMED,
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES,
ZHA_ALARM_OPTIONS,
ZHA_CHANNEL_MSG,
ZHA_CONFIG_SCHEMAS,
)
from .core.group import GroupMember
from .core.helpers import (
async_input_cluster_exists,
async_is_bindable_target,
convert_install_code,
get_matched_clusters,
@ -894,6 +897,10 @@ async def websocket_get_configuration(hass, connection, msg):
data = {"schemas": {}, "data": {}}
for section, schema in ZHA_CONFIG_SCHEMAS.items():
if section == ZHA_ALARM_OPTIONS and not async_input_cluster_exists(
hass, IasAce.cluster_id
):
continue
data["schemas"][section] = voluptuous_serialize.convert(
schema, custom_serializer=custom_serializer
)

View File

@ -8,13 +8,15 @@ from __future__ import annotations
import asyncio
from collections.abc import Coroutine
import logging
from zigpy.exceptions import ZigbeeException
import zigpy.zcl.clusters.security as security
from zigpy.zcl.clusters.security import IasAce as AceCluster
from homeassistant.core import callback
from homeassistant.core import CALLABLE_T, callback
from .. import registries
from .. import registries, typing as zha_typing
from ..const import (
SIGNAL_ATTR_UPDATED,
WARNING_DEVICE_MODE_EMERGENCY,
@ -25,11 +27,238 @@ from ..const import (
)
from .base import ChannelStatus, ZigbeeChannel
IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False),
IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False),
IAS_ACE_EMERGENCY = 0x0002 # ("emergency", (), False),
IAS_ACE_FIRE = 0x0003 # ("fire", (), False),
IAS_ACE_PANIC = 0x0004 # ("panic", (), False),
IAS_ACE_GET_ZONE_ID_MAP = 0x0005 # ("get_zone_id_map", (), False),
IAS_ACE_GET_ZONE_INFO = 0x0006 # ("get_zone_info", (t.uint8_t,), False),
IAS_ACE_GET_PANEL_STATUS = 0x0007 # ("get_panel_status", (), False),
IAS_ACE_GET_BYPASSED_ZONE_LIST = 0x0008 # ("get_bypassed_zone_list", (), False),
IAS_ACE_GET_ZONE_STATUS = (
0x0009 # ("get_zone_status", (t.uint8_t, t.uint8_t, t.Bool, t.bitmap16), False)
)
NAME = 0
SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed"
SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered"
@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasAce.cluster_id)
_LOGGER = logging.getLogger(__name__)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(AceCluster.cluster_id)
class IasAce(ZigbeeChannel):
"""IAS Ancillary Control Equipment channel."""
def __init__(
self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType
) -> None:
"""Initialize IAS Ancillary Control Equipment channel."""
super().__init__(cluster, ch_pool)
self.command_map: dict[int, CALLABLE_T] = {
IAS_ACE_ARM: self.arm,
IAS_ACE_BYPASS: self._bypass,
IAS_ACE_EMERGENCY: self._emergency,
IAS_ACE_FIRE: self._fire,
IAS_ACE_PANIC: self._panic,
IAS_ACE_GET_ZONE_ID_MAP: self._get_zone_id_map,
IAS_ACE_GET_ZONE_INFO: self._get_zone_info,
IAS_ACE_GET_PANEL_STATUS: self._send_panel_status_response,
IAS_ACE_GET_BYPASSED_ZONE_LIST: self._get_bypassed_zone_list,
IAS_ACE_GET_ZONE_STATUS: self._get_zone_status,
}
self.arm_map: dict[AceCluster.ArmMode, CALLABLE_T] = {
AceCluster.ArmMode.Disarm: self._disarm,
AceCluster.ArmMode.Arm_All_Zones: self._arm_away,
AceCluster.ArmMode.Arm_Day_Home_Only: self._arm_day,
AceCluster.ArmMode.Arm_Night_Sleep_Only: self._arm_night,
}
self.armed_state: AceCluster.PanelStatus = AceCluster.PanelStatus.Panel_Disarmed
self.invalid_tries: int = 0
# These will all be setup by the entity from zha configuration
self.panel_code: str = "1234"
self.code_required_arm_actions = False
self.max_invalid_tries: int = 3
# where do we store this to handle restarts
self.alarm_status: AceCluster.AlarmStatus = AceCluster.AlarmStatus.No_Alarm
@callback
def cluster_command(self, tsn, command_id, args) -> None:
"""Handle commands received to this cluster."""
self.warning(
"received command %s", self._cluster.server_commands.get(command_id)[NAME]
)
self.command_map[command_id](*args)
def arm(self, arm_mode: int, code: str, zone_id: int):
"""Handle the IAS ACE arm command."""
mode = AceCluster.ArmMode(arm_mode)
self.zha_send_event(
self._cluster.server_commands.get(IAS_ACE_ARM)[NAME],
{
"arm_mode": mode.value,
"arm_mode_description": mode.name,
"code": code,
"zone_id": zone_id,
},
)
zigbee_reply = self.arm_map[mode](code)
self._ch_pool.hass.async_create_task(zigbee_reply)
if self.invalid_tries >= self.max_invalid_tries:
self.alarm_status = AceCluster.AlarmStatus.Emergency
self.armed_state = AceCluster.PanelStatus.In_Alarm
self.async_send_signal(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}")
else:
self.async_send_signal(f"{self.unique_id}_{SIGNAL_ARMED_STATE_CHANGED}")
self._send_panel_status_changed()
def _disarm(self, code: str):
"""Test the code and disarm the panel if the code is correct."""
if (
code != self.panel_code
and self.armed_state != AceCluster.PanelStatus.Panel_Disarmed
):
self.warning("Invalid code supplied to IAS ACE")
self.invalid_tries += 1
zigbee_reply = self.arm_response(
AceCluster.ArmNotification.Invalid_Arm_Disarm_Code
)
else:
self.invalid_tries = 0
if (
self.armed_state == AceCluster.PanelStatus.Panel_Disarmed
and self.alarm_status == AceCluster.AlarmStatus.No_Alarm
):
self.warning("IAS ACE already disarmed")
zigbee_reply = self.arm_response(
AceCluster.ArmNotification.Already_Disarmed
)
else:
self.warning("Disarming all IAS ACE zones")
zigbee_reply = self.arm_response(
AceCluster.ArmNotification.All_Zones_Disarmed
)
self.armed_state = AceCluster.PanelStatus.Panel_Disarmed
self.alarm_status = AceCluster.AlarmStatus.No_Alarm
return zigbee_reply
def _arm_day(self, code: str) -> None:
"""Arm the panel for day / home zones."""
return self._handle_arm(
code,
AceCluster.PanelStatus.Armed_Stay,
AceCluster.ArmNotification.Only_Day_Home_Zones_Armed,
)
def _arm_night(self, code: str) -> None:
"""Arm the panel for night / sleep zones."""
return self._handle_arm(
code,
AceCluster.PanelStatus.Armed_Night,
AceCluster.ArmNotification.Only_Night_Sleep_Zones_Armed,
)
def _arm_away(self, code: str) -> None:
"""Arm the panel for away mode."""
return self._handle_arm(
code,
AceCluster.PanelStatus.Armed_Away,
AceCluster.ArmNotification.All_Zones_Armed,
)
def _handle_arm(
self,
code: str,
panel_status: AceCluster.PanelStatus,
armed_type: AceCluster.ArmNotification,
) -> None:
"""Arm the panel with the specified statuses."""
if self.code_required_arm_actions and code != self.panel_code:
self.warning("Invalid code supplied to IAS ACE")
zigbee_reply = self.arm_response(
AceCluster.ArmNotification.Invalid_Arm_Disarm_Code
)
else:
self.warning("Arming all IAS ACE zones")
self.armed_state = panel_status
zigbee_reply = self.arm_response(armed_type)
return zigbee_reply
def _bypass(self, zone_list, code) -> None:
"""Handle the IAS ACE bypass command."""
self.zha_send_event(
self._cluster.server_commands.get(IAS_ACE_BYPASS)[NAME],
{"zone_list": zone_list, "code": code},
)
def _emergency(self) -> None:
"""Handle the IAS ACE emergency command."""
self._set_alarm(
AceCluster.AlarmStatus.Emergency,
IAS_ACE_EMERGENCY,
)
def _fire(self) -> None:
"""Handle the IAS ACE fire command."""
self._set_alarm(
AceCluster.AlarmStatus.Fire,
IAS_ACE_FIRE,
)
def _panic(self) -> None:
"""Handle the IAS ACE panic command."""
self._set_alarm(
AceCluster.AlarmStatus.Emergency_Panic,
IAS_ACE_PANIC,
)
def _set_alarm(self, status: AceCluster.PanelStatus, event: str) -> None:
"""Set the specified alarm status."""
self.alarm_status = status
self.armed_state = AceCluster.PanelStatus.In_Alarm
self.async_send_signal(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}")
self._send_panel_status_changed()
def _get_zone_id_map(self):
"""Handle the IAS ACE zone id map command."""
def _get_zone_info(self, zone_id):
"""Handle the IAS ACE zone info command."""
def _send_panel_status_response(self) -> None:
"""Handle the IAS ACE panel status response command."""
response = self.panel_status_response(
self.armed_state,
0x00,
AceCluster.AudibleNotification.Default_Sound,
self.alarm_status,
)
self._ch_pool.hass.async_create_task(response)
def _send_panel_status_changed(self) -> None:
"""Handle the IAS ACE panel status changed command."""
response = self.panel_status_changed(
self.armed_state,
0x00,
AceCluster.AudibleNotification.Default_Sound,
self.alarm_status,
)
self._ch_pool.hass.async_create_task(response)
def _get_bypassed_zone_list(self):
"""Handle the IAS ACE bypassed zone list command."""
def _get_zone_status(
self, starting_zone_id, max_zone_ids, zone_status_mask_flag, zone_status_mask
):
"""Handle the IAS ACE zone status command."""
@registries.CHANNEL_ONLY_CLUSTERS.register(security.IasWd.cluster_id)
@registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasWd.cluster_id)

View File

@ -13,6 +13,7 @@ import zigpy_xbee.zigbee.application
import zigpy_zigate.zigbee.application
import zigpy_znp.zigbee.application
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM
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
@ -83,6 +84,7 @@ CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement"
CHANNEL_EVENT_RELAY = "event_relay"
CHANNEL_FAN = "fan"
CHANNEL_HUMIDITY = "humidity"
CHANNEL_IAS_ACE = "ias_ace"
CHANNEL_IAS_WD = "ias_wd"
CHANNEL_IDENTIFY = "identify"
CHANNEL_ILLUMINANCE = "illuminance"
@ -106,6 +108,7 @@ CLUSTER_TYPE_IN = "in"
CLUSTER_TYPE_OUT = "out"
PLATFORMS = (
ALARM,
BINARY_SENSOR,
CLIMATE,
COVER,
@ -118,6 +121,10 @@ PLATFORMS = (
SWITCH,
)
CONF_ALARM_MASTER_CODE = "alarm_master_code"
CONF_ALARM_FAILED_TRIES = "alarm_failed_tries"
CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code"
CONF_BAUDRATE = "baudrate"
CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path"
CONF_DATABASE = "database_path"
@ -137,6 +144,14 @@ CONF_ZHA_OPTIONS_SCHEMA = vol.Schema(
}
)
CONF_ZHA_ALARM_SCHEMA = vol.Schema(
{
vol.Required(CONF_ALARM_MASTER_CODE, default="1234"): cv.string,
vol.Required(CONF_ALARM_FAILED_TRIES, default=3): cv.positive_int,
vol.Required(CONF_ALARM_ARM_REQUIRES_CODE, default=False): cv.boolean,
}
)
CUSTOM_CONFIGURATION = "custom_configuration"
DATA_DEVICE_CONFIG = "zha_device_config"
@ -191,8 +206,13 @@ POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown"
PRESET_SCHEDULE = "schedule"
PRESET_COMPLEX = "complex"
ZHA_ALARM_OPTIONS = "zha_alarm_options"
ZHA_OPTIONS = "zha_options"
ZHA_CONFIG_SCHEMAS = {ZHA_OPTIONS: CONF_ZHA_OPTIONS_SCHEMA}
ZHA_CONFIG_SCHEMAS = {
ZHA_OPTIONS: CONF_ZHA_OPTIONS_SCHEMA,
ZHA_ALARM_OPTIONS: CONF_ZHA_ALARM_SCHEMA,
}
class RadioType(enum.Enum):

View File

@ -65,6 +65,7 @@ from .const import (
UNKNOWN,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
ZHA_OPTIONS,
)
from .helpers import LogMixin, async_get_zha_config_value
@ -396,7 +397,10 @@ class ZHADevice(LogMixin):
async def async_configure(self):
"""Configure the device."""
should_identify = async_get_zha_config_value(
self._zha_gateway.config_entry, CONF_ENABLE_IDENTIFY_ON_JOIN, True
self._zha_gateway.config_entry,
ZHA_OPTIONS,
CONF_ENABLE_IDENTIFY_ON_JOIN,
True,
)
self.debug("started configuration")
await self._channels.async_configure()

View File

@ -15,6 +15,7 @@ from homeassistant.helpers.entity_registry import async_entries_for_device
from . import const as zha_const, registries as zha_regs, typing as zha_typing
from .. import ( # noqa: F401 pylint: disable=unused-import,
alarm_control_panel,
binary_sensor,
climate,
cover,

View File

@ -31,7 +31,6 @@ from .const import (
CUSTOM_CONFIGURATION,
DATA_ZHA,
DATA_ZHA_GATEWAY,
ZHA_OPTIONS,
)
from .registries import BINDABLE_CLUSTERS
from .typing import ZhaDeviceType, ZigpyClusterType
@ -131,15 +130,27 @@ def async_is_bindable_target(source_zha_device, target_zha_device):
@callback
def async_get_zha_config_value(config_entry, config_key, default):
def async_get_zha_config_value(config_entry, section, config_key, default):
"""Get the value for the specified configuration from the zha config entry."""
return (
config_entry.options.get(CUSTOM_CONFIGURATION, {})
.get(ZHA_OPTIONS, {})
.get(section, {})
.get(config_key, default)
)
def async_input_cluster_exists(hass, cluster_id):
"""Determine if a device containing the specified in cluster is paired."""
zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
zha_devices = zha_gateway.devices.values()
for zha_device in zha_devices:
clusters_by_endpoint = zha_device.async_get_clusters()
for clusters in clusters_by_endpoint.values():
if cluster_id in clusters[CLUSTER_TYPE_IN]:
return True
return False
async def async_get_zha_device(hass, device_id):
"""Get a ZHA device for the given device registry id."""
device_registry = await hass.helpers.device_registry.async_get_registry()

View File

@ -9,6 +9,7 @@ import zigpy.profiles.zha
import zigpy.profiles.zll
import zigpy.zcl as zcl
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM
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
@ -104,6 +105,7 @@ DEVICE_CLASS = {
zigpy.profiles.zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH,
zigpy.profiles.zha.DeviceType.SHADE: COVER,
zigpy.profiles.zha.DeviceType.SMART_PLUG: SWITCH,
zigpy.profiles.zha.DeviceType.IAS_ANCILLARY_CONTROL: ALARM,
},
zigpy.profiles.zll.PROFILE_ID: {
zigpy.profiles.zll.DeviceType.COLOR_LIGHT: LIGHT,

View File

@ -54,6 +54,7 @@ from .core.const import (
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
SIGNAL_SET_LEVEL,
ZHA_OPTIONS,
)
from .core.helpers import LogMixin, async_get_zha_config_value
from .core.registries import ZHA_ENTITIES
@ -394,7 +395,10 @@ class Light(BaseLight, ZhaEntity):
self._effect_list = effect_list
self._default_transition = async_get_zha_config_value(
zha_device.gateway.config_entry, CONF_DEFAULT_LIGHT_TRANSITION, 0
zha_device.gateway.config_entry,
ZHA_OPTIONS,
CONF_DEFAULT_LIGHT_TRANSITION,
0,
)
@callback
@ -553,7 +557,10 @@ class LightGroup(BaseLight, ZhaGroupEntity):
self._identify_channel = group.endpoint[Identify.cluster_id]
self._debounced_member_refresh = None
self._default_transition = async_get_zha_config_value(
zha_device.gateway.config_entry, CONF_DEFAULT_LIGHT_TRANSITION, 0
zha_device.gateway.config_entry,
ZHA_OPTIONS,
CONF_DEFAULT_LIGHT_TRANSITION,
0,
)
async def async_added_to_hass(self):

View File

@ -48,6 +48,15 @@ async def config_entry_fixture(hass):
zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0"},
zha_const.CONF_RADIO_TYPE: "ezsp",
},
options={
zha_const.CUSTOM_CONFIGURATION: {
zha_const.ZHA_ALARM_OPTIONS: {
zha_const.CONF_ALARM_ARM_REQUIRES_CODE: False,
zha_const.CONF_ALARM_MASTER_CODE: "4321",
zha_const.CONF_ALARM_FAILED_TRIES: 2,
}
}
},
)
entry.add_to_hass(hass)
return entry

View File

@ -0,0 +1,245 @@
"""Test zha alarm control panel."""
from unittest.mock import AsyncMock, call, patch, sentinel
import pytest
import zigpy.profiles.zha as zha
import zigpy.zcl.clusters.security as security
import zigpy.zcl.foundation as zcl_f
from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
STATE_ALARM_DISARMED,
STATE_ALARM_TRIGGERED,
STATE_UNAVAILABLE,
)
from .common import async_enable_traffic, find_entity_id
@pytest.fixture
def zigpy_device(zigpy_device_mock):
"""Device tracker zigpy device."""
endpoints = {
1: {
"in_clusters": [security.IasAce.cluster_id],
"out_clusters": [],
"device_type": zha.DeviceType.IAS_ANCILLARY_CONTROL,
}
}
return zigpy_device_mock(
endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00"
)
@patch(
"zigpy.zcl.clusters.security.IasAce.client_command",
new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]),
)
async def test_alarm_control_panel(hass, zha_device_joined_restored, zigpy_device):
"""Test zha alarm control panel platform."""
zha_device = await zha_device_joined_restored(zigpy_device)
cluster = zigpy_device.endpoints.get(1).ias_ace
entity_id = await find_entity_id(ALARM_DOMAIN, zha_device, hass)
assert entity_id is not None
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
await async_enable_traffic(hass, [zha_device], enabled=False)
# test that the panel was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, [zha_device])
# test that the state has changed from unavailable to STATE_ALARM_DISARMED
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
# arm_away from HA
cluster.client_command.reset_mock()
await hass.services.async_call(
ALARM_DOMAIN, "alarm_arm_away", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY
assert cluster.client_command.call_count == 2
assert cluster.client_command.await_count == 2
assert cluster.client_command.call_args == call(
4,
security.IasAce.PanelStatus.Armed_Away,
0,
security.IasAce.AudibleNotification.Default_Sound,
security.IasAce.AlarmStatus.No_Alarm,
)
# disarm from HA
await reset_alarm_panel(hass, cluster, entity_id)
# trip alarm from faulty code entry
cluster.client_command.reset_mock()
await hass.services.async_call(
ALARM_DOMAIN, "alarm_arm_away", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY
cluster.client_command.reset_mock()
await hass.services.async_call(
ALARM_DOMAIN,
"alarm_disarm",
{ATTR_ENTITY_ID: entity_id, "code": "1111"},
blocking=True,
)
await hass.services.async_call(
ALARM_DOMAIN,
"alarm_disarm",
{ATTR_ENTITY_ID: entity_id, "code": "1111"},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
assert cluster.client_command.call_count == 4
assert cluster.client_command.await_count == 4
assert cluster.client_command.call_args == call(
4,
security.IasAce.PanelStatus.In_Alarm,
0,
security.IasAce.AudibleNotification.Default_Sound,
security.IasAce.AlarmStatus.Emergency,
)
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
# arm_home from HA
cluster.client_command.reset_mock()
await hass.services.async_call(
ALARM_DOMAIN, "alarm_arm_home", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME
assert cluster.client_command.call_count == 2
assert cluster.client_command.await_count == 2
assert cluster.client_command.call_args == call(
4,
security.IasAce.PanelStatus.Armed_Stay,
0,
security.IasAce.AudibleNotification.Default_Sound,
security.IasAce.AlarmStatus.No_Alarm,
)
# arm_night from HA
cluster.client_command.reset_mock()
await hass.services.async_call(
ALARM_DOMAIN, "alarm_arm_night", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT
assert cluster.client_command.call_count == 2
assert cluster.client_command.await_count == 2
assert cluster.client_command.call_args == call(
4,
security.IasAce.PanelStatus.Armed_Night,
0,
security.IasAce.AudibleNotification.Default_Sound,
security.IasAce.AlarmStatus.No_Alarm,
)
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
# arm from panel
cluster.listener_event(
"cluster_command", 1, 0, [security.IasAce.ArmMode.Arm_All_Zones, "", 0]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
# arm day home only from panel
cluster.listener_event(
"cluster_command", 1, 0, [security.IasAce.ArmMode.Arm_Day_Home_Only, "", 0]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
# arm night sleep only from panel
cluster.listener_event(
"cluster_command", 1, 0, [security.IasAce.ArmMode.Arm_Night_Sleep_Only, "", 0]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT
# disarm from panel with bad code
cluster.listener_event(
"cluster_command", 1, 0, [security.IasAce.ArmMode.Disarm, "", 0]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT
# disarm from panel with bad code for 2nd time trips alarm
cluster.listener_event(
"cluster_command", 1, 0, [security.IasAce.ArmMode.Disarm, "", 0]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
# disarm from panel with good code
cluster.listener_event(
"cluster_command", 1, 0, [security.IasAce.ArmMode.Disarm, "4321", 0]
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
# panic from panel
cluster.listener_event("cluster_command", 1, 4, [])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
# fire from panel
cluster.listener_event("cluster_command", 1, 3, [])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
# emergency from panel
cluster.listener_event("cluster_command", 1, 2, [])
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED
# reset the panel
await reset_alarm_panel(hass, cluster, entity_id)
async def reset_alarm_panel(hass, cluster, entity_id):
"""Reset the state of the alarm panel."""
cluster.client_command.reset_mock()
await hass.services.async_call(
ALARM_DOMAIN,
"alarm_disarm",
{ATTR_ENTITY_ID: entity_id, "code": "4321"},
blocking=True,
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
assert cluster.client_command.call_count == 2
assert cluster.client_command.await_count == 2
assert cluster.client_command.call_args == call(
4,
security.IasAce.PanelStatus.Panel_Disarmed,
0,
security.IasAce.AudibleNotification.Default_Sound,
security.IasAce.AlarmStatus.No_Alarm,
)
cluster.client_command.reset_mock()