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
This commit is contained in:
Pascal Vizeli 2019-04-19 09:43:47 +02:00 committed by GitHub
parent 6a7bd19a5a
commit 3e443d253c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 298 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@
"requirements": [],
"dependencies": [
"http",
"frontend",
"panel_custom"
],
"codeowners": [

View File

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

View File

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

View File

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