diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py new file mode 100644 index 00000000000..fca433550d7 --- /dev/null +++ b/homeassistant/components/system_health/__init__.py @@ -0,0 +1,73 @@ +"""System health component.""" +import asyncio +from collections import OrderedDict +import logging +from typing import Callable, Dict + +import async_timeout +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.components import websocket_api + +DEPENDENCIES = ['http'] +DOMAIN = 'system_health' +INFO_CALLBACK_TIMEOUT = 5 +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +@callback +def async_register_info(hass: HomeAssistantType, domain: str, + info_callback: Callable[[HomeAssistantType], Dict]): + """Register an info callback.""" + data = hass.data.setdefault( + DOMAIN, OrderedDict()).setdefault('info', OrderedDict()) + data[domain] = info_callback + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Set up the System Health component.""" + hass.components.websocket_api.async_register_command(handle_info) + return True + + +async def _info_wrapper(hass, info_callback): + """Wrap info callback.""" + try: + with async_timeout.timeout(INFO_CALLBACK_TIMEOUT): + return await info_callback(hass) + except asyncio.TimeoutError: + return { + 'error': 'Fetching info timed out' + } + except Exception as err: # pylint: disable=W0703 + _LOGGER.exception("Error fetching info") + return { + 'error': str(err) + } + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'system_health/info' +}) +async def handle_info(hass: HomeAssistantType, + connection: websocket_api.ActiveConnection, + msg: Dict): + """Handle an info request.""" + info_callbacks = hass.data.get(DOMAIN, {}).get('info', {}) + data = OrderedDict() + data['homeassistant'] = \ + 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() + ])): + data[domain] = domain_data + + connection.send_message(websocket_api.result_message(msg['id'], data)) diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index 2e32960573d..daa85a2425e 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -10,21 +10,18 @@ from datetime import timedelta from distutils.version import StrictVersion import json import logging -import os -import platform import uuid import aiohttp import async_timeout import voluptuous as vol -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.const import __version__ as current_version +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, __version__ as current_version) from homeassistant.helpers import event from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from homeassistant.util.package import is_virtual_env REQUIREMENTS = ['distro==1.3.0'] @@ -124,44 +121,22 @@ async def async_setup(hass, config): return True -async def get_system_info(hass, include_components): - """Return info about the system.""" - info_object = { - 'arch': platform.machine(), - 'dev': 'dev' in current_version, - 'docker': False, - 'os_name': platform.system(), - 'python_version': platform.python_version(), - 'timezone': dt_util.DEFAULT_TIME_ZONE.zone, - 'version': current_version, - 'virtualenv': is_virtual_env(), - 'hassio': hass.components.hassio.is_hassio(), - } - - if include_components: - info_object['components'] = list(hass.config.components) - - if platform.system() == 'Windows': - info_object['os_version'] = platform.win32_ver()[0] - elif platform.system() == 'Darwin': - info_object['os_version'] = platform.mac_ver()[0] - elif platform.system() == 'FreeBSD': - info_object['os_version'] = platform.release() - elif platform.system() == 'Linux': - import distro - linux_dist = await hass.async_add_job( - distro.linux_distribution, False) - info_object['distribution'] = linux_dist[0] - info_object['os_version'] = linux_dist[1] - info_object['docker'] = os.path.isfile('/.dockerenv') - - return info_object - - async def get_newest_version(hass, huuid, include_components): """Get the newest Home Assistant version.""" if huuid: - info_object = await get_system_info(hass, include_components) + info_object = \ + await hass.helpers.system_info.async_get_system_info() + + if include_components: + info_object['components'] = list(hass.config.components) + + import distro + + linux_dist = await hass.async_add_executor_job( + distro.linux_distribution, False) + info_object['distribution'] = linux_dist[0] + info_object['os_version'] = linux_dist[1] + info_object['huuid'] = huuid else: info_object = {} diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 9c67af820f4..48c8f27996a 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -22,13 +22,22 @@ result_message = messages.result_message async_response = decorators.async_response require_admin = decorators.require_admin ws_require_user = decorators.ws_require_user +websocket_command = decorators.websocket_command # pylint: enable=invalid-name @bind_hass @callback -def async_register_command(hass, command, handler, schema): +def async_register_command(hass, command_or_handler, handler=None, + schema=None): """Register a websocket command.""" + # pylint: disable=protected-access + if handler is None: + handler = command_or_handler + command = handler._ws_command + schema = handler._ws_schema + else: + command = command_or_handler handlers = hass.data.get(DOMAIN) if handlers is None: handlers = hass.data[DOMAIN] = {} diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index d91b884541d..08619f6d15f 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -98,3 +98,17 @@ def ws_require_user( return check_current_user return validator + + +def websocket_command(schema): + """Tag a function as a websocket command.""" + command = schema['type'] + + def decorate(func): + """Decorate ws command function.""" + # pylint: disable=protected-access + func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) + func._ws_command = command + return func + + return decorate diff --git a/homeassistant/config.py b/homeassistant/config.py index 3fd138f54e4..2a9f8f64835 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -105,6 +105,9 @@ map: # Track the sun sun: +# Allow diagnosing system problems +system_health: + # Sensors sensor: # Weather prediction diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py new file mode 100644 index 00000000000..14cf1ff230c --- /dev/null +++ b/homeassistant/helpers/system_info.py @@ -0,0 +1,36 @@ +"""Helper to gather system info.""" +import os +import platform +from typing import Dict + +from homeassistant.const import __version__ as current_version +from homeassistant.loader import bind_hass +from homeassistant.util.package import is_virtual_env +from .typing import HomeAssistantType + + +@bind_hass +async def async_get_system_info(hass: HomeAssistantType) -> Dict: + """Return info about the system.""" + info_object = { + 'version': current_version, + 'dev': 'dev' in current_version, + 'hassio': hass.components.hassio.is_hassio(), + 'virtualenv': is_virtual_env(), + 'python_version': platform.python_version(), + 'docker': False, + 'arch': platform.machine(), + 'timezone': str(hass.config.time_zone), + 'os_name': platform.system(), + } + + if platform.system() == 'Windows': + info_object['os_version'] = platform.win32_ver()[0] + elif platform.system() == 'Darwin': + info_object['os_version'] = platform.mac_ver()[0] + elif platform.system() == 'FreeBSD': + info_object['os_version'] = platform.release() + elif platform.system() == 'Linux': + info_object['docker'] = os.path.isfile('/.dockerenv') + + return info_object diff --git a/tests/components/system_health/__init__.py b/tests/components/system_health/__init__.py new file mode 100644 index 00000000000..d59c20d4da6 --- /dev/null +++ b/tests/components/system_health/__init__.py @@ -0,0 +1 @@ +"""Tests for the system health component.""" diff --git a/tests/components/system_health/test_init.py b/tests/components/system_health/test_init.py new file mode 100644 index 00000000000..e090b11877e --- /dev/null +++ b/tests/components/system_health/test_init.py @@ -0,0 +1,105 @@ +"""Tests for the system health component init.""" +import asyncio +from unittest.mock import Mock + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro + + +@pytest.fixture +def mock_system_info(hass): + """Mock system info.""" + hass.helpers.system_info.async_get_system_info = Mock( + return_value=mock_coro({'hello': True}) + ) + + +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', {}) + 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'] + + assert len(data) == 1 + data = data['homeassistant'] + assert data == {'hello': True} + + +async def test_info_endpoint_register_callback(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint allows registering callbacks.""" + async def mock_info(hass): + return {'storage': 'YAML'} + + 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'] + + assert len(data) == 2 + data = data['lovelace'] + assert data == {'storage': 'YAML'} + + +async def test_info_endpoint_register_callback_timeout(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint timing out.""" + async def mock_info(hass): + raise asyncio.TimeoutError + + 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'] + + assert len(data) == 2 + data = data['lovelace'] + assert data == {'error': 'Fetching info timed out'} + + +async def test_info_endpoint_register_callback_exc(hass, hass_ws_client, + mock_system_info): + """Test that the info endpoint requires auth.""" + async def mock_info(hass): + raise Exception("TEST ERROR") + + 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'] + + assert len(data) == 2 + data = data['lovelace'] + assert data == {'error': 'TEST ERROR'} diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index 23b669928f4..bde6c3b0c61 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -8,7 +8,8 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import updater import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, mock_coro, mock_component +from tests.common import ( + async_fire_time_changed, mock_coro, mock_component, MockDependency) NEW_VERSION = '10000.0' MOCK_VERSION = '10.0' @@ -23,6 +24,13 @@ MOCK_CONFIG = {updater.DOMAIN: { }} +@pytest.fixture(autouse=True) +def mock_distro(): + """Mock distro dep.""" + with MockDependency('distro'): + yield + + @pytest.fixture def mock_get_newest_version(): """Fixture to mock get_newest_version.""" @@ -99,30 +107,12 @@ def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version): assert call[1] is None -@asyncio.coroutine -def test_enabled_component_info(hass, mock_get_uuid): - """Test if new entity is created if new version is available.""" - with patch('homeassistant.components.updater.platform.system', - Mock(return_value="junk")): - res = yield from updater.get_system_info(hass, True) - assert 'components' in res, 'Updater failed to generate component list' - - -@asyncio.coroutine -def test_disable_component_info(hass, mock_get_uuid): - """Test if new entity is created if new version is available.""" - with patch('homeassistant.components.updater.platform.system', - Mock(return_value="junk")): - res = yield from updater.get_system_info(hass, False) - assert 'components' not in res, 'Updater failed, components generate' - - @asyncio.coroutine def test_get_newest_version_no_analytics_when_no_huuid(hass, aioclient_mock): """Test we do not gather analytics when no huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', side_effect=Exception): res = yield from updater.get_newest_version(hass, None, False) assert res == (MOCK_RESPONSE['version'], @@ -134,7 +124,7 @@ def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): """Test we do not gather analytics when no huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res == (MOCK_RESPONSE['version'], @@ -144,7 +134,7 @@ def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): @asyncio.coroutine def test_error_fetching_new_version_timeout(hass): """Test we do not gather analytics when no huuid is passed in.""" - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))), \ patch('async_timeout.timeout', side_effect=asyncio.TimeoutError): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) @@ -156,7 +146,7 @@ def test_error_fetching_new_version_bad_json(hass, aioclient_mock): """Test we do not gather analytics when no huuid is passed in.""" aioclient_mock.post(updater.UPDATER_URL, text='not json') - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res is None @@ -170,7 +160,7 @@ def test_error_fetching_new_version_invalid_response(hass, aioclient_mock): # 'release-notes' is missing }) - with patch('homeassistant.components.updater.get_system_info', + with patch('homeassistant.helpers.system_info.async_get_system_info', Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res is None diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py new file mode 100644 index 00000000000..7f23447e1f4 --- /dev/null +++ b/tests/helpers/test_system_info.py @@ -0,0 +1,12 @@ +"""Tests for the system info helper.""" +import json + +from homeassistant.const import __version__ as current_version + + +async def test_get_system_info(hass): + """Test the get system info.""" + info = await hass.helpers.system_info.async_get_system_info() + assert isinstance(info, dict) + assert info['version'] == current_version + assert json.dumps(info) is not None