diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py new file mode 100644 index 00000000000..1a775723b0b --- /dev/null +++ b/homeassistant/components/zha/diagnostics.py @@ -0,0 +1,79 @@ +"""Provides diagnostics for ZHA.""" +from __future__ import annotations + +import dataclasses +from typing import Any + +import bellows +import pkg_resources +import zigpy +from zigpy.config import CONF_NWK_EXTENDED_PAN_ID +import zigpy_deconz +import zigpy_xbee +import zigpy_zigate +import zigpy_znp + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .core.const import ATTR_IEEE, DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY +from .core.device import ZHADevice +from .core.gateway import ZHAGateway +from .core.helpers import async_get_zha_device + +KEYS_TO_REDACT = { + ATTR_IEEE, + CONF_UNIQUE_ID, + "network_key", + CONF_NWK_EXTENDED_PAN_ID, +} + + +def shallow_asdict(obj: Any) -> dict: + """Return a shallow copy of a dataclass as a dict.""" + if hasattr(obj, "__dataclass_fields__"): + result = {} + + for field in dataclasses.fields(obj): + result[field.name] = shallow_asdict(getattr(obj, field.name)) + + return result + if hasattr(obj, "as_dict"): + return obj.as_dict() + return obj + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict: + """Return diagnostics for a config entry.""" + config: dict = hass.data[DATA_ZHA][DATA_ZHA_CONFIG] + gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + return async_redact_data( + { + "config": config, + "config_entry": config_entry.as_dict(), + "application_state": shallow_asdict(gateway.application_controller.state), + "versions": { + "bellows": bellows.__version__, + "zigpy": zigpy.__version__, + "zigpy_deconz": zigpy_deconz.__version__, + "zigpy_xbee": zigpy_xbee.__version__, + "zigpy_znp": zigpy_znp.__version__, + "zigpy_zigate": zigpy_zigate.__version__, + "zhaquirks": pkg_resources.get_distribution("zha-quirks").version, + }, + }, + KEYS_TO_REDACT, + ) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry +) -> dict: + """Return diagnostics for a device.""" + zha_device: ZHADevice = await async_get_zha_device(hass, device.id) + return async_redact_data(zha_device.zha_device_info, KEYS_TO_REDACT) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 0e969b1b0f3..cb562cd5eaa 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -11,6 +11,7 @@ from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE import zigpy.device import zigpy.group import zigpy.profiles +from zigpy.state import State import zigpy.types import zigpy.zdo.types as zdo_t @@ -54,6 +55,7 @@ def zigpy_app_controller(): app.ieee.return_value = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") type(app).nwk = PropertyMock(return_value=zigpy.types.NWK(0x0000)) type(app).devices = PropertyMock(return_value={}) + type(app).state = PropertyMock(return_value=State()) return app diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py new file mode 100644 index 00000000000..804b6d73316 --- /dev/null +++ b/tests/components/zha/test_diagnostics.py @@ -0,0 +1,86 @@ +"""Tests for the diagnostics data provided by the ESPHome integration.""" + + +import pytest +import zigpy.profiles.zha as zha +import zigpy.zcl.clusters.security as security + +from homeassistant.components.diagnostics.const import REDACTED +from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import async_get + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) + +CONFIG_ENTRY_DIAGNOSTICS_KEYS = [ + "config", + "config_entry", + "application_state", + "versions", +] + + +@pytest.fixture +def zigpy_device(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00" + ) + + +async def test_diagnostics_for_config_entry( + hass: HomeAssistant, + hass_client, + config_entry, + zha_device_joined, + zigpy_device, +): + """Test diagnostics for config entry.""" + await zha_device_joined(zigpy_device) + diagnostics_data = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics_data + for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS: + assert key in diagnostics_data + assert diagnostics_data[key] is not None + + +async def test_diagnostics_for_device( + hass: HomeAssistant, + hass_client, + config_entry, + zha_device_joined, + zigpy_device, +): + """Test diagnostics for device.""" + + zha_device: ZHADevice = await zha_device_joined(zigpy_device) + dev_reg = async_get(hass) + device = dev_reg.async_get_device({("zha", str(zha_device.ieee))}) + assert device + diagnostics_data = await get_diagnostics_for_device( + hass, hass_client, config_entry, device + ) + assert diagnostics_data + device_info: dict = zha_device.zha_device_info + for key, value in device_info.items(): + assert key in diagnostics_data + if key not in KEYS_TO_REDACT: + assert key in diagnostics_data + else: + assert diagnostics_data[key] == REDACTED