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:
parent
91aa874c0c
commit
cb07ea0d60
10 changed files with 283 additions and 65 deletions
73
homeassistant/components/system_health/__init__.py
Normal file
73
homeassistant/components/system_health/__init__.py
Normal 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))
|
|
@ -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 = {}
|
||||
|
|
|
@ -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] = {}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -105,6 +105,9 @@ map:
|
|||
# Track the sun
|
||||
sun:
|
||||
|
||||
# Allow diagnosing system problems
|
||||
system_health:
|
||||
|
||||
# Sensors
|
||||
sensor:
|
||||
# Weather prediction
|
||||
|
|
36
homeassistant/helpers/system_info.py
Normal file
36
homeassistant/helpers/system_info.py
Normal 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
|
1
tests/components/system_health/__init__.py
Normal file
1
tests/components/system_health/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the system health component."""
|
105
tests/components/system_health/test_init.py
Normal file
105
tests/components/system_health/test_init.py
Normal 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'}
|
|
@ -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
|
||||
|
|
12
tests/helpers/test_system_info.py
Normal file
12
tests/helpers/test_system_info.py
Normal 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
|
Loading…
Add table
Reference in a new issue