diff --git a/homeassistant/components/homekit_controller/diagnostics.py b/homeassistant/components/homekit_controller/diagnostics.py new file mode 100644 index 00000000000..83f781013d2 --- /dev/null +++ b/homeassistant/components/homekit_controller/diagnostics.py @@ -0,0 +1,131 @@ +"""Diagnostics support for HomeKit Controller.""" +from __future__ import annotations + +from typing import Any + +from aiohomekit.model.characteristics.characteristic_types import CharacteristicsTypes + +from homeassistant.components.diagnostics import REDACTED, async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .connection import HKDevice +from .const import KNOWN_DEVICES + +REDACTED_CHARACTERISTICS = [ + CharacteristicsTypes.get_uuid(CharacteristicsTypes.SERIAL_NUMBER), +] + +REDACTED_CONFIG_ENTRY_KEYS = [ + "AccessoryIP", + "iOSDeviceLTSK", +] + +REDACTED_STATE = ["access_token", "entity_picture"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + return _async_get_diagnostics(hass, entry, device) + + +@callback +def _async_get_diagnostics_for_device( + hass: HomeAssistant, device: DeviceEntry +) -> dict[str, Any]: + data = {} + + data["name"] = device.name + data["model"] = device.model + data["manfacturer"] = device.manufacturer + data["sw_version"] = device.sw_version + data["hw_version"] = device.hw_version + + entities = data["entities"] = [] + + hass_entities = er.async_entries_for_device( + er.async_get(hass), + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in hass_entities: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = async_redact_data(state.as_dict(), REDACTED_STATE) + state_dict.pop("context", None) + + entities.append( + { + "original_name": entity_entry.original_name, + "original_device_class": entity_entry.original_device_class, + "entity_category": entity_entry.entity_category, + "original_icon": entity_entry.original_icon, + "icon": entity_entry.icon, + "unit_of_measurement": entity_entry.unit_of_measurement, + "device_class": entity_entry.device_class, + "disabled": entity_entry.disabled, + "disabled_by": entity_entry.disabled_by, + "state": state_dict, + } + ) + + return data + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry | None = None +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + hkid = entry.data["AccessoryPairingID"] + connection: HKDevice = hass.data[KNOWN_DEVICES][hkid] + + data = { + "config-entry": { + "title": entry.title, + "version": entry.version, + "data": async_redact_data(entry.data, REDACTED_CONFIG_ENTRY_KEYS), + } + } + + # This is the raw data as returned by homekit + # It is roughly equivalent to what is in .storage/homekit_controller-entity-map + # But it also has the latest values seen by the polling or events + data["entity-map"] = accessories = connection.entity_map.serialize() + + # It contains serial numbers, which we should strip out + for accessory in accessories: + for service in accessory.get("services", []): + for char in service.get("characteristics", []): + try: + normalized = CharacteristicsTypes.get_uuid(char["type"]) + except KeyError: + normalized = char["type"] + + if normalized in REDACTED_CHARACTERISTICS: + char["value"] = REDACTED + + if device: + data["device"] = _async_get_diagnostics_for_device(hass, device) + else: + device_registry = dr.async_get(hass) + + devices = data["devices"] = [] + for device_id in connection.devices.values(): + device = device_registry.async_get(device_id) + devices.append(_async_get_diagnostics_for_device(hass, device)) + + return data diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py new file mode 100644 index 00000000000..292d657257f --- /dev/null +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -0,0 +1,511 @@ +"""Test homekit_controller diagnostics.""" +from aiohttp import ClientSession + +from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.components.homekit_controller.common import ( + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_config_entry(hass: HomeAssistant, hass_client: ClientSession, utcnow): + """Test generating diagnostics for a config entry.""" + accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") + config_entry, _ = await setup_test_accessories(hass, accessories) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert diag == { + "config-entry": { + "title": "test", + "version": 1, + "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, + }, + "entity-map": [ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Koogeek-LS1-20833F", + "description": "Name", + "maxLen": 64, + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "Koogeek", + "description": "Manufacturer", + "maxLen": 64, + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "LS1", + "description": "Model", + "maxLen": 64, + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64, + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify", + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr"], + "format": "string", + "value": "2.2.15", + "description": "Firmware Revision", + "maxLen": 64, + }, + ], + }, + { + "iid": 7, + "type": "00000043-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000025-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": False, + "description": "On", + }, + { + "type": "00000013-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 44, + "description": "Hue", + "unit": "arcdegrees", + "minValue": 0, + "maxValue": 359, + "minStep": 1, + }, + { + "type": "0000002F-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 0, + "description": "Saturation", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "format": "int", + "value": 100, + "description": "Brightness", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr"], + "format": "string", + "value": "Light Strip", + "description": "Name", + "maxLen": 64, + }, + ], + }, + { + "iid": 13, + "type": "4aaaf940-0dec-11e5-b939-0800200c9a66", + "characteristics": [ + { + "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", + "iid": 14, + "perms": ["pr", "pw"], + "format": "tlv8", + "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "description": "TIMER_SETTINGS", + } + ], + }, + { + "iid": 15, + "type": "151909D0-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D2-3802-11E4-916C-0800200C9A66", + "iid": 16, + "perms": ["pr", "hd"], + "format": "string", + "value": "url,data", + "description": "FW Upgrade supported types", + "maxLen": 64, + }, + { + "type": "151909D1-3802-11E4-916C-0800200C9A66", + "iid": 17, + "perms": ["pw", "hd"], + "format": "string", + "description": "FW Upgrade URL", + "maxLen": 64, + }, + { + "type": "151909D6-3802-11E4-916C-0800200C9A66", + "iid": 18, + "perms": ["pr", "ev", "hd"], + "format": "int", + "value": 0, + "description": "FW Upgrade Status", + }, + { + "type": "151909D7-3802-11E4-916C-0800200C9A66", + "iid": 19, + "perms": ["pw", "hd"], + "format": "data", + "description": "FW Upgrade Data", + }, + ], + }, + { + "iid": 20, + "type": "151909D3-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D5-3802-11E4-916C-0800200C9A66", + "iid": 21, + "perms": ["pr", "pw"], + "format": "int", + "value": 0, + "description": "Timezone", + }, + { + "type": "151909D4-3802-11E4-916C-0800200C9A66", + "iid": 22, + "perms": ["pr", "pw"], + "format": "int", + "value": 1550348623, + "description": "Time value since Epoch", + }, + ], + }, + ], + } + ], + "devices": [ + { + "name": "Koogeek-LS1-20833F", + "model": "LS1", + "manfacturer": "Koogeek", + "sw_version": "2.2.15", + "hw_version": "", + "entities": [ + { + "original_name": "Koogeek-LS1-20833F", + "disabled": False, + "disabled_by": None, + "entity_category": None, + "device_class": None, + "original_device_class": None, + "icon": None, + "original_icon": None, + "unit_of_measurement": None, + "state": { + "entity_id": "light.koogeek_ls1_20833f", + "state": "off", + "attributes": { + "supported_color_modes": ["hs"], + "friendly_name": "Koogeek-LS1-20833F", + "supported_features": 17, + }, + "last_changed": "2023-01-01T00:00:00+00:00", + "last_updated": "2023-01-01T00:00:00+00:00", + }, + } + ], + } + ], + } + + +async def test_device(hass: HomeAssistant, hass_client: ClientSession, utcnow): + """Test generating diagnostics for a device entry.""" + accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") + config_entry, _ = await setup_test_accessories(hass, accessories) + + connection = hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] + device_registry = dr.async_get(hass) + device = device_registry.async_get(connection.devices[1]) + + diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) + + assert diag == { + "config-entry": { + "title": "test", + "version": 1, + "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, + }, + "entity-map": [ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pr"], + "format": "string", + "value": "Koogeek-LS1-20833F", + "description": "Name", + "maxLen": 64, + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr"], + "format": "string", + "value": "Koogeek", + "description": "Manufacturer", + "maxLen": 64, + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "LS1", + "description": "Model", + "maxLen": 64, + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "**REDACTED**", + "description": "Serial Number", + "maxLen": 64, + }, + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pw"], + "format": "bool", + "description": "Identify", + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 23, + "perms": ["pr"], + "format": "string", + "value": "2.2.15", + "description": "Firmware Revision", + "maxLen": 64, + }, + ], + }, + { + "iid": 7, + "type": "00000043-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000025-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr", "pw", "ev"], + "format": "bool", + "value": False, + "description": "On", + }, + { + "type": "00000013-0000-1000-8000-0026BB765291", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 44, + "description": "Hue", + "unit": "arcdegrees", + "minValue": 0, + "maxValue": 359, + "minStep": 1, + }, + { + "type": "0000002F-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 0, + "description": "Saturation", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "format": "int", + "value": 100, + "description": "Brightness", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1, + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr"], + "format": "string", + "value": "Light Strip", + "description": "Name", + "maxLen": 64, + }, + ], + }, + { + "iid": 13, + "type": "4aaaf940-0dec-11e5-b939-0800200c9a66", + "characteristics": [ + { + "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", + "iid": 14, + "perms": ["pr", "pw"], + "format": "tlv8", + "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "description": "TIMER_SETTINGS", + } + ], + }, + { + "iid": 15, + "type": "151909D0-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D2-3802-11E4-916C-0800200C9A66", + "iid": 16, + "perms": ["pr", "hd"], + "format": "string", + "value": "url,data", + "description": "FW Upgrade supported types", + "maxLen": 64, + }, + { + "type": "151909D1-3802-11E4-916C-0800200C9A66", + "iid": 17, + "perms": ["pw", "hd"], + "format": "string", + "description": "FW Upgrade URL", + "maxLen": 64, + }, + { + "type": "151909D6-3802-11E4-916C-0800200C9A66", + "iid": 18, + "perms": ["pr", "ev", "hd"], + "format": "int", + "value": 0, + "description": "FW Upgrade Status", + }, + { + "type": "151909D7-3802-11E4-916C-0800200C9A66", + "iid": 19, + "perms": ["pw", "hd"], + "format": "data", + "description": "FW Upgrade Data", + }, + ], + }, + { + "iid": 20, + "type": "151909D3-3802-11E4-916C-0800200C9A66", + "characteristics": [ + { + "type": "151909D5-3802-11E4-916C-0800200C9A66", + "iid": 21, + "perms": ["pr", "pw"], + "format": "int", + "value": 0, + "description": "Timezone", + }, + { + "type": "151909D4-3802-11E4-916C-0800200C9A66", + "iid": 22, + "perms": ["pr", "pw"], + "format": "int", + "value": 1550348623, + "description": "Time value since Epoch", + }, + ], + }, + ], + } + ], + "device": { + "name": "Koogeek-LS1-20833F", + "model": "LS1", + "manfacturer": "Koogeek", + "sw_version": "2.2.15", + "hw_version": "", + "entities": [ + { + "original_name": "Koogeek-LS1-20833F", + "disabled": False, + "disabled_by": None, + "entity_category": None, + "device_class": None, + "original_device_class": None, + "icon": None, + "original_icon": None, + "unit_of_measurement": None, + "state": { + "entity_id": "light.koogeek_ls1_20833f", + "state": "off", + "attributes": { + "supported_color_modes": ["hs"], + "friendly_name": "Koogeek-LS1-20833F", + "supported_features": 17, + }, + "last_changed": "2023-01-01T00:00:00+00:00", + "last_updated": "2023-01-01T00:00:00+00:00", + }, + } + ], + }, + }