diff --git a/CODEOWNERS b/CODEOWNERS index 15e56afdd2e..6d5b91f5ef6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -208,6 +208,8 @@ tests/components/dexcom/* @gagebenne homeassistant/components/dhcp/* @bdraco tests/components/dhcp/* @bdraco homeassistant/components/dht/* @thegardenmonkey +homeassistant/components/diagnostics/* @home-assistant/core +tests/components/diagnostics/* @home-assistant/core homeassistant/components/digital_ocean/* @fabaff homeassistant/components/discogs/* @thibmaek homeassistant/components/dlna_dmr/* @StevenLooman @chishm diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 88f86034aea..94f2aa2b9f6 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -7,6 +7,7 @@ "cloud", "counter", "dhcp", + "diagnostics", "energy", "frontend", "history", diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py new file mode 100644 index 00000000000..b2f899b5a89 --- /dev/null +++ b/homeassistant/components/diagnostics/__init__.py @@ -0,0 +1,123 @@ +"""The Diagnostics integration.""" +from __future__ import annotations + +import json +import logging +from typing import Protocol + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components import http, websocket_api +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import integration_platform +from homeassistant.helpers.json import ExtendedJSONEncoder +from homeassistant.util.json import ( + find_paths_unserializable_data, + format_unserializable_data, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up Diagnostics from a config entry.""" + hass.data[DOMAIN] = {} + + await integration_platform.async_process_integration_platforms( + hass, DOMAIN, _register_diagnostics_platform + ) + + websocket_api.async_register_command(hass, handle_info) + hass.http.register_view(DownloadDiagnosticsView) + + return True + + +class DiagnosticsProtocol(Protocol): + """Define the format that diagnostics platforms can have.""" + + async def async_get_config_entry_diagnostics( + self, hass: HomeAssistant, config_entry: ConfigEntry + ) -> dict: + """Return diagnostics for a config entry.""" + + +async def _register_diagnostics_platform( + hass: HomeAssistant, integration_domain: str, platform: DiagnosticsProtocol +): + """Register a diagnostics platform.""" + hass.data[DOMAIN][integration_domain] = { + "config_entry": getattr(platform, "async_get_config_entry_diagnostics", None) + } + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "diagnostics/list"}) +@callback +def handle_info( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +): + """List all possible diagnostic handlers.""" + connection.send_result( + msg["id"], + [ + { + "domain": domain, + "handlers": {key: val is not None for key, val in info.items()}, + } + for domain, info in hass.data[DOMAIN].items() + ], + ) + + +class DownloadDiagnosticsView(http.HomeAssistantView): + """Download diagnostics view.""" + + url = "/api/diagnostics/{d_type}/{d_id}" + name = "api:diagnostics" + + async def get( # pylint: disable=no-self-use + self, request: web.Request, d_type: str, d_id: str + ) -> web.Response: + """Download diagnostics.""" + if d_type != "config_entry": + return web.Response(status=404) + + hass = request.app["hass"] + config_entry = hass.config_entries.async_get_entry(d_id) + + if config_entry is None: + return web.Response(status=404) + + info = hass.data[DOMAIN].get(config_entry.domain) + + if info is None: + return web.Response(status=404) + + if info["config_entry"] is None: + return web.Response(status=404) + + data = await info["config_entry"](hass, config_entry) + + try: + json_data = json.dumps(data, indent=4, cls=ExtendedJSONEncoder) + except TypeError: + _LOGGER.error( + "Failed to serialize to JSON: %s/%s. Bad data at %s", + d_type, + d_id, + format_unserializable_data(find_paths_unserializable_data(data)), + ) + return web.Response(status=500) + + return web.Response( + body=json_data, + content_type="application/json", + headers={ + "Content-Disposition": f'attachment; filename="{config_entry.domain}-{config_entry.entry_id}.json"' + }, + ) diff --git a/homeassistant/components/diagnostics/const.py b/homeassistant/components/diagnostics/const.py new file mode 100644 index 00000000000..91d3dc5211a --- /dev/null +++ b/homeassistant/components/diagnostics/const.py @@ -0,0 +1,3 @@ +"""Constants for the Diagnostics integration.""" + +DOMAIN = "diagnostics" diff --git a/homeassistant/components/diagnostics/manifest.json b/homeassistant/components/diagnostics/manifest.json new file mode 100644 index 00000000000..ad6edf110b0 --- /dev/null +++ b/homeassistant/components/diagnostics/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "diagnostics", + "name": "Diagnostics", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/diagnostics", + "dependencies": ["http"], + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/diagnostics/strings.json b/homeassistant/components/diagnostics/strings.json new file mode 100644 index 00000000000..1409d36b63b --- /dev/null +++ b/homeassistant/components/diagnostics/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Diagnostics" +} diff --git a/homeassistant/components/diagnostics/translations/en.json b/homeassistant/components/diagnostics/translations/en.json new file mode 100644 index 00000000000..f15fe84c3ed --- /dev/null +++ b/homeassistant/components/diagnostics/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index a0c078e325b..54d4944cf70 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -52,6 +52,7 @@ NO_IOT_CLASS = [ "default_config", "device_automation", "device_tracker", + "diagnostics", "discovery", "downloader", "fan", diff --git a/tests/components/diagnostics/__init__.py b/tests/components/diagnostics/__init__.py new file mode 100644 index 00000000000..620640cd217 --- /dev/null +++ b/tests/components/diagnostics/__init__.py @@ -0,0 +1 @@ +"""Tests for the Diagnostics integration.""" diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py new file mode 100644 index 00000000000..5c329b00d8e --- /dev/null +++ b/tests/components/diagnostics/test_init.py @@ -0,0 +1,56 @@ +"""Test the Diagnostics integration.""" +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_platform + + +@pytest.fixture(autouse=True) +async def mock_diagnostics_integration(hass): + """Mock a diagnostics integration.""" + hass.config.components.add("fake_integration") + mock_platform( + hass, + "fake_integration.diagnostics", + Mock( + async_get_config_entry_diagnostics=AsyncMock( + return_value={ + "hello": "info", + } + ), + ), + ) + assert await async_setup_component(hass, "diagnostics", {}) + + +async def test_websocket_info(hass, hass_ws_client): + """Test camera_thumbnail websocket command.""" + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "diagnostics/list"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == [ + {"domain": "fake_integration", "handlers": {"config_entry": True}} + ] + + +async def test_download_diagnostics(hass, hass_client): + """Test record service.""" + config_entry = MockConfigEntry(domain="fake_integration") + config_entry.add_to_hass(hass) + + client = await hass_client() + response = await client.get( + f"/api/diagnostics/config_entry/{config_entry.entry_id}" + ) + assert response.status == HTTPStatus.OK + assert await response.json() == {"hello": "info"}