diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py new file mode 100644 index 00000000000..6d700c4fb8e --- /dev/null +++ b/homeassistant/components/cloud/system_health.py @@ -0,0 +1,48 @@ +"""Provide info to system health.""" +from hass_nabucasa import Cloud +from yarl import URL + +from homeassistant.components import system_health +from homeassistant.core import HomeAssistant, callback + +from .client import CloudClient +from .const import DOMAIN + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info, "/config/cloud") + + +async def system_health_info(hass): + """Get info for the info page.""" + cloud: Cloud = hass.data[DOMAIN] + client: CloudClient = cloud.client + + data = { + "logged_in": cloud.is_logged_in, + } + + if cloud.is_logged_in: + data["subscription_expiration"] = cloud.expiration_date + data["relayer_connected"] = cloud.is_connected + data["remote_enabled"] = client.prefs.remote_enabled + data["remote_connected"] = cloud.remote.is_connected + data["alexa_enabled"] = client.prefs.alexa_enabled + data["google_enabled"] = client.prefs.google_enabled + + data["can_reach_cert_server"] = system_health.async_check_can_reach_url( + hass, cloud.acme_directory_server + ) + data["can_reach_cloud_auth"] = system_health.async_check_can_reach_url( + hass, + f"https://cognito-idp.{cloud.region}.amazonaws.com/{cloud.user_pool_id}/.well-known/jwks.json", + ) + data["can_reach_cloud"] = system_health.async_check_can_reach_url( + hass, URL(cloud.relayer).with_scheme("https").with_path("/status") + ) + + return data diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index b14a86c4842..e0d1152a049 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -7,10 +7,10 @@ from .const import DOMAIN @callback def async_register( - hass: HomeAssistant, register: system_health.RegisterSystemHealth + hass: HomeAssistant, register: system_health.SystemHealthRegistration ) -> None: """Register system health callbacks.""" - register.async_register_info(system_health_info) + register.async_register_info(system_health_info, "/config/lovelace") async def system_health_info(hass): diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 73308b7b220..97e98d211c0 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -1,15 +1,17 @@ """Support for System health .""" import asyncio import dataclasses +from datetime import datetime import logging -from typing import Callable, Dict +from typing import Awaitable, Callable, Dict, Optional +import aiohttp import async_timeout import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import integration_platform +from homeassistant.helpers import aiohttp_client, integration_platform from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -34,14 +36,14 @@ def async_register_info( _LOGGER.warning( "system_health.async_register_info is deprecated. Add a system_health platform instead." ) - hass.data.setdefault(DOMAIN, {}).setdefault("info", {}) - RegisterSystemHealth(hass, domain).async_register_info(info_callback) + hass.data.setdefault(DOMAIN, {}) + SystemHealthRegistration(hass, domain).async_register_info(info_callback) async def async_setup(hass: HomeAssistant, config: ConfigType): """Set up the System Health component.""" hass.components.websocket_api.async_register_command(handle_info) - hass.data.setdefault(DOMAIN, {"info": {}}) + hass.data.setdefault(DOMAIN, {}) await integration_platform.async_process_integration_platforms( hass, DOMAIN, _register_system_health_platform @@ -52,19 +54,36 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): async def _register_system_health_platform(hass, integration_domain, platform): """Register a system health platform.""" - platform.async_register(hass, RegisterSystemHealth(hass, integration_domain)) + platform.async_register(hass, SystemHealthRegistration(hass, integration_domain)) -async def _info_wrapper(hass, info_callback): - """Wrap info callback.""" +async def get_integration_info( + hass: HomeAssistant, registration: "SystemHealthRegistration" +): + """Get integration system health.""" try: with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): - return await info_callback(hass) + data = await registration.info_callback(hass) except asyncio.TimeoutError: - return {"error": "Fetching info timed out"} - except Exception as err: # pylint: disable=broad-except + data = {"error": {"type": "failed", "error": "timeout"}} + except Exception: # pylint: disable=broad-except _LOGGER.exception("Error fetching info") - return {"error": str(err)} + data = {"error": {"type": "failed", "error": "unknown"}} + + result = {"info": data} + + if registration.manage_url: + result["manage_url"] = registration.manage_url + + return result + + +@callback +def _format_value(val): + """Format a system health value.""" + if isinstance(val, datetime): + return {"value": val.isoformat(), "type": "date"} + return val @websocket_api.async_response @@ -72,37 +91,132 @@ async def _info_wrapper(hass, info_callback): async def handle_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: Dict ): - """Handle an info request.""" - info_callbacks = hass.data.get(DOMAIN, {}).get("info", {}) + """Handle an info request via a subscription.""" + registrations: Dict[str, SystemHealthRegistration] = hass.data[DOMAIN] data = {} - data["homeassistant"] = await hass.helpers.system_info.async_get_system_info() + data["homeassistant"] = { + "info": await hass.helpers.system_info.async_get_system_info() + } - if info_callbacks: - for domain, domain_data in zip( - info_callbacks, - await asyncio.gather( - *( - _info_wrapper(hass, info_callback) - for info_callback in info_callbacks.values() + pending_info = {} + + for domain, domain_data in zip( + registrations, + await asyncio.gather( + *( + get_integration_info(hass, registration) + for registration in registrations.values() + ) + ), + ): + for key, value in domain_data["info"].items(): + if asyncio.iscoroutine(value): + value = asyncio.create_task(value) + if isinstance(value, asyncio.Task): + pending_info[(domain, key)] = value + domain_data["info"][key] = {"type": "pending"} + else: + domain_data["info"][key] = _format_value(value) + + data[domain] = domain_data + + # Confirm subscription + connection.send_result(msg["id"]) + + stop_event = asyncio.Event() + connection.subscriptions[msg["id"]] = stop_event.set + + # Send initial data + connection.send_message( + websocket_api.messages.event_message( + msg["id"], {"type": "initial", "data": data} + ) + ) + + # If nothing pending, wrap it up. + if not pending_info: + connection.send_message( + websocket_api.messages.event_message(msg["id"], {"type": "finish"}) + ) + return + + tasks = [asyncio.create_task(stop_event.wait()), *pending_info.values()] + pending_lookup = {val: key for key, val in pending_info.items()} + + # One task is the stop_event.wait() and is always there + while len(tasks) > 1 and not stop_event.is_set(): + # Wait for first completed task + done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + + if stop_event.is_set(): + for task in tasks: + task.cancel() + return + + # Update subscription of all finished tasks + for result in done: + domain, key = pending_lookup[result] + event_msg = { + "type": "update", + "domain": domain, + "key": key, + } + + if result.exception(): + exception = result.exception() + _LOGGER.error( + "Error fetching system info for %s - %s", + domain, + key, + exc_info=(type(exception), exception, exception.__traceback__), ) - ), - ): - data[domain] = domain_data + event_msg["success"] = False + event_msg["error"] = {"type": "failed", "error": "unknown"} + else: + event_msg["success"] = True + event_msg["data"] = _format_value(result.result()) - connection.send_message(websocket_api.result_message(msg["id"], data)) + connection.send_message( + websocket_api.messages.event_message(msg["id"], event_msg) + ) + + connection.send_message( + websocket_api.messages.event_message(msg["id"], {"type": "finish"}) + ) -@dataclasses.dataclass(frozen=True) -class RegisterSystemHealth: - """Helper class to allow platforms to register.""" +@dataclasses.dataclass() +class SystemHealthRegistration: + """Helper class to track platform registration.""" hass: HomeAssistant domain: str + info_callback: Optional[Callable[[HomeAssistant], Awaitable[Dict]]] = None + manage_url: Optional[str] = None @callback def async_register_info( self, - info_callback: Callable[[HomeAssistant], Dict], + info_callback: Callable[[HomeAssistant], Awaitable[Dict]], + manage_url: Optional[str] = None, ): """Register an info callback.""" - self.hass.data[DOMAIN]["info"][self.domain] = info_callback + self.info_callback = info_callback + self.manage_url = manage_url + self.hass.data[DOMAIN][self.domain] = self + + +async def async_check_can_reach_url( + hass: HomeAssistant, url: str, more_info: Optional[str] = None +) -> str: + """Test if the url can be reached.""" + session = aiohttp_client.async_get_clientsession(hass) + + try: + await session.get(url, timeout=5) + return "ok" + except aiohttp.ClientError: + data = {"type": "failed", "error": "unreachable"} + if more_info is not None: + data["more_info"] = more_info + return data diff --git a/tests/common.py b/tests/common.py index 2db97235f5a..611becabe33 100644 --- a/tests/common.py +++ b/tests/common.py @@ -964,7 +964,7 @@ async def flush_store(store): async def get_system_health_info(hass, domain): """Get system health info.""" - return await hass.data["system_health"]["info"][domain](hass) + return await hass.data["system_health"][domain].info_callback(hass) def mock_integration(hass, module): diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py new file mode 100644 index 00000000000..b69ab462ddb --- /dev/null +++ b/tests/components/cloud/test_system_health.py @@ -0,0 +1,60 @@ +"""Test cloud system health.""" +import asyncio + +from aiohttp import ClientError + +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import Mock +from tests.common import get_system_health_info + + +async def test_cloud_system_health(hass, aioclient_mock): + """Test cloud system health.""" + aioclient_mock.get("https://cloud.bla.com/status", text="") + aioclient_mock.get("https://cert-server", text="") + aioclient_mock.get( + "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", + exc=ClientError, + ) + hass.config.components.add("cloud") + assert await async_setup_component(hass, "system_health", {}) + now = utcnow() + + hass.data["cloud"] = Mock( + region="us-east-1", + user_pool_id="AAAA", + relayer="wss://cloud.bla.com/websocket_api", + acme_directory_server="https://cert-server", + is_logged_in=True, + remote=Mock(is_connected=False), + expiration_date=now, + is_connected=True, + client=Mock( + prefs=Mock( + remote_enabled=True, + alexa_enabled=True, + google_enabled=False, + ) + ), + ) + + info = await get_system_health_info(hass, "cloud") + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info == { + "logged_in": True, + "subscription_expiration": now, + "relayer_connected": True, + "remote_enabled": True, + "remote_connected": False, + "alexa_enabled": True, + "google_enabled": False, + "can_reach_cert_server": "ok", + "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, + "can_reach_cloud": "ok", + } diff --git a/tests/components/system_health/test_init.py b/tests/components/system_health/test_init.py index d07eec5d332..a3ca4aab449 100644 --- a/tests/components/system_health/test_init.py +++ b/tests/components/system_health/test_init.py @@ -1,8 +1,10 @@ """Tests for the system health component init.""" import asyncio +from aiohttp.client_exceptions import ClientError import pytest +from homeassistant.components import system_health from homeassistant.setup import async_setup_component from tests.async_mock import AsyncMock, Mock @@ -17,19 +19,48 @@ def mock_system_info(hass): ) -async def test_info_endpoint_return_info(hass, hass_ws_client, mock_system_info): - """Test that the info endpoint works.""" - assert await async_setup_component(hass, "system_health", {}) +async def gather_system_health_info(hass, hass_ws_client): + """Gather all info.""" client = await hass_ws_client(hass) resp = await client.send_json({"id": 6, "type": "system_health/info"}) + + # Confirm subscription resp = await client.receive_json() assert resp["success"] - data = resp["result"] + + data = {} + + # Get initial data + resp = await client.receive_json() + assert resp["event"]["type"] == "initial" + data = resp["event"]["data"] + + while True: + resp = await client.receive_json() + event = resp["event"] + + if event["type"] == "finish": + break + + assert event["type"] == "update" + + if event["success"]: + data[event["domain"]]["info"][event["key"]] = event["data"] + else: + data[event["domain"]]["info"][event["key"]] = event["error"] + + return data + + +async def test_info_endpoint_return_info(hass, hass_ws_client, mock_system_info): + """Test that the info endpoint works.""" + assert await async_setup_component(hass, "system_health", {}) + data = await gather_system_health_info(hass, hass_ws_client) assert len(data) == 1 data = data["homeassistant"] - assert data == {"hello": True} + assert data == {"info": {"hello": True}} async def test_info_endpoint_register_callback(hass, hass_ws_client, mock_system_info): @@ -40,16 +71,11 @@ async def test_info_endpoint_register_callback(hass, hass_ws_client, mock_system hass.components.system_health.async_register_info("lovelace", mock_info) assert await async_setup_component(hass, "system_health", {}) - client = await hass_ws_client(hass) - - resp = await client.send_json({"id": 6, "type": "system_health/info"}) - resp = await client.receive_json() - assert resp["success"] - data = resp["result"] + data = await gather_system_health_info(hass, hass_ws_client) assert len(data) == 2 data = data["lovelace"] - assert data == {"storage": "YAML"} + assert data == {"info": {"storage": "YAML"}} # Test our test helper works assert await get_system_health_info(hass, "lovelace") == {"storage": "YAML"} @@ -65,16 +91,11 @@ async def test_info_endpoint_register_callback_timeout( hass.components.system_health.async_register_info("lovelace", mock_info) assert await async_setup_component(hass, "system_health", {}) - client = await hass_ws_client(hass) - - resp = await client.send_json({"id": 6, "type": "system_health/info"}) - resp = await client.receive_json() - assert resp["success"] - data = resp["result"] + data = await gather_system_health_info(hass, hass_ws_client) assert len(data) == 2 data = data["lovelace"] - assert data == {"error": "Fetching info timed out"} + assert data == {"info": {"error": {"type": "failed", "error": "timeout"}}} async def test_info_endpoint_register_callback_exc( @@ -87,30 +108,58 @@ async def test_info_endpoint_register_callback_exc( hass.components.system_health.async_register_info("lovelace", mock_info) assert await async_setup_component(hass, "system_health", {}) - client = await hass_ws_client(hass) - - resp = await client.send_json({"id": 6, "type": "system_health/info"}) - resp = await client.receive_json() - assert resp["success"] - data = resp["result"] + data = await gather_system_health_info(hass, hass_ws_client) assert len(data) == 2 data = data["lovelace"] - assert data == {"error": "TEST ERROR"} + assert data == {"info": {"error": {"type": "failed", "error": "unknown"}}} -async def test_platform_loading(hass): +async def test_platform_loading(hass, hass_ws_client, aioclient_mock): """Test registering via platform.""" + aioclient_mock.get("http://example.com/status", text="") + aioclient_mock.get("http://example.com/status_fail", exc=ClientError) hass.config.components.add("fake_integration") mock_platform( hass, "fake_integration.system_health", Mock( async_register=lambda hass, register: register.async_register_info( - AsyncMock(return_value={"hello": "info"}) + AsyncMock( + return_value={ + "hello": "info", + "server_reachable": system_health.async_check_can_reach_url( + hass, "http://example.com/status" + ), + "server_fail_reachable": system_health.async_check_can_reach_url( + hass, + "http://example.com/status_fail", + more_info="http://more-info-url.com", + ), + "async_crash": AsyncMock(side_effect=ValueError)(), + } + ), + "/config/fake_integration", ) ), ) assert await async_setup_component(hass, "system_health", {}) - assert await get_system_health_info(hass, "fake_integration") == {"hello": "info"} + data = await gather_system_health_info(hass, hass_ws_client) + + assert data["fake_integration"] == { + "info": { + "hello": "info", + "server_reachable": "ok", + "server_fail_reachable": { + "type": "failed", + "error": "unreachable", + "more_info": "http://more-info-url.com", + }, + "async_crash": { + "type": "failed", + "error": "unknown", + }, + }, + "manage_url": "/config/fake_integration", + }