mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 17:57:55 +00:00
Add view to download support package to Cloud component (#135856)
This commit is contained in:
parent
2f5816c5b6
commit
9a9374bf45
@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
|
|||||||
from homeassistant.components.homeassistant import exposed_entities
|
from homeassistant.components.homeassistant import exposed_entities
|
||||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
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.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
|||||||
hass.http.register_view(CloudRegisterView)
|
hass.http.register_view(CloudRegisterView)
|
||||||
hass.http.register_view(CloudResendConfirmView)
|
hass.http.register_view(CloudResendConfirmView)
|
||||||
hass.http.register_view(CloudForgotPasswordView)
|
hass.http.register_view(CloudForgotPasswordView)
|
||||||
|
hass.http.register_view(DownloadSupportPackageView)
|
||||||
|
|
||||||
_CLOUD_ERRORS.update(
|
_CLOUD_ERRORS.update(
|
||||||
{
|
{
|
||||||
@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
|
|||||||
return self.json_message("ok")
|
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.require_admin
|
||||||
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
|
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
@ -101,6 +101,57 @@ async def get_integration_info(
|
|||||||
return result
|
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
|
@callback
|
||||||
def _format_value(val: Any) -> Any:
|
def _format_value(val: Any) -> Any:
|
||||||
"""Format a system health value."""
|
"""Format a system health value."""
|
||||||
@ -115,20 +166,10 @@ async def handle_info(
|
|||||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle an info request via a subscription."""
|
"""Handle an info request via a subscription."""
|
||||||
registrations: dict[str, SystemHealthRegistration] = hass.data[DOMAIN]
|
|
||||||
data = {}
|
data = {}
|
||||||
pending_info: dict[tuple[str, str], asyncio.Task] = {}
|
pending_info: dict[tuple[str, str], asyncio.Task] = {}
|
||||||
|
|
||||||
for domain, domain_data in zip(
|
async for domain, domain_data in _registered_domain_data(hass):
|
||||||
registrations,
|
|
||||||
await asyncio.gather(
|
|
||||||
*(
|
|
||||||
get_integration_info(hass, registration)
|
|
||||||
for registration in registrations.values()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
strict=False,
|
|
||||||
):
|
|
||||||
for key, value in domain_data["info"].items():
|
for key, value in domain_data["info"].items():
|
||||||
if asyncio.iscoroutine(value):
|
if asyncio.iscoroutine(value):
|
||||||
value = asyncio.create_task(value)
|
value = asyncio.create_task(value)
|
||||||
|
49
tests/components/cloud/snapshots/test_http_api.ambr
Normal file
49
tests/components/cloud/snapshots/test_http_api.ambr
Normal file
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
'''
|
||||||
|
# ---
|
@ -1,10 +1,11 @@
|
|||||||
"""Tests for the HTTP API for the cloud component."""
|
"""Tests for the HTTP API for the cloud component."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from hass_nabucasa import thingtalk
|
from hass_nabucasa import thingtalk
|
||||||
@ -15,9 +16,12 @@ from hass_nabucasa.auth import (
|
|||||||
UnknownError,
|
UnknownError,
|
||||||
)
|
)
|
||||||
from hass_nabucasa.const import STATE_CONNECTED
|
from hass_nabucasa.const import STATE_CONNECTED
|
||||||
|
from hass_nabucasa.remote import CertificateStatus
|
||||||
from hass_nabucasa.voice import TTS_VOICES
|
from hass_nabucasa.voice import TTS_VOICES
|
||||||
import pytest
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
|
|
||||||
|
from homeassistant.components import system_health
|
||||||
from homeassistant.components.alexa import errors as alexa_errors
|
from homeassistant.components.alexa import errors as alexa_errors
|
||||||
|
|
||||||
# pylint: disable-next=hass-component-root-import
|
# 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.core import HomeAssistant, State
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util.location import LocationInfo
|
from homeassistant.util.location import LocationInfo
|
||||||
|
|
||||||
|
from tests.common import mock_platform
|
||||||
from tests.components.google_assistant import MockConfig
|
from tests.components.google_assistant import MockConfig
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
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",
|
"user_pool_id": "user_pool_id",
|
||||||
"region": "region",
|
"region": "region",
|
||||||
"relayer_server": "relayer",
|
"relayer_server": "relayer",
|
||||||
|
"acme_server": "cert-server",
|
||||||
"accounts_server": "api-test.hass.io",
|
"accounts_server": "api-test.hass.io",
|
||||||
"google_actions": {"filter": {"include_domains": "light"}},
|
"google_actions": {"filter": {"include_domains": "light"}},
|
||||||
"alexa": {
|
"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.call_count == 1
|
||||||
assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event"
|
assert async_dispatcher_send_mock.mock_calls[0][1][1] == "cloud_event"
|
||||||
assert async_dispatcher_send_mock.mock_calls[0][1][2] == {"type": "logout"}
|
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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user