diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index 49e4af9e3e5..4a8a569a5a6 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -37,6 +37,10 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.loader import (
+ async_get_custom_components,
+ async_get_loaded_integration,
+)
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
@@ -431,6 +435,79 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
+ async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
+ """Collect information about active and custom integrations."""
+ # Get loaded components from hass.config.components
+ loaded_components = hass.config.components.copy()
+
+ # Get custom integrations
+ custom_domains = set()
+ with suppress(Exception):
+ custom_domains = set(await async_get_custom_components(hass))
+
+ # Separate built-in and custom integrations
+ builtin_integrations = []
+ custom_integrations = []
+
+ for domain in sorted(loaded_components):
+ try:
+ integration = async_get_loaded_integration(hass, domain)
+ except Exception: # noqa: BLE001
+ # Broad exception catch for robustness in support package
+ # generation. If we can't get integration info,
+ # just add the domain
+ if domain in custom_domains:
+ custom_integrations.append(
+ {
+ "domain": domain,
+ "name": "Unknown",
+ "version": "Unknown",
+ "documentation": "Unknown",
+ }
+ )
+ else:
+ builtin_integrations.append(
+ {
+ "domain": domain,
+ "name": "Unknown",
+ }
+ )
+ else:
+ if domain in custom_domains:
+ # This is a custom integration
+ # include version and documentation link
+ version = (
+ str(integration.version) if integration.version else "Unknown"
+ )
+ if not (documentation := integration.documentation):
+ documentation = "Unknown"
+
+ custom_integrations.append(
+ {
+ "domain": domain,
+ "name": integration.name,
+ "version": version,
+ "documentation": documentation,
+ }
+ )
+ else:
+ # This is a built-in integration.
+ # No version needed, as it is always the same as the
+ # Home Assistant version
+ builtin_integrations.append(
+ {
+ "domain": domain,
+ "name": integration.name,
+ }
+ )
+
+ return {
+ "builtin_count": len(builtin_integrations),
+ "builtin_integrations": builtin_integrations,
+ "custom_count": len(custom_integrations),
+ "custom_integrations": custom_integrations,
+ }
+
async def _generate_markdown(
self,
hass: HomeAssistant,
@@ -453,6 +530,38 @@ class DownloadSupportPackageView(HomeAssistantView):
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
+ # Add integration information
+ try:
+ integration_info = await self._get_integration_info(hass)
+ except Exception: # noqa: BLE001
+ # Broad exception catch for robustness in support package generation
+ # If there's any error getting integration info, just note it
+ markdown += "## Active integrations\n\n"
+ markdown += "Unable to collect integration information\n\n"
+ else:
+ markdown += "## Active Integrations\n\n"
+ markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
+ markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
+
+ # Built-in integrations
+ if integration_info["builtin_integrations"]:
+ markdown += "Built-in integrations
\n\n"
+ markdown += "Domain | Name\n"
+ markdown += "--- | ---\n"
+ for integration in integration_info["builtin_integrations"]:
+ markdown += f"{integration['domain']} | {integration['name']}\n"
+ markdown += "\n \n\n"
+
+ # Custom integrations
+ if integration_info["custom_integrations"]:
+ markdown += "Custom integrations
\n\n"
+ markdown += "Domain | Name | Version | Documentation\n"
+ markdown += "--- | --- | --- | ---\n"
+ for integration in integration_info["custom_integrations"]:
+ doc_url = integration.get("documentation") or "N/A"
+ markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
+ markdown += "\n \n\n"
+
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr
index 52c544dc541..9e1f68e23f8 100644
--- a/tests/components/cloud/snapshots/test_http_api.ambr
+++ b/tests/components/cloud/snapshots/test_http_api.ambr
@@ -19,6 +19,41 @@
timezone | US/Pacific
config_dir | config
+ ## Active Integrations
+
+ Built-in integrations: 15
+ Custom integrations: 1
+
+ Built-in integrations
+
+ Domain | Name
+ --- | ---
+ auth | Auth
+ binary_sensor | Binary Sensor
+ cloud | Home Assistant Cloud
+ cloud.binary_sensor | Unknown
+ cloud.stt | Unknown
+ cloud.tts | Unknown
+ ffmpeg | FFmpeg
+ homeassistant | Home Assistant Core Integration
+ http | HTTP
+ mock_no_info_integration | mock_no_info_integration
+ repairs | Repairs
+ stt | Speech-to-text (STT)
+ system_health | System Health
+ tts | Text-to-speech (TTS)
+ webhook | Webhook
+
+
+
+ Custom integrations
+
+ Domain | Name | Version | Documentation
+ --- | --- | --- | ---
+ test | Test Components | 1.2.3 | http://example.com
+
+
+
mock_no_info_integration
No information available
@@ -59,3 +94,156 @@
'''
# ---
+# name: test_download_support_package_custom_components_error
+ '''
+ ## System Information
+
+ version | core-2025.2.0
+ --- | ---
+ installation_type | Home Assistant Core
+ dev | False
+ hassio | False
+ docker | False
+ container_arch | None
+ 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
+
+ ## Active Integrations
+
+ Built-in integrations: 15
+ Custom integrations: 0
+
+ Built-in integrations
+
+ Domain | Name
+ --- | ---
+ auth | Auth
+ binary_sensor | Binary Sensor
+ cloud | Home Assistant Cloud
+ cloud.binary_sensor | Unknown
+ cloud.stt | Unknown
+ cloud.tts | Unknown
+ ffmpeg | FFmpeg
+ homeassistant | Home Assistant Core Integration
+ http | HTTP
+ mock_no_info_integration | mock_no_info_integration
+ repairs | Repairs
+ stt | Speech-to-text (STT)
+ system_health | System Health
+ tts | Text-to-speech (TTS)
+ webhook | Webhook
+
+
+
+ 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 | ready
+ instance_id | 12345678901234567890
+ can_reach_cert_server | Exception: Unexpected exception
+ can_reach_cloud_auth | Failed: unreachable
+ can_reach_cloud | ok
+
+
+
+ ## Full logs
+
+ Logs
+
+ ```logs
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] This message will be dropped since this test patches MAX_RECORDS
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
+ 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
+ 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
+ ```
+
+
+
+ '''
+# ---
+# name: test_download_support_package_integration_load_error
+ '''
+ ## System Information
+
+ version | core-2025.2.0
+ --- | ---
+ installation_type | Home Assistant Core
+ dev | False
+ hassio | False
+ docker | False
+ container_arch | None
+ 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
+
+ ## Active integrations
+
+ Unable to collect integration information
+
+ 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 | ready
+ instance_id | 12345678901234567890
+ can_reach_cert_server | Exception: Unexpected exception
+ can_reach_cloud_auth | Failed: unreachable
+ can_reach_cloud | ok
+
+
+
+ ## Full logs
+
+ Logs
+
+ ```logs
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] This message will be dropped since this test patches MAX_RECORDS
+ 2025-02-10 12:00:00.000 INFO (MainThread) [hass_nabucasa.iot] Hass nabucasa log
+ 2025-02-10 12:00:00.000 WARNING (MainThread) [snitun.utils.aiohttp_client] Snitun log
+ 2025-02-10 12:00:00.000 ERROR (MainThread) [homeassistant.components.cloud.client] Cloud log
+ ```
+
+
+
+ '''
+# ---
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index 96927477b0a..5256ff8a509 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -36,6 +36,7 @@ from homeassistant.components.homeassistant import exposed_entities
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.loader import async_get_loaded_integration
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.location import LocationInfo
@@ -1840,6 +1841,7 @@ async def test_logout_view_dispatch_event(
@patch("homeassistant.components.cloud.helpers.FixedSizeQueueLogHandler.MAX_RECORDS", 3)
+@pytest.mark.usefixtures("enable_custom_integrations")
async def test_download_support_package(
hass: HomeAssistant,
cloud: MagicMock,
@@ -1875,6 +1877,9 @@ async def test_download_support_package(
)
hass.config.components.add("mock_no_info_integration")
+ # Add mock custom integration for testing
+ hass.config.components.add("test") # This is a custom integration from the fixture
+
assert await async_setup_component(hass, "system_health", {})
with patch("uuid.UUID.hex", new_callable=PropertyMock) as hexmock:
@@ -1947,3 +1952,232 @@ async def test_download_support_package(
req = await cloud_client.get("/api/cloud/support_package")
assert req.status == HTTPStatus.OK
assert await req.text() == snapshot
+
+
+@pytest.mark.usefixtures("enable_custom_integrations")
+async def test_download_support_package_custom_components_error(
+ hass: HomeAssistant,
+ cloud: MagicMock,
+ set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test download support package when async_get_custom_components fails."""
+
+ 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,
+ }
+ )
+
+ now = dt_util.utcnow()
+ tz = now.astimezone().tzinfo
+ freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
+ logging.getLogger("hass_nabucasa.iot").info(
+ "This message will be dropped since this test patches MAX_RECORDS"
+ )
+ logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
+ logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
+ logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
+ freezer.move_to(now)
+
+ 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,
+ "container_arch": None,
+ "arch": "x86_64",
+ "timezone": "US/Pacific",
+ "os_name": "Linux",
+ "os_version": "6.12.9",
+ "user": "hass",
+ },
+ ),
+ patch(
+ "homeassistant.components.cloud.http_api.async_get_custom_components",
+ side_effect=Exception("Custom components error"),
+ ),
+ ):
+ req = await cloud_client.get("/api/cloud/support_package")
+ assert req.status == HTTPStatus.OK
+ assert await req.text() == snapshot
+
+
+@pytest.mark.usefixtures("enable_custom_integrations")
+async def test_download_support_package_integration_load_error(
+ hass: HomeAssistant,
+ cloud: MagicMock,
+ set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]],
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+ snapshot: SnapshotAssertion,
+) -> None:
+ """Test download support package when async_get_loaded_integration fails."""
+
+ 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")
+ # Add a component that will fail to load integration info
+ hass.config.components.add("test") # This is a custom integration from the fixture
+ hass.config.components.add("failing_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,
+ }
+ )
+
+ now = dt_util.utcnow()
+ tz = now.astimezone().tzinfo
+ freezer.move_to(datetime.datetime(2025, 2, 10, 12, 0, 0, tzinfo=tz))
+ logging.getLogger("hass_nabucasa.iot").info(
+ "This message will be dropped since this test patches MAX_RECORDS"
+ )
+ logging.getLogger("hass_nabucasa.iot").info("Hass nabucasa log")
+ logging.getLogger("snitun.utils.aiohttp_client").warning("Snitun log")
+ logging.getLogger("homeassistant.components.cloud.client").error("Cloud log")
+ freezer.move_to(now)
+
+ 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,
+ "container_arch": None,
+ "arch": "x86_64",
+ "timezone": "US/Pacific",
+ "os_name": "Linux",
+ "os_version": "6.12.9",
+ "user": "hass",
+ },
+ ),
+ patch(
+ "homeassistant.components.cloud.http_api.async_get_loaded_integration",
+ side_effect=lambda hass, domain: Exception("Integration load error")
+ if domain == "failing_integration"
+ else async_get_loaded_integration(hass, domain),
+ ),
+ ):
+ req = await cloud_client.get("/api/cloud/support_package")
+ assert req.status == HTTPStatus.OK
+ assert await req.text() == snapshot