From 4e56339ba164308e61b9370130b1685a9b01857c Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 9 Aug 2020 20:37:07 -0400 Subject: [PATCH] add event and device action for when devices drop (#38701) --- homeassistant/components/zha/core/device.py | 18 +++- homeassistant/components/zha/strings.json | 3 +- tests/components/zha/test_device_trigger.py | 100 +++++++++++++++++++- 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 53b1dcc163b..b8229793a48 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -30,6 +30,7 @@ from .const import ( ATTR_CLUSTER_ID, ATTR_COMMAND, ATTR_COMMAND_TYPE, + ATTR_DEVICE_IEEE, ATTR_DEVICE_TYPE, ATTR_ENDPOINT_ID, ATTR_ENDPOINTS, @@ -247,9 +248,14 @@ class ZHADevice(LogMixin): @property def device_automation_triggers(self): """Return the device automation triggers for this device.""" + triggers = { + ("device_offline", "device_offline"): { + "device_event_type": "device_offline" + } + } if hasattr(self._zigpy_device, "device_automation_triggers"): - return self._zigpy_device.device_automation_triggers - return None + triggers.update(self._zigpy_device.device_automation_triggers) + return triggers @property def available_signal(self): @@ -346,6 +352,14 @@ class ZHADevice(LogMixin): # reinit channels then signal entities self.hass.async_create_task(self._async_became_available()) return + if availability_changed and not available: + self.hass.bus.async_fire( + "zha_event", + { + ATTR_DEVICE_IEEE: str(self.ieee), + "device_event_type": "device_offline", + }, + ) async_dispatcher_send(self.hass, f"{self._available_signal}_entity") async def _async_became_available(self) -> None: diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index b26cebbd40a..915501b2437 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -51,7 +51,8 @@ "device_tilted": "Device tilted", "device_knocked": "Device knocked \"{subtype}\"", "device_dropped": "Device dropped", - "device_flipped": "Device flipped \"{subtype}\"" + "device_flipped": "Device flipped \"{subtype}\"", + "device_offline": "Device offline" }, "trigger_subtype": { "turn_on": "Turn on", diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 266094963f2..e0a73903e0d 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -1,12 +1,23 @@ """ZHA device automation trigger tests.""" +from datetime import timedelta +import time + import pytest import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation +import homeassistant.components.zha.core.device as zha_core_device from homeassistant.helpers.device_registry import async_get_registry from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import async_get_device_automations, async_mock_service +from .common import async_enable_traffic + +from tests.common import ( + async_fire_time_changed, + async_get_device_automations, + async_mock_service, +) ON = 1 OFF = 0 @@ -49,7 +60,7 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): "out_clusters": [general.OnOff.cluster_id], "device_type": 0, } - }, + } ) zha_device = await zha_device_joined_restored(zigpy_device) @@ -79,6 +90,13 @@ async def test_triggers(hass, mock_devices): triggers = await async_get_device_automations(hass, "trigger", reg_device.id) expected_triggers = [ + { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "device_offline", + "subtype": "device_offline", + }, { "device_id": reg_device.id, "domain": "zha", @@ -128,7 +146,15 @@ async def test_no_triggers(hass, mock_devices): reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}, set()) triggers = await async_get_device_automations(hass, "trigger", reg_device.id) - assert triggers == [] + assert triggers == [ + { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "device_offline", + "subtype": "device_offline", + } + ] async def test_if_fires_on_event(hass, mock_devices, calls): @@ -180,6 +206,74 @@ async def test_if_fires_on_event(hass, mock_devices, calls): assert calls[0].data["message"] == "service called" +async def test_device_offline_fires( + hass, zigpy_device_mock, zha_device_restored, calls +): + """Test for device offline triggers firing.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + "in_clusters": [general.Basic.cluster_id], + "out_clusters": [general.OnOff.cluster_id], + "device_type": 0, + } + } + ) + + zha_device = await zha_device_restored(zigpy_device, last_seen=time.time()) + await async_enable_traffic(hass, [zha_device]) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": zha_device.device_id, + "domain": "zha", + "platform": "device", + "type": "device_offline", + "subtype": "device_offline", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + await hass.async_block_till_done() + assert zha_device.available is True + + zigpy_device.last_seen = ( + time.time() - zha_core_device.CONSIDER_UNAVAILABLE_BATTERY - 2 + ) + + # there are 3 checkins to perform before marking the device unavailable + future = dt_util.utcnow() + timedelta(seconds=90) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + future = dt_util.utcnow() + timedelta(seconds=90) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + future = dt_util.utcnow() + timedelta( + seconds=zha_core_device.CONSIDER_UNAVAILABLE_BATTERY + 100 + ) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert zha_device.available is False + assert len(calls) == 1 + assert calls[0].data["message"] == "service called" + + async def test_exception_no_triggers(hass, mock_devices, calls, caplog): """Test for exception on event triggers firing."""