From c34af4be86a63d6948b5f55af041602244cca9da Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 17 Sep 2025 00:47:00 +0200 Subject: [PATCH] Add active built-in and custom integrations to Cloud support package (#152452) --- homeassistant/components/cloud/http_api.py | 109 ++++++++ .../cloud/snapshots/test_http_api.ambr | 188 ++++++++++++++ tests/components/cloud/test_http_api.py | 234 ++++++++++++++++++ 3 files changed, 531 insertions(+) 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