Backend support for themes (#8419)

* Backend support for themes

* Fix test

* Add theme_updated event

* Shorten name

* Add tests
This commit is contained in:
Andrey 2017-07-13 04:08:13 +03:00 committed by Paulus Schoutsen
parent bb9db28c95
commit a65f22378e
4 changed files with 183 additions and 2 deletions

View File

@ -6,7 +6,11 @@ import logging
import os import os
from aiohttp import web from aiohttp import web
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.config import find_config_file, load_yaml_config_file
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.components import api from homeassistant.components import api
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
@ -22,6 +26,8 @@ URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html'
STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/')
ATTR_THEMES = 'themes'
DEFAULT_THEME_COLOR = '#03A9F4'
MANIFEST_JSON = { MANIFEST_JSON = {
'background_color': '#FFFFFF', 'background_color': '#FFFFFF',
'description': 'Open-source home automation platform running on Python 3.', 'description': 'Open-source home automation platform running on Python 3.',
@ -32,7 +38,7 @@ MANIFEST_JSON = {
'name': 'Home Assistant', 'name': 'Home Assistant',
'short_name': 'Assistant', 'short_name': 'Assistant',
'start_url': '/', 'start_url': '/',
'theme_color': '#03A9F4' 'theme_color': DEFAULT_THEME_COLOR
} }
for size in (192, 384, 512, 1024): for size in (192, 384, 512, 1024):
@ -44,11 +50,30 @@ for size in (192, 384, 512, 1024):
DATA_PANELS = 'frontend_panels' DATA_PANELS = 'frontend_panels'
DATA_INDEX_VIEW = 'frontend_index_view' DATA_INDEX_VIEW = 'frontend_index_view'
DATA_THEMES = 'frontend_themes'
DATA_DEFAULT_THEME = 'frontend_default_theme'
DEFAULT_THEME = 'default'
PRIMARY_COLOR = 'primary-color'
# To keep track we don't register a component twice (gives a warning) # To keep track we don't register a component twice (gives a warning)
_REGISTERED_COMPONENTS = set() _REGISTERED_COMPONENTS = set()
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(ATTR_THEMES): vol.Schema({
cv.string: {cv.string: cv.string}
}),
}),
}, extra=vol.ALLOW_EXTRA)
SERVICE_SET_THEME = 'set_theme'
SERVICE_RELOAD_THEMES = 'reload_themes'
SERVICE_SET_THEME_SCHEMA = vol.Schema({
vol.Required(CONF_NAME): cv.string,
})
def register_built_in_panel(hass, component_name, sidebar_title=None, def register_built_in_panel(hass, component_name, sidebar_title=None,
sidebar_icon=None, url_path=None, config=None): sidebar_icon=None, url_path=None, config=None):
@ -186,9 +211,65 @@ def setup(hass, config):
'dev-template'): 'dev-template'):
register_built_in_panel(hass, panel) register_built_in_panel(hass, panel)
themes = config.get(DOMAIN, {}).get(ATTR_THEMES)
if themes:
setup_themes(hass, themes)
return True return True
def setup_themes(hass, themes):
"""Set up themes data and services."""
hass.data[DATA_THEMES] = themes
hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
hass.http.register_view(ThemesView)
@callback
def update_theme_and_fire_event():
"""Update theme_color in manifest."""
name = hass.data[DATA_DEFAULT_THEME]
themes = hass.data[DATA_THEMES]
if name != DEFAULT_THEME and PRIMARY_COLOR in themes[name]:
MANIFEST_JSON['theme_color'] = themes[name][PRIMARY_COLOR]
else:
MANIFEST_JSON['theme_color'] = DEFAULT_THEME_COLOR
hass.bus.async_fire(EVENT_THEMES_UPDATED, {
'themes': themes,
'default_theme': name,
})
@callback
def set_theme(call):
"""Set backend-prefered theme."""
data = call.data
name = data[CONF_NAME]
if name == DEFAULT_THEME or name in hass.data[DATA_THEMES]:
_LOGGER.info("Theme %s set as default", name)
hass.data[DATA_DEFAULT_THEME] = name
update_theme_and_fire_event()
else:
_LOGGER.warning("Theme %s is not defined.", name)
@callback
def reload_themes(_):
"""Reload themes."""
path = find_config_file(hass.config.config_dir)
new_themes = load_yaml_config_file(path)[DOMAIN].get(ATTR_THEMES, {})
hass.data[DATA_THEMES] = new_themes
if hass.data[DATA_DEFAULT_THEME] not in new_themes:
hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
update_theme_and_fire_event()
descriptions = load_yaml_config_file(
os.path.join(os.path.dirname(__file__), 'services.yaml'))
hass.services.register(DOMAIN, SERVICE_SET_THEME,
set_theme,
descriptions[SERVICE_SET_THEME],
SERVICE_SET_THEME_SCHEMA)
hass.services.register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes,
descriptions[SERVICE_RELOAD_THEMES])
class BootstrapView(HomeAssistantView): class BootstrapView(HomeAssistantView):
"""View to bootstrap frontend with all needed data.""" """View to bootstrap frontend with all needed data."""
@ -291,3 +372,21 @@ class ManifestJSONView(HomeAssistantView):
"""Return the manifest.json.""" """Return the manifest.json."""
msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8')
return web.Response(body=msg, content_type="application/manifest+json") return web.Response(body=msg, content_type="application/manifest+json")
class ThemesView(HomeAssistantView):
"""View to return defined themes."""
requires_auth = False
url = '/api/themes'
name = 'api:themes'
@callback
def get(self, request):
"""Return themes."""
hass = request.app['hass']
return self.json({
'themes': hass.data[DATA_THEMES],
'default_theme': hass.data[DATA_DEFAULT_THEME],
})

