RFC: Add system health component (#20436)

* Add system health component

* Remove stale comment

* Fix confusing syntax

* Update test_init.py

* Address comments

* Lint

* Move distro check to updater

* Convert to websocket

* Lint

* Make info callback async

* Fix tests

* Fix tests

* Lint

* Catch exceptions
This commit is contained in:
Paulus Schoutsen 2019-01-30 10:57:53 -08:00 committed by GitHub
parent 91aa874c0c
commit cb07ea0d60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 283 additions and 65 deletions

View File

@ -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))

View File

@ -10,21 +10,18 @@ from datetime import timedelta
from distutils.version import StrictVersion from distutils.version import StrictVersion
import json import json
import logging import logging
import os
import platform
import uuid import uuid
import aiohttp import aiohttp
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.const import (
from homeassistant.const import __version__ as current_version ATTR_FRIENDLY_NAME, __version__ as current_version)
from homeassistant.helpers import event from homeassistant.helpers import event
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.util.package import is_virtual_env
REQUIREMENTS = ['distro==1.3.0'] REQUIREMENTS = ['distro==1.3.0']
@ -124,44 +121,22 @@ async def async_setup(hass, config):
return True 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): async def get_newest_version(hass, huuid, include_components):
"""Get the newest Home Assistant version.""" """Get the newest Home Assistant version."""
if huuid: 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 info_object['huuid'] = huuid
else: else:
info_object = {} info_object = {}

View File

@ -22,13 +22,22 @@ result_message = messages.result_message
async_response = decorators.async_response async_response = decorators.async_response
require_admin = decorators.require_admin require_admin = decorators.require_admin
ws_require_user = decorators.ws_require_user ws_require_user = decorators.ws_require_user
websocket_command = decorators.websocket_command
# pylint: enable=invalid-name # pylint: enable=invalid-name
@bind_hass @bind_hass
@callback @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.""" """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) handlers = hass.data.get(DOMAIN)
if handlers is None: if handlers is None:
handlers = hass.data[DOMAIN] = {} handlers = hass.data[DOMAIN] = {}

View File

@ -98,3 +98,17 @@ def ws_require_user(
return check_current_user return check_current_user
return validator 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

View File

@ -105,6 +105,9 @@ map:
# Track the sun # Track the sun
sun: sun:
# Allow diagnosing system problems
system_health:
# Sensors # Sensors
sensor: sensor:
# Weather prediction # Weather prediction

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the system health component."""

View File

@ -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'}

View File

@ -8,7 +8,8 @@ import pytest
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components import updater from homeassistant.components import updater
import homeassistant.util.dt as dt_util 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' NEW_VERSION = '10000.0'
MOCK_VERSION = '10.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 @pytest.fixture
def mock_get_newest_version(): def mock_get_newest_version():
"""Fixture to 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 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 @asyncio.coroutine
def test_get_newest_version_no_analytics_when_no_huuid(hass, aioclient_mock): 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.""" """Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) 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): side_effect=Exception):
res = yield from updater.get_newest_version(hass, None, False) res = yield from updater.get_newest_version(hass, None, False)
assert res == (MOCK_RESPONSE['version'], 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.""" """Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE) 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'}))): Mock(return_value=mock_coro({'fake': 'bla'}))):
res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
assert res == (MOCK_RESPONSE['version'], assert res == (MOCK_RESPONSE['version'],
@ -144,7 +134,7 @@ def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock):
@asyncio.coroutine @asyncio.coroutine
def test_error_fetching_new_version_timeout(hass): def test_error_fetching_new_version_timeout(hass):
"""Test we do not gather analytics when no huuid is passed in.""" """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'}))), \ Mock(return_value=mock_coro({'fake': 'bla'}))), \
patch('async_timeout.timeout', side_effect=asyncio.TimeoutError): patch('async_timeout.timeout', side_effect=asyncio.TimeoutError):
res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) 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.""" """Test we do not gather analytics when no huuid is passed in."""
aioclient_mock.post(updater.UPDATER_URL, text='not json') 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'}))): Mock(return_value=mock_coro({'fake': 'bla'}))):
res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
assert res is None assert res is None
@ -170,7 +160,7 @@ def test_error_fetching_new_version_invalid_response(hass, aioclient_mock):
# 'release-notes' is missing # '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'}))): Mock(return_value=mock_coro({'fake': 'bla'}))):
res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
assert res is None assert res is None

View File

@ -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