diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a0df976486f..33143821f9c 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -163,7 +163,7 @@ class Channels: def zha_send_event(self, event_data: dict[str, str | int]) -> None: """Relay events to hass.""" self.zha_device.hass.bus.async_fire( - "zha_event", + const.ZHA_EVENT, { const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), const.ATTR_UNIQUE_ID: self.unique_id, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 1e249ebd52b..c2d9e926453 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -374,6 +374,7 @@ ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" ZHA_CHANNEL_READS_PER_REQ = 5 +ZHA_EVENT = "zha_event" ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" ZHA_GW_MSG_DEVICE_INFO = "device_info" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index e5b3403ba54..6e72c17ef42 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable from datetime import timedelta from enum import Enum +from functools import cached_property import logging import random import time @@ -280,7 +281,16 @@ class ZHADevice(LogMixin): """Return the gateway for this device.""" return self._zha_gateway - @property + @cached_property + def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: + """Return the a lookup of commands to etype/sub_type.""" + commands: dict[str, list[tuple[str, str]]] = {} + for etype_subtype, trigger in self.device_automation_triggers.items(): + if command := trigger.get(ATTR_COMMAND): + commands.setdefault(command, []).append(etype_subtype) + return commands + + @cached_property def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" triggers = { diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 670b1cc1477..44682aaa559 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -1,4 +1,5 @@ """Provides device automations for ZHA devices that emit events.""" + import voluptuous as vol from homeassistant.components.automation import ( @@ -16,12 +17,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from . import DOMAIN +from .core.const import ZHA_EVENT from .core.helpers import async_get_zha_device CONF_SUBTYPE = "subtype" DEVICE = "device" DEVICE_IEEE = "device_ieee" -ZHA_EVENT = "zha_event" TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py new file mode 100644 index 00000000000..e2d238ddbe8 --- /dev/null +++ b/homeassistant/components/zha/logbook.py @@ -0,0 +1,81 @@ +"""Describe ZHA logbook events.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from homeassistant.components.logbook.const import ( + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .core.const import DOMAIN as ZHA_DOMAIN, ZHA_EVENT +from .core.helpers import async_get_zha_device + +if TYPE_CHECKING: + from .core.device import ZHADevice + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + device_registry = dr.async_get(hass) + + @callback + def async_describe_zha_event(event: Event) -> dict[str, str]: + """Describe zha logbook event.""" + device: dr.DeviceEntry | None = None + device_name: str = "Unknown device" + zha_device: ZHADevice | None = None + event_data: dict = event.data + event_type: str | None = None + event_subtype: str | None = None + + try: + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + if device: + device_name = device.name_by_user or device.name or "Unknown device" + zha_device = async_get_zha_device(hass, event.data[ATTR_DEVICE_ID]) + except (KeyError, AttributeError): + pass + + if ( + zha_device + and (command := event_data.get(ATTR_COMMAND)) + and (command_to_etype_subtype := zha_device.device_automation_commands) + and (etype_subtypes := command_to_etype_subtype.get(command)) + ): + all_triggers = zha_device.device_automation_triggers + for etype_subtype in etype_subtypes: + trigger = all_triggers[etype_subtype] + if not all( + event_data.get(key) == value for key, value in trigger.items() + ): + continue + event_type, event_subtype = etype_subtype + break + + if event_type is None: + event_type = event_data[ATTR_COMMAND] + + if event_subtype is not None and event_subtype != event_type: + event_type = f"{event_type} - {event_subtype}" + + event_type = event_type.replace("_", " ").title() + + message = f"{event_type} event was fired" + if event_data["params"]: + message = f"{message} with parameters: {event_data['params']}" + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(ZHA_DOMAIN, ZHA_EVENT, async_describe_zha_event) diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 79b8dbc6a71..e55e10bd7ae 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -510,7 +510,7 @@ async def test_poll_control_cluster_command(hass, poll_control_device): checkin_mock = AsyncMock() poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] cluster = poll_control_ch.cluster - events = async_capture_events(hass, "zha_event") + events = async_capture_events(hass, zha_const.ZHA_EVENT) with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock): tsn = 22 diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 3c00b5d3109..d5b07e16685 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -17,6 +17,7 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, ) +from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( ATTR_COMMAND, STATE_CLOSED, @@ -410,7 +411,7 @@ async def test_cover_remote(hass, zha_device_joined_restored, zigpy_cover_remote cluster = zigpy_cover_remote.endpoints[1].out_clusters[ closures.WindowCovering.cluster_id ] - zha_events = async_capture_events(hass, "zha_event") + zha_events = async_capture_events(hass, ZHA_EVENT) # up command hdr = make_zcl_header(0, global_command=False) diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py new file mode 100644 index 00000000000..00e1cc28ea6 --- /dev/null +++ b/tests/components/zha/test_logbook.py @@ -0,0 +1,208 @@ +"""ZHA logbook describe events tests.""" + +import pytest +import zigpy.profiles.zha +import zigpy.zcl.clusters.general as general + +from homeassistant.components.zha.core.const import ZHA_EVENT +from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +from tests.components.logbook.common import MockRow, mock_humanify + +ON = 1 +OFF = 0 +SHAKEN = "device_shaken" +COMMAND = "command" +COMMAND_SHAKE = "shake" +COMMAND_HOLD = "hold" +COMMAND_SINGLE = "single" +COMMAND_DOUBLE = "double" +DOUBLE_PRESS = "remote_button_double_press" +SHORT_PRESS = "remote_button_short_press" +LONG_PRESS = "remote_button_long_press" +LONG_RELEASE = "remote_button_long_release" +UP = "up" +DOWN = "down" + + +@pytest.fixture +async def mock_devices(hass, zigpy_device_mock, zha_device_joined): + """IAS device fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + + zha_device = await zha_device_joined(zigpy_device) + zha_device.update_available(True) + await hass.async_block_till_done() + return zigpy_device, zha_device + + +async def test_zha_logbook_event_device_with_triggers(hass, mock_devices): + """Test zha logbook events with device and triggers.""" + + zigpy_device, zha_device = mock_devices + + zigpy_device.device_automation_triggers = { + (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, + (UP, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE, "endpoint_id": 1}, + (DOWN, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE, "endpoint_id": 2}, + (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}, + (LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD}, + (LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD}, + } + + ieee_address = str(zha_device.ieee) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_SHAKE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_DOUBLE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_DOUBLE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 2, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "FakeManufacturer FakeModel" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Device Shaken event was fired with parameters: {'test': 'test'}" + ) + + assert events[1]["name"] == "FakeManufacturer FakeModel" + assert events[1]["domain"] == "zha" + assert ( + events[1]["message"] + == "Up - Remote Button Double Press event was fired with parameters: {'test': 'test'}" + ) + + +async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): + """Test zha logbook events with device and without triggers.""" + + zigpy_device, zha_device = mock_devices + ieee_address = str(zha_device.ieee) + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_SHAKE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "FakeManufacturer FakeModel" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Shake event was fired with parameters: {'test': 'test'}" + ) + + +async def test_zha_logbook_event_device_no_device(hass, mock_devices): + """Test zha logbook events without device and without triggers.""" + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: "non-existing-device", + COMMAND: COMMAND_SHAKE, + "device_ieee": "90:fd:9f:ff:fe:fe:d8:a1", + CONF_UNIQUE_ID: "90:fd:9f:ff:fe:fe:d8:a1:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "Unknown device" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Shake event was fired with parameters: {'test': 'test'}" + )