View File

@ -0,0 +1,11 @@
# Describes the format for available frontend services
set_theme:
description: Set a theme unless the client selected per-device theme.
fields:
name:
description: Name of a predefined theme or 'default'.
example: 'light'
reload_themes:
description: Reload themes from yaml config.

View File

@ -179,6 +179,7 @@ EVENT_COMPONENT_LOADED = 'component_loaded'
EVENT_SERVICE_REGISTERED = 'service_registered' EVENT_SERVICE_REGISTERED = 'service_registered'
EVENT_SERVICE_REMOVED = 'service_removed' EVENT_SERVICE_REMOVED = 'service_removed'
EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_LOGBOOK_ENTRY = 'logbook_entry'
EVENT_THEMES_UPDATED = 'themes_updated'
# #### STATES #### # #### STATES ####
STATE_ON = 'on' STATE_ON = 'on'

View File

@ -1,10 +1,12 @@
"""The tests for Home Assistant frontend.""" """The tests for Home Assistant frontend."""
import asyncio import asyncio
import re import re
from unittest.mock import patch
import pytest import pytest
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components.frontend import DOMAIN, ATTR_THEMES
@pytest.fixture @pytest.fixture
@ -14,6 +16,20 @@ def mock_http_client(hass, test_client):
return hass.loop.run_until_complete(test_client(hass.http.app)) return hass.loop.run_until_complete(test_client(hass.http.app))
@pytest.fixture
def mock_http_client_with_themes(hass, test_client):
"""Start the Hass HTTP component."""
hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {
DOMAIN: {
ATTR_THEMES: {
'happy': {
'primary-color': 'red'
}
}
}}))
return hass.loop.run_until_complete(test_client(hass.http.app))
@asyncio.coroutine @asyncio.coroutine
def test_frontend_and_static(mock_http_client): def test_frontend_and_static(mock_http_client):
"""Test if we can get the frontend.""" """Test if we can get the frontend."""
@ -56,10 +72,64 @@ def test_we_cannot_POST_to_root(mock_http_client):
@asyncio.coroutine @asyncio.coroutine
def test_states_routes(hass, mock_http_client): def test_states_routes(mock_http_client):
"""All served by index.""" """All served by index."""
resp = yield from mock_http_client.get('/states') resp = yield from mock_http_client.get('/states')
assert resp.status == 200 assert resp.status == 200
resp = yield from mock_http_client.get('/states/group.existing') resp = yield from mock_http_client.get('/states/group.existing')
assert resp.status == 200 assert resp.status == 200
@asyncio.coroutine
def test_themes_api(mock_http_client_with_themes):
"""Test that /api/themes returns correct data."""
resp = yield from mock_http_client_with_themes.get('/api/themes')
json = yield from resp.json()
assert json['default_theme'] == 'default'
assert json['themes'] == {'happy': {'primary-color': 'red'}}
@asyncio.coroutine
def test_themes_set_theme(hass, mock_http_client_with_themes):
"""Test frontend.set_theme service."""
yield from hass.services.async_call(DOMAIN, 'set_theme', {'name': 'happy'})
yield from hass.async_block_till_done()
resp = yield from mock_http_client_with_themes.get('/api/themes')
json = yield from resp.json()
assert json['default_theme'] == 'happy'
yield from hass.services.async_call(
DOMAIN, 'set_theme', {'name': 'default'})
yield from hass.async_block_till_done()
resp = yield from mock_http_client_with_themes.get('/api/themes')
json = yield from resp.json()
assert json['default_theme'] == 'default'
@asyncio.coroutine
def test_themes_set_theme_wrong_name(hass, mock_http_client_with_themes):
"""Test frontend.set_theme service called with wrong name."""
yield from hass.services.async_call(DOMAIN, 'set_theme', {'name': 'wrong'})
yield from hass.async_block_till_done()
resp = yield from mock_http_client_with_themes.get('/api/themes')
json = yield from resp.json()
assert json['default_theme'] == 'default'
@asyncio.coroutine
def test_themes_reload_themes(hass, mock_http_client_with_themes):
"""Test frontend.reload_themes service."""
with patch('homeassistant.components.frontend.load_yaml_config_file',
return_value={DOMAIN: {
ATTR_THEMES: {
'sad': {'primary-color': 'blue'}
}}}):
yield from hass.services.async_call(DOMAIN, 'set_theme',
{'name': 'happy'})
yield from hass.services.async_call(DOMAIN, 'reload_themes')
yield from hass.async_block_till_done()
resp = yield from mock_http_client_with_themes.get('/api/themes')
json = yield from resp.json()
assert json['themes'] == {'sad': {'primary-color': 'blue'}}
assert json['default_theme'] == 'default'