diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7fd65d44227..4dd0ad329fc 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -6,7 +6,11 @@ import logging import os 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.components import api 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/') +ATTR_THEMES = 'themes' +DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', 'description': 'Open-source home automation platform running on Python 3.', @@ -32,7 +38,7 @@ MANIFEST_JSON = { 'name': 'Home Assistant', 'short_name': 'Assistant', 'start_url': '/', - 'theme_color': '#03A9F4' + 'theme_color': DEFAULT_THEME_COLOR } for size in (192, 384, 512, 1024): @@ -44,11 +50,30 @@ for size in (192, 384, 512, 1024): DATA_PANELS = 'frontend_panels' 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) _REGISTERED_COMPONENTS = set() _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, sidebar_icon=None, url_path=None, config=None): @@ -186,9 +211,65 @@ def setup(hass, config): 'dev-template'): register_built_in_panel(hass, panel) + themes = config.get(DOMAIN, {}).get(ATTR_THEMES) + if themes: + setup_themes(hass, themes) + 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): """View to bootstrap frontend with all needed data.""" @@ -291,3 +372,21 @@ class ManifestJSONView(HomeAssistantView): """Return the manifest.json.""" msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8') 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], + }) diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml new file mode 100644 index 00000000000..7d56cbb7693 --- /dev/null +++ b/homeassistant/components/frontend/services.yaml @@ -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. diff --git a/homeassistant/const.py b/homeassistant/const.py index 510f7daf12e..f7df04be7f1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -179,6 +179,7 @@ EVENT_COMPONENT_LOADED = 'component_loaded' EVENT_SERVICE_REGISTERED = 'service_registered' EVENT_SERVICE_REMOVED = 'service_removed' EVENT_LOGBOOK_ENTRY = 'logbook_entry' +EVENT_THEMES_UPDATED = 'themes_updated' # #### STATES #### STATE_ON = 'on' diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 3d9798800d7..d1e2e5d4346 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -1,10 +1,12 @@ """The tests for Home Assistant frontend.""" import asyncio import re +from unittest.mock import patch import pytest from homeassistant.setup import async_setup_component +from homeassistant.components.frontend import DOMAIN, ATTR_THEMES @pytest.fixture @@ -14,6 +16,20 @@ def mock_http_client(hass, test_client): 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 def test_frontend_and_static(mock_http_client): """Test if we can get the frontend.""" @@ -56,10 +72,64 @@ def test_we_cannot_POST_to_root(mock_http_client): @asyncio.coroutine -def test_states_routes(hass, mock_http_client): +def test_states_routes(mock_http_client): """All served by index.""" resp = yield from mock_http_client.get('/states') assert resp.status == 200 resp = yield from mock_http_client.get('/states/group.existing') 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'