From 3e443d253c622ac06b14bce4f4c9d9e7188bd098 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 19 Apr 2019 09:43:47 +0200 Subject: [PATCH] Hass.io Add-on panel support for Ingress (#23185) * Hass.io Add-on panel support for Ingress * Revert part of discovery startup handling * Add type * Fix tests * Add tests * Fix lint * Fix lint on test --- homeassistant/components/hassio/__init__.py | 16 ++- .../components/hassio/addon_panel.py | 93 +++++++++++++ homeassistant/components/hassio/auth.py | 9 +- homeassistant/components/hassio/const.py | 6 + homeassistant/components/hassio/discovery.py | 23 ++-- homeassistant/components/hassio/handler.py | 8 ++ homeassistant/components/hassio/ingress.py | 2 +- homeassistant/components/hassio/manifest.json | 1 + tests/components/hassio/test_addon_panel.py | 128 ++++++++++++++++++ tests/components/hassio/test_handler.py | 20 +++ tests/components/hassio/test_init.py | 31 +++-- 11 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/hassio/addon_panel.py create mode 100644 tests/components/hassio/test_addon_panel.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 2fdb859c320..c8c0f6c9f19 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -16,11 +16,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow -from .auth import async_setup_auth -from .discovery import async_setup_discovery +from .auth import async_setup_auth_view +from .addon_panel import async_setup_addon_panel +from .discovery import async_setup_discovery_view from .handler import HassIO, HassioAPIError from .http import HassIOView -from .ingress import async_setup_ingress +from .ingress import async_setup_ingress_view _LOGGER = logging.getLogger(__name__) @@ -265,12 +266,15 @@ async def async_setup(hass, config): HASS_DOMAIN, service, async_handle_core_service) # Init discovery Hass.io feature - async_setup_discovery(hass, hassio, config) + async_setup_discovery_view(hass, hassio) # Init auth Hass.io feature - async_setup_auth(hass) + async_setup_auth_view(hass) # Init ingress Hass.io feature - async_setup_ingress(hass, host) + async_setup_ingress_view(hass, host) + + # Init add-on ingress panels + await async_setup_addon_panel(hass, hassio) return True diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py new file mode 100644 index 00000000000..d19ca23799a --- /dev/null +++ b/homeassistant/components/hassio/addon_panel.py @@ -0,0 +1,93 @@ +"""Implement the Ingress Panel feature for Hass.io Add-ons.""" +import asyncio +import logging + +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.typing import HomeAssistantType + +from .const import ATTR_PANELS, ATTR_TITLE, ATTR_ICON, ATTR_ADMIN, ATTR_ENABLE +from .handler import HassioAPIError + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_addon_panel(hass: HomeAssistantType, hassio): + """Add-on Ingress Panel setup.""" + hassio_addon_panel = HassIOAddonPanel(hass, hassio) + hass.http.register_view(hassio_addon_panel) + + # If panels are exists + panels = await hassio_addon_panel.get_panels() + if not panels: + return + + # Register available panels + jobs = [] + for addon, data in panels.items(): + if not data[ATTR_ENABLE]: + continue + jobs.append(_register_panel(hass, addon, data)) + + if jobs: + await asyncio.wait(jobs) + + +class HassIOAddonPanel(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio_push:panel" + url = "/api/hassio_push/panel/{addon}" + + def __init__(self, hass, hassio): + """Initialize WebView.""" + self.hass = hass + self.hassio = hassio + + async def post(self, request, addon): + """Handle new add-on panel requests.""" + panels = await self.get_panels() + + # Panel exists for add-on slug + if addon not in panels or not panels[addon][ATTR_ENABLE]: + _LOGGER.error("Panel is not enable for %s", addon) + return web.Response(status=400) + data = panels[addon] + + # Register panel + await _register_panel(self.hass, addon, data) + return web.Response() + + async def delete(self, request, addon): + """Handle remove add-on panel requests.""" + # Currently not supported by backend / frontend + return web.Response() + + async def get_panels(self): + """Return panels add-on info data.""" + try: + data = await self.hassio.get_ingress_panels() + return data[ATTR_PANELS] + except HassioAPIError as err: + _LOGGER.error("Can't read panel info: %s", err) + return {} + + +def _register_panel(hass, addon, data): + """Init coroutine to register the panel. + + Return coroutine. + """ + return hass.components.frontend.async_register_built_in_panel( + frontend_url_path=addon, + webcomponent_name='hassio-main', + sidebar_title=data[ATTR_TITLE], + sidebar_icon=data[ATTR_ICON], + js_url='/api/hassio/app/entrypoint.js', + embed_iframe=True, + require_admin=data[ATTR_ADMIN], + config={ + "ingress": addon + } + ) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 05c183ccd60..85ae6473562 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -1,18 +1,19 @@ """Implement the auth feature from Hass.io for Add-ons.""" -from ipaddress import ip_address import logging import os +from ipaddress import ip_address +import voluptuous as vol from aiohttp import web from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound -import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.const import KEY_REAL_IP from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME @@ -27,7 +28,7 @@ SCHEMA_API_AUTH = vol.Schema({ @callback -def async_setup_auth(hass): +def async_setup_auth_view(hass: HomeAssistantType): """Auth setup.""" hassio_auth = HassIOAuth(hass) hass.http.register_view(hassio_auth) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index e4132562c31..9656346cd2c 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,5 +1,6 @@ """Hass.io const variables.""" +ATTR_ADDONS = 'addons' ATTR_DISCOVERY = 'discovery' ATTR_ADDON = 'addon' ATTR_NAME = 'name' @@ -8,6 +9,11 @@ ATTR_CONFIG = 'config' ATTR_UUID = 'uuid' ATTR_USERNAME = 'username' ATTR_PASSWORD = 'password' +ATTR_PANELS = 'panels' +ATTR_ENABLE = 'enable' +ATTR_TITLE = 'title' +ATTR_ICON = 'icon' +ATTR_ADMIN = 'admin' X_HASSIO = 'X-Hassio-Key' X_INGRESS_PATH = "X-Ingress-Path" diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 09a98edc148..90953d634c3 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -5,9 +5,9 @@ import logging from aiohttp import web from aiohttp.web_exceptions import HTTPServiceUnavailable -from homeassistant.components.http import HomeAssistantView from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CoreState, callback +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView from .const import ( ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_NAME, ATTR_SERVICE, @@ -18,12 +18,13 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_discovery(hass, hassio, config): +def async_setup_discovery_view(hass: HomeAssistantView, hassio): """Discovery setup.""" - hassio_discovery = HassIODiscovery(hass, hassio, config) + hassio_discovery = HassIODiscovery(hass, hassio) + hass.http.register_view(hassio_discovery) # Handle exists discovery messages - async def async_discovery_start_handler(event): + async def _async_discovery_start_handler(event): """Process all exists discovery on startup.""" try: data = await hassio.retrieve_discovery_messages() @@ -36,13 +37,8 @@ def async_setup_discovery(hass, hassio, config): if jobs: await asyncio.wait(jobs) - if hass.state == CoreState.running: - hass.async_create_task(async_discovery_start_handler(None)) - else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, async_discovery_start_handler) - - hass.http.register_view(hassio_discovery) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, _async_discovery_start_handler) class HassIODiscovery(HomeAssistantView): @@ -51,11 +47,10 @@ class HassIODiscovery(HomeAssistantView): name = "api:hassio_push:discovery" url = "/api/hassio_push/discovery/{uuid}" - def __init__(self, hass, hassio, config): + def __init__(self, hass: HomeAssistantView, hassio): """Initialize WebView.""" self.hass = hass self.hassio = hassio - self.config = config async def post(self, request, uuid): """Handle new discovery requests.""" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 7eddc639690..aae1f31d486 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -81,6 +81,14 @@ class HassIO: return self.send_command( "/addons/{}/info".format(addon), method="get") + @_api_data + def get_ingress_panels(self): + """Return data for Add-on ingress panels. + + This method return a coroutine. + """ + return self.send_command("/ingress/panels", method="get") + @_api_bool def restart_homeassistant(self): """Restart Home-Assistant container. diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 0ba83f1ca1b..824dee86fad 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_setup_ingress(hass: HomeAssistantType, host: str): +def async_setup_ingress_view(hass: HomeAssistantType, host: str): """Auth setup.""" websession = hass.helpers.aiohttp_client.async_get_clientsession() diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 23095064d55..24782e45799 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -5,6 +5,7 @@ "requirements": [], "dependencies": [ "http", + "frontend", "panel_custom" ], "codeowners": [ diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py new file mode 100644 index 00000000000..05915218659 --- /dev/null +++ b/tests/components/hassio/test_addon_panel.py @@ -0,0 +1,128 @@ +"""Test add-on panel.""" +from unittest.mock import patch, Mock + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.const import HTTP_HEADER_HA_AUTH + +from tests.common import mock_coro +from . import API_PASSWORD + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock): + """Mock all setup requests.""" + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + + +async def test_hassio_addon_panel_startup(hass, aioclient_mock, hassio_env): + """Test startup and panel setup after event.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={ + 'result': 'ok', 'data': {'panels': { + "test1": { + "enable": True, + "title": "Test", + "icon": "mdi:test", + "admin": False + }, + "test2": { + "enable": False, + "title": "Test 2", + "icon": "mdi:test2", + "admin": True + }, + }}}) + + assert aioclient_mock.call_count == 0 + + with patch( + 'homeassistant.components.hassio.addon_panel._register_panel', + Mock(return_value=mock_coro()) + ) as mock_panel: + await async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + }) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + assert mock_panel.called + mock_panel.assert_called_with( + hass, 'test1', { + 'enable': True, 'title': 'Test', + 'icon': 'mdi:test', 'admin': False + }) + + +async def test_hassio_addon_panel_api(hass, aioclient_mock, hassio_env, + hass_client): + """Test panel api after event.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={ + 'result': 'ok', 'data': {'panels': { + "test1": { + "enable": True, + "title": "Test", + "icon": "mdi:test", + "admin": False + }, + "test2": { + "enable": False, + "title": "Test 2", + "icon": "mdi:test2", + "admin": True + }, + }}}) + + assert aioclient_mock.call_count == 0 + + with patch( + 'homeassistant.components.hassio.addon_panel._register_panel', + Mock(return_value=mock_coro()) + ) as mock_panel: + await async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + }) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + assert mock_panel.called + mock_panel.assert_called_with( + hass, 'test1', { + 'enable': True, 'title': 'Test', + 'icon': 'mdi:test', 'admin': False + }) + + hass_client = await hass_client() + + resp = await hass_client.post( + '/api/hassio_push/panel/test2', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + assert resp.status == 400 + + resp = await hass_client.post( + '/api/hassio_push/panel/test1', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + assert resp.status == 200 + assert mock_panel.call_count == 2 + + mock_panel.assert_called_with( + hass, 'test1', { + 'enable': True, 'title': 'Test', + 'icon': 'mdi:test', 'admin': False + }) diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 3e7b9e95d92..372d567c021 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -105,3 +105,23 @@ async def test_api_retrieve_discovery(hassio_handler, aioclient_mock): data = await hassio_handler.retrieve_discovery_messages() assert data['discovery'][-1]['service'] == "mqtt" assert aioclient_mock.call_count == 1 + + +async def test_api_ingress_panels(hassio_handler, aioclient_mock): + """Test setup with API Ingress panels.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={'result': 'ok', 'data': { + "panels": { + "slug": { + "enable": True, + "title": "Test", + "icon": "mdi:test", + "admin": False + } + } + }}) + + data = await hassio_handler.get_ingress_panels() + assert aioclient_mock.call_count == 1 + assert data['panels'] + assert "slug" in data['panels'] diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f1f148f8495..7b8fad3ec09 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -31,6 +31,9 @@ def mock_all(aioclient_mock): aioclient_mock.get( "http://127.0.0.1/homeassistant/info", json={ 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={ + 'result': 'ok', 'data': {'panels': {}}}) @asyncio.coroutine @@ -40,7 +43,7 @@ def test_setup_api_ping(hass, aioclient_mock): result = yield from async_setup_component(hass, 'hassio', {}) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert hass.components.hassio.get_homeassistant_version() == "10.0" assert hass.components.hassio.is_hassio() @@ -79,7 +82,7 @@ def test_setup_api_push_api_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert aioclient_mock.mock_calls[1][2]['watchdog'] @@ -98,7 +101,7 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert not aioclient_mock.mock_calls[1][2]['watchdog'] @@ -114,7 +117,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]['refresh_token'] @@ -174,7 +177,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['port'] == 8123 assert aioclient_mock.mock_calls[1][2]['refresh_token'] == token.token @@ -192,7 +195,7 @@ def test_setup_core_push_timezone(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert aioclient_mock.mock_calls[2][2]['timezone'] == "testzone" @@ -206,7 +209,7 @@ def test_setup_hassio_no_additional_data(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4 assert aioclient_mock.mock_calls[-1][3]['X-Hassio-Key'] == "123456" @@ -285,14 +288,14 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 5 + assert aioclient_mock.call_count == 6 assert aioclient_mock.mock_calls[-1][2] == 'test' yield from hass.services.async_call('hassio', 'host_shutdown', {}) yield from hass.services.async_call('hassio', 'host_reboot', {}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 7 + assert aioclient_mock.call_count == 8 yield from hass.services.async_call('hassio', 'snapshot_full', {}) yield from hass.services.async_call('hassio', 'snapshot_partial', { @@ -302,7 +305,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 9 + assert aioclient_mock.call_count == 10 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} @@ -318,7 +321,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, 'password': "123456" @@ -338,12 +341,12 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('homeassistant', 'stop') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 yield from hass.services.async_call('homeassistant', 'check_config') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 with patch( 'homeassistant.config.async_check_ha_config_file', @@ -353,4 +356,4 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.async_block_till_done() assert mock_check_config.called - assert aioclient_mock.call_count == 3 + assert aioclient_mock.call_count == 4