diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index 473f553593a..b1a845ef8b0 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"{domain}
\n\n"
+ f"{domain_info_md}"
+ " \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 ce80f6303d9..7d2224fc6fc 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 00000000000..9b2f2e0eb33
--- /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
+
+ mock_no_info_integration
+
+ No information available
+
+
+ cloud
+
+ 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
+
+
+
+
+ '''
+# ---
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index 910fa03d46c..e4a526ceadd 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