diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 473f553593adaf76376dcb98bbf57f52e0c7da9a..b1a845ef8b01fba73a1c73ab65c477d4c79adb63 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.system_health import get_info as get_system_health_info from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None: hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) + hass.http.register_view(DownloadSupportPackageView) _CLOUD_ERRORS.update( { @@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView): return self.json_message("ok") +class DownloadSupportPackageView(HomeAssistantView): + """Download support package view.""" + + url = "/api/cloud/support_package" + name = "api:cloud:support_package" + + def _generate_markdown( + self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]] + ) -> str: + def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: + if len(domain_info) == 0: + return "No information available\n" + + markdown = "" + first = True + for key, value in domain_info.items(): + markdown += f"{key} | {value}\n" + if first: + markdown += "--- | ---\n" + first = False + return markdown + "\n" + + markdown = "## System Information\n\n" + markdown += get_domain_table_markdown(hass_info) + + for domain, domain_info in domains_info.items(): + domain_info_md = get_domain_table_markdown(domain_info) + markdown += ( + f"<details><summary>{domain}</summary>\n\n" + f"{domain_info_md}" + "</details>\n\n" + ) + + return markdown + + async def get(self, request: web.Request) -> web.Response: + """Download support package file.""" + + hass = request.app[KEY_HASS] + domain_health = await get_system_health_info(hass) + + hass_info = domain_health.pop("homeassistant", {}) + markdown = self._generate_markdown(hass_info, domain_health) + + return web.Response( + body=markdown, + content_type="text/markdown", + headers={ + "Content-Disposition": 'attachment; filename="support_package.md"' + }, + ) + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"}) @websocket_api.async_response diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index ce80f6303d913d1fefb0351ec814048478a876c0..7d2224fc6fcc94832eb285bc64ea4ef5cc9db3d3 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable import dataclasses from datetime import datetime import logging @@ -101,6 +101,57 @@ async def get_integration_info( return result +async def _registered_domain_data( + hass: HomeAssistant, +) -> AsyncGenerator[tuple[str, dict[str, Any]]]: + registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] + for domain, domain_data in zip( + registrations, + await asyncio.gather( + *( + get_integration_info(hass, registration) + for registration in registrations.values() + ) + ), + strict=False, + ): + yield domain, domain_data + + +async def get_info(hass: HomeAssistant) -> dict[str, dict[str, str]]: + """Get the full set of system health information.""" + domains: dict[str, dict[str, Any]] = {} + + async def _get_info_value(value: Any) -> Any: + if not asyncio.iscoroutine(value): + return value + try: + return await value + except Exception as exception: + _LOGGER.exception("Error fetching system info for %s - %s", domain, key) + return f"Exception: {exception}" + + async for domain, domain_data in _registered_domain_data(hass): + domain_info: dict[str, Any] = {} + for key, value in domain_data["info"].items(): + info_value = await _get_info_value(value) + + if isinstance(info_value, datetime): + domain_info[key] = info_value.isoformat() + elif ( + isinstance(info_value, dict) + and "type" in info_value + and info_value["type"] == "failed" + ): + domain_info[key] = f"Failed: {info_value.get('error', 'unknown')}" + else: + domain_info[key] = info_value + + domains[domain] = domain_info + + return domains + + @callback def _format_value(val: Any) -> Any: """Format a system health value.""" @@ -115,20 +166,10 @@ async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle an info request via a subscription.""" - registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN] data = {} pending_info: dict[tuple[str, str], asyncio.Task] = {} - for domain, domain_data in zip( - registrations, - await asyncio.gather( - *( - get_integration_info(hass, registration) - for registration in registrations.values() - ) - ), - strict=False, - ): + async for domain, domain_data in _registered_domain_data(hass): for key, value in domain_data["info"].items(): if asyncio.iscoroutine(value): value = asyncio.create_task(value) diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr new file mode 100644 index 0000000000000000000000000000000000000000..9b2f2e0eb337077dcab8cc198ca5a3e5c79b382d --- /dev/null +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_download_support_package + ''' + ## System Information + + version | core-2025.2.0 + --- | --- + installation_type | Home Assistant Core + dev | False + hassio | False + docker | False + user | hass + virtualenv | False + python_version | 3.13.1 + os_name | Linux + os_version | 6.12.9 + arch | x86_64 + timezone | US/Pacific + config_dir | config + + <details><summary>mock_no_info_integration</summary> + + No information available + </details> + + <details><summary>cloud</summary> + + logged_in | True + --- | --- + subscription_expiration | 2025-01-17T11:19:31+00:00 + relayer_connected | True + relayer_region | xx-earth-616 + remote_enabled | True + remote_connected | False + alexa_enabled | True + google_enabled | False + cloud_ice_servers_enabled | True + remote_server | us-west-1 + certificate_status | CertificateStatus.READY + instance_id | 12345678901234567890 + can_reach_cert_server | Exception: Unexpected exception + can_reach_cloud_auth | Failed: unreachable + can_reach_cloud | ok + + </details> + + + ''' +# --- diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 910fa03d46cdb87dfdd37bec46ffcd85e6711d9c..e4a526ceadd2ad973b1cef8f0bc51786d8835b46 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,10 +1,11 @@ """Tests for the HTTP API for the cloud component.""" +from collections.abc import Callable, Coroutine from copy import deepcopy from http import HTTPStatus import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import aiohttp from hass_nabucasa import thingtalk @@ -15,9 +16,12 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.remote import CertificateStatus from hass_nabucasa.voice import TTS_VOICES import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import system_health from homeassistant.components.alexa import errors as alexa_errors # pylint: disable-next=hass-component-root-import @@ -30,8 +34,10 @@ from homeassistant.components.websocket_api import ERR_INVALID_FORMAT from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.location import LocationInfo +from tests.common import mock_platform from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -113,6 +119,7 @@ async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: "user_pool_id": "user_pool_id", "region": "region", "relayer_server": "relayer", + "acme_server": "cert-server", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, "alexa": { @@ -1860,3 +1867,96 @@ async def test_logout_view_dispatch_event( assert async_dispatcher_send_mock.call_count == 1 assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event" assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"} + + +async def test_download_support_package( + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + hass_client: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + snapshot: SnapshotAssertion, +) -> None: + """Test downloading a support package file.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get( + "https://cert-server/directory", exc=Exception("Unexpected exception") + ) + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=aiohttp.ClientError, + ) + + def async_register_mock_platform( + hass: HomeAssistant, register: system_health.SystemHealthRegistration + ) -> None: + async def mock_empty_info(hass: HomeAssistant) -> dict[str, Any]: + return {} + + register.async_register_info(mock_empty_info, "/config/mock_integration") + + mock_platform( + hass, + "mock_no_info_integration.system_health", + MagicMock(async_register=async_register_mock_platform), + ) + hass.config.components.add("mock_no_info_integration") + + assert await async_setup_component(hass, "system_health", {}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock: + hexmock.return_value = "12345678901234567890" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() + + await cloud.login("test-user", "test-pass") + + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + cloud.expiration_date = dt_util.parse_datetime("2025-01-17T11:19:31.0+00:00") + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + { + "alexa_enabled": True, + "google_enabled": False, + "remote_enabled": True, + "cloud_ice_servers_enabled": True, + } + ) + + cloud_client = await hass_client() + with ( + patch.object(hass.config, "config_dir", new="config"), + patch( + "homeassistant.components.homeassistant.system_health.system_info.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "version": "2025.2.0", + "dev": False, + "hassio": False, + "virtualenv": False, + "python_version": "3.13.1", + "docker": False, + "arch": "x86_64", + "timezone": "US/Pacific", + "os_name": "Linux", + "os_version": "6.12.9", + "user": "hass", + }, + ), + ): + req = await cloud_client.get("/api/cloud/support_package") + assert req.status == HTTPStatus.OK + assert await req.text() == snapshot