Add logbook describe event support to ZHA (#73077)
This commit is contained in:
parent
73f2bca377
commit
a5dc7c5f28
8 changed files with 307 additions and 5 deletions
|
@ -163,7 +163,7 @@ class Channels:
|
||||||
def zha_send_event(self, event_data: dict[str, str | int]) -> None:
|
def zha_send_event(self, event_data: dict[str, str | int]) -> None:
|
||||||
"""Relay events to hass."""
|
"""Relay events to hass."""
|
||||||
self.zha_device.hass.bus.async_fire(
|
self.zha_device.hass.bus.async_fire(
|
||||||
"zha_event",
|
const.ZHA_EVENT,
|
||||||
{
|
{
|
||||||
const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee),
|
const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee),
|
||||||
const.ATTR_UNIQUE_ID: self.unique_id,
|
const.ATTR_UNIQUE_ID: self.unique_id,
|
||||||
|
|
|
@ -374,6 +374,7 @@ ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting"
|
||||||
ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data"
|
ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data"
|
||||||
ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done"
|
ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done"
|
||||||
ZHA_CHANNEL_READS_PER_REQ = 5
|
ZHA_CHANNEL_READS_PER_REQ = 5
|
||||||
|
ZHA_EVENT = "zha_event"
|
||||||
ZHA_GW_MSG = "zha_gateway_message"
|
ZHA_GW_MSG = "zha_gateway_message"
|
||||||
ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized"
|
ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized"
|
||||||
ZHA_GW_MSG_DEVICE_INFO = "device_info"
|
ZHA_GW_MSG_DEVICE_INFO = "device_info"
|
||||||
|
|
|
@ -5,6 +5,7 @@ import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from functools import cached_property
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
@ -280,7 +281,16 @@ class ZHADevice(LogMixin):
|
||||||
"""Return the gateway for this device."""
|
"""Return the gateway for this device."""
|
||||||
return self._zha_gateway
|
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]]:
|
def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]:
|
||||||
"""Return the device automation triggers for this device."""
|
"""Return the device automation triggers for this device."""
|
||||||
triggers = {
|
triggers = {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
"""Provides device automations for ZHA devices that emit events."""
|
"""Provides device automations for ZHA devices that emit events."""
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.automation import (
|
from homeassistant.components.automation import (
|
||||||
|
@ -16,12 +17,12 @@ from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import DOMAIN
|
from . import DOMAIN
|
||||||
|
from .core.const import ZHA_EVENT
|
||||||
from .core.helpers import async_get_zha_device
|
from .core.helpers import async_get_zha_device
|
||||||
|
|
||||||
CONF_SUBTYPE = "subtype"
|
CONF_SUBTYPE = "subtype"
|
||||||
DEVICE = "device"
|
DEVICE = "device"
|
||||||
DEVICE_IEEE = "device_ieee"
|
DEVICE_IEEE = "device_ieee"
|
||||||
ZHA_EVENT = "zha_event"
|
|
||||||
|
|
||||||
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||||
{vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str}
|
{vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str}
|
||||||
|
|
81
homeassistant/components/zha/logbook.py
Normal file
81
homeassistant/components/zha/logbook.py
Normal file
|
@ -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)
|
|
@ -510,7 +510,7 @@ async def test_poll_control_cluster_command(hass, poll_control_device):
|
||||||
checkin_mock = AsyncMock()
|
checkin_mock = AsyncMock()
|
||||||
poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"]
|
poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"]
|
||||||
cluster = poll_control_ch.cluster
|
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):
|
with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock):
|
||||||
tsn = 22
|
tsn = 22
|
||||||
|
|
|
@ -17,6 +17,7 @@ from homeassistant.components.cover import (
|
||||||
SERVICE_SET_COVER_POSITION,
|
SERVICE_SET_COVER_POSITION,
|
||||||
SERVICE_STOP_COVER,
|
SERVICE_STOP_COVER,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.zha.core.const import ZHA_EVENT
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_COMMAND,
|
ATTR_COMMAND,
|
||||||
STATE_CLOSED,
|
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[
|
cluster = zigpy_cover_remote.endpoints[1].out_clusters[
|
||||||
closures.WindowCovering.cluster_id
|
closures.WindowCovering.cluster_id
|
||||||
]
|
]
|
||||||
zha_events = async_capture_events(hass, "zha_event")
|
zha_events = async_capture_events(hass, ZHA_EVENT)
|
||||||
|
|
||||||
# up command
|
# up command
|
||||||
hdr = make_zcl_header(0, global_command=False)
|
hdr = make_zcl_header(0, global_command=False)
|
||||||
|
|
208
tests/components/zha/test_logbook.py
Normal file
208
tests/components/zha/test_logbook.py
Normal file
|
@ -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'}"
|
||||||
|
)
|
Loading…
Add table
Add a link
Reference in a new issue