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
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 = {}

View file

@ -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] = {}

View file

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

View file

@ -105,6 +105,9 @@ map:
# Track the sun
sun:
# Allow diagnosing system problems
system_health:
# Sensors
sensor:
# 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.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

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