From f2cc00cc647f0bc429f17742a29a1de0105deb9f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 12 Jan 2018 15:29:58 +0100 Subject: [PATCH] Core support for hass.io calls & Bugfix check_config (#11571) * Initial overwrites * Add check_config function. * Update hassio.py * Address comments * add hassio support * add more tests * revert core changes * Address check_config * Address comment with api_bool * Bugfix check_config * Update core.py * Update test_core.py * Update config.py * Update hassio.py * Update config.py * Update test_config.py --- homeassistant/components/hassio.py | 86 ++++++++++++++++++++++++++--- homeassistant/components/updater.py | 7 +++ homeassistant/config.py | 18 ++++-- tests/components/test_hassio.py | 58 ++++++++++++++++++- tests/components/test_updater.py | 23 +++++++- tests/test_config.py | 4 +- 6 files changed, 179 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 8bd1b11cf0d..cc6db5fbab3 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -17,13 +17,16 @@ from aiohttp.hdrs import CONTENT_TYPE import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback, DOMAIN as HASS_DOMAIN from homeassistant.const import ( - CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, CONF_TIME_ZONE) + CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, CONF_TIME_ZONE, + SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) +from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.components.http import ( HomeAssistantView, KEY_AUTHENTICATED, CONF_API_PASSWORD, CONF_SERVER_PORT, CONF_SERVER_HOST, CONF_SSL_CERTIFICATE) from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) @@ -34,7 +37,7 @@ DEPENDENCIES = ['http'] X_HASSIO = 'X-HASSIO-KEY' DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' -HASSIO_UPDATE_INTERVAL = timedelta(hours=1) +HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) SERVICE_ADDON_START = 'addon_start' SERVICE_ADDON_STOP = 'addon_stop' @@ -120,12 +123,40 @@ MAP_SERVICE_API = { } +@callback @bind_hass def get_homeassistant_version(hass): - """Return last available HomeAssistant version.""" + """Return latest available HomeAssistant version. + + Async friendly. + """ return hass.data.get(DATA_HOMEASSISTANT_VERSION) +@callback +@bind_hass +def is_hassio(hass): + """Return True if hass.io is loaded. + + Async friendly. + """ + return DOMAIN in hass.config.components + + +@bind_hass +@asyncio.coroutine +def async_check_config(hass): + """Check config over Hass.io API.""" + result = yield from hass.data[DOMAIN].send_command( + '/homeassistant/check', timeout=300) + + if not result: + return "Hass.io config check API error" + elif result['result'] == "error": + return result['message'] + return None + + @asyncio.coroutine def async_setup(hass, config): """Set up the HASSio component.""" @@ -136,7 +167,7 @@ def async_setup(hass, config): return False websession = hass.helpers.aiohttp_client.async_get_clientsession() - hassio = HassIO(hass.loop, websession, host) + hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) if not (yield from hassio.is_connected()): _LOGGER.error("Not connected with HassIO!") @@ -170,11 +201,14 @@ def async_setup(hass, config): payload = data # Call API - yield from hassio.send_command( + ret = yield from hassio.send_command( api_command.format(addon=addon, snapshot=snapshot), payload=payload, timeout=MAP_SERVICE_API[service.service][2] ) + if not ret or ret['result'] != "ok": + _LOGGER.error("Error on Hass.io API: %s", ret['message']) + for service, settings in MAP_SERVICE_API.items(): hass.services.async_register( DOMAIN, service, async_service_handler, schema=settings[1]) @@ -193,9 +227,44 @@ def async_setup(hass, config): # Fetch last version yield from update_homeassistant_version(None) + @asyncio.coroutine + def async_handle_core_service(call): + """Service handler for handling core services.""" + if call.service == SERVICE_HOMEASSISTANT_STOP: + yield from hassio.send_command('/homeassistant/stop') + return + + error = yield from async_check_config(hass) + if error: + _LOGGER.error(error) + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", + "Config validating", "{0}.check_config".format(HASS_DOMAIN)) + return + + if call.service == SERVICE_HOMEASSISTANT_RESTART: + yield from hassio.send_command('/homeassistant/restart') + + # Mock core services + for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, + SERVICE_CHECK_CONFIG): + hass.services.async_register( + HASS_DOMAIN, service, async_handle_core_service) + return True +def _api_bool(funct): + """API wrapper to return Boolean.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrapper function.""" + data = yield from funct(*argv, **kwargs) + return data and data['result'] == "ok" + + return _wrapper + + class HassIO(object): """Small API wrapper for HassIO.""" @@ -205,6 +274,7 @@ class HassIO(object): self.websession = websession self._ip = ip + @_api_bool def is_connected(self): """Return True if it connected to HassIO supervisor. @@ -219,6 +289,7 @@ class HassIO(object): """ return self.send_command("/homeassistant/info", method="get") + @_api_bool def update_hass_api(self, http_config): """Update Home-Assistant API data on HassIO. @@ -238,6 +309,7 @@ class HassIO(object): return self.send_command("/homeassistant/options", payload=options) + @_api_bool def update_hass_timezone(self, core_config): """Update Home-Assistant timezone data on HassIO. @@ -261,7 +333,7 @@ class HassIO(object): X_HASSIO: os.environ.get('HASSIO_TOKEN') }) - if request.status != 200: + if request.status not in (200, 400): _LOGGER.error( "%s return code %d.", command, request.status) return None diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index f1f5b7dd1fd..f7bf9774e42 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -97,9 +97,15 @@ def async_setup(hass, config): newest, releasenotes = result + # Skip on dev if newest is None or 'dev' in current_version: return + # Load data from supervisor on hass.io + if hass.components.hassio.is_hassio(): + newest = hass.components.hassio.get_homeassistant_version() + + # Validate version if StrictVersion(newest) > StrictVersion(current_version): _LOGGER.info("The latest available version is %s", newest) hass.states.async_set( @@ -131,6 +137,7 @@ def get_system_info(hass, include_components): 'timezone': dt_util.DEFAULT_TIME_ZONE.zone, 'version': current_version, 'virtualenv': os.environ.get('VIRTUAL_ENV') is not None, + 'hassio': hass.components.hassio.is_hassio(), } if include_components: diff --git a/homeassistant/config.py b/homeassistant/config.py index fee7572a2c2..3f4c4c174d7 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -33,6 +33,8 @@ from homeassistant.helpers import config_per_platform, extract_domain_configs _LOGGER = logging.getLogger(__name__) DATA_PERSISTENT_ERRORS = 'bootstrap_persistent_errors' +RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") +RE_ASCII = re.compile(r"\033\[[^m]*m") HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)' YAML_CONFIG_FILE = 'configuration.yaml' VERSION_FILE = '.HA_VERSION' @@ -655,15 +657,19 @@ def async_check_ha_config_file(hass): proc = yield from asyncio.create_subprocess_exec( sys.executable, '-m', 'homeassistant', '--script', 'check_config', '--config', hass.config.config_dir, - stdout=asyncio.subprocess.PIPE, loop=hass.loop) + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, loop=hass.loop) + # Wait for the subprocess exit - stdout_data, dummy = yield from proc.communicate() - result = yield from proc.wait() + log, _ = yield from proc.communicate() + exit_code = yield from proc.wait() - if not result: - return None + # Convert to ASCII + log = RE_ASCII.sub('', log.decode()) - return re.sub(r'\033\[[^m]*m', '', str(stdout_data, 'utf-8')) + if exit_code != 0 or RE_YAML_ERROR.search(log): + return log + return None @callback diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index b6be6f5a6a1..48443658fc4 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -7,6 +7,7 @@ import pytest from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component +from homeassistant.components.hassio import async_check_config from tests.common import mock_coro @@ -60,6 +61,8 @@ def test_fail_setup_cannot_connect(hass): result = yield from async_setup_component(hass, 'hassio', {}) assert not result + assert not hass.components.hassio.is_hassio() + @asyncio.coroutine def test_setup_api_ping(hass, aioclient_mock): @@ -75,7 +78,8 @@ def test_setup_api_ping(hass, aioclient_mock): assert result assert aioclient_mock.call_count == 2 - assert hass.data['hassio_hass_version'] == "10.0" + assert hass.components.hassio.get_homeassistant_version() == "10.0" + assert hass.components.hassio.is_hassio() @asyncio.coroutine @@ -215,6 +219,7 @@ def test_service_register(hassio_env, hass): assert hass.services.has_service('hassio', 'addon_stdin') assert hass.services.has_service('hassio', 'host_shutdown') assert hass.services.has_service('hassio', 'host_reboot') + assert hass.services.has_service('hassio', 'host_reboot') assert hass.services.has_service('hassio', 'snapshot_full') assert hass.services.has_service('hassio', 'snapshot_partial') assert hass.services.has_service('hassio', 'restore_full') @@ -294,6 +299,57 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False} +@asyncio.coroutine +def test_service_calls_core(hassio_env, hass, aioclient_mock): + """Call core service and check the API calls behind that.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) + + yield from hass.services.async_call('homeassistant', 'stop') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 1 + + yield from hass.services.async_call('homeassistant', 'check_config') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + + yield from hass.services.async_call('homeassistant', 'restart') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 4 + + +@asyncio.coroutine +def test_check_config_ok(hassio_env, hass, aioclient_mock): + """Check Config that is okay.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) + + assert (yield from async_check_config(hass)) is None + + +@asyncio.coroutine +def test_check_config_fail(hassio_env, hass, aioclient_mock): + """Check Config that is wrong.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={ + 'result': 'error', 'message': "Error"}) + + assert (yield from async_check_config(hass)) == "Error" + + @asyncio.coroutine def test_forward_request(hassio_client): """Test fetching normal path.""" diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py index 6d68add93a5..28ffcac2b13 100644 --- a/tests/components/test_updater.py +++ b/tests/components/test_updater.py @@ -8,7 +8,7 @@ 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 +from tests.common import async_fire_time_changed, mock_coro, mock_component NEW_VERSION = '10000.0' MOCK_VERSION = '10.0' @@ -174,3 +174,24 @@ def test_error_fetching_new_version_invalid_response(hass, aioclient_mock): Mock(return_value=mock_coro({'fake': 'bla'}))): res = yield from updater.get_newest_version(hass, MOCK_HUUID, False) assert res is None + + +@asyncio.coroutine +def test_new_version_shows_entity_after_hour_hassio( + hass, mock_get_uuid, mock_get_newest_version): + """Test if new entity is created if new version is available / hass.io.""" + mock_get_uuid.return_value = MOCK_HUUID + mock_get_newest_version.return_value = mock_coro((NEW_VERSION, '')) + mock_component(hass, 'hassio') + hass.data['hassio_hass_version'] = "999.0" + + res = yield from async_setup_component( + hass, updater.DOMAIN, {updater.DOMAIN: {}}) + assert res, 'Updater failed to setup' + + with patch('homeassistant.components.updater.current_version', + MOCK_VERSION): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) + yield from hass.async_block_till_done() + + assert hass.states.is_state(updater.ENTITY_ID, "999.0") diff --git a/tests/test_config.py b/tests/test_config.py index 2c8edc32f82..377c650e91f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -531,7 +531,7 @@ class TestConfig(unittest.TestCase): """Check that restart propagates to stop.""" process_mock = mock.MagicMock() attrs = { - 'communicate.return_value': mock_coro(('output', 'error')), + 'communicate.return_value': mock_coro((b'output', None)), 'wait.return_value': mock_coro(0)} process_mock.configure_mock(**attrs) mock_create.return_value = mock_coro(process_mock) @@ -546,7 +546,7 @@ class TestConfig(unittest.TestCase): process_mock = mock.MagicMock() attrs = { 'communicate.return_value': - mock_coro(('\033[34mhello'.encode('utf-8'), 'error')), + mock_coro(('\033[34mhello'.encode('utf-8'), None)), 'wait.return_value': mock_coro(1)} process_mock.configure_mock(**attrs) mock_create.return_value = mock_coro(process_mock)