mirror of
https://github.com/home-assistant/core.git
synced 2025-11-07 01:50:18 +00:00
Add active built-in and custom integrations to Cloud support package (#152452)
This commit is contained in:
@@ -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 += "<details><summary>Built-in integrations</summary>\n\n"
|
||||
markdown += "Domain | Name\n"
|
||||
markdown += "--- | ---\n"
|
||||
for integration in integration_info["builtin_integrations"]:
|
||||
markdown += f"{integration['domain']} | {integration['name']}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Custom integrations
|
||||
if integration_info["custom_integrations"]:
|
||||
markdown += "<details><summary>Custom integrations</summary>\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</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
||||
@@ -19,6 +19,41 @@
|
||||
timezone | US/Pacific
|
||||
config_dir | config
|
||||
|
||||
## Active Integrations
|
||||
|
||||
Built-in integrations: 15
|
||||
Custom integrations: 1
|
||||
|
||||
<details><summary>Built-in integrations</summary>
|
||||
|
||||
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
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Custom integrations</summary>
|
||||
|
||||
Domain | Name | Version | Documentation
|
||||
--- | --- | --- | ---
|
||||
test | Test Components | 1.2.3 | http://example.com
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>mock_no_info_integration</summary>
|
||||
|
||||
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
|
||||
|
||||
<details><summary>Built-in integrations</summary>
|
||||
|
||||
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
|
||||
|
||||
</details>
|
||||
|
||||
<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 | ready
|
||||
instance_id | 12345678901234567890
|
||||
can_reach_cert_server | Exception: Unexpected exception
|
||||
can_reach_cloud_auth | Failed: unreachable
|
||||
can_reach_cloud | ok
|
||||
|
||||
</details>
|
||||
|
||||
## Full logs
|
||||
|
||||
<details><summary>Logs</summary>
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
'''
|
||||
# ---
|
||||
# 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
|
||||
|
||||
<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 | ready
|
||||
instance_id | 12345678901234567890
|
||||
can_reach_cert_server | Exception: Unexpected exception
|
||||
can_reach_cloud_auth | Failed: unreachable
|
||||
can_reach_cloud | ok
|
||||
|
||||
</details>
|
||||
|
||||
## Full logs
|
||||
|
||||
<details><summary>Logs</summary>
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
'''
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user