From a65f22378e8953837fd7ca5cce37802bebd0b4a3 Mon Sep 17 00:00:00 2001
From: Andrey <andrey-git@users.noreply.github.com>
Date: Thu, 13 Jul 2017 04:08:13 +0300
Subject: [PATCH] Backend support for themes (#8419)

* Backend support for themes

* Fix test

* Add theme_updated event

* Shorten name

* Add tests
---
 homeassistant/components/frontend/__init__.py | 101 +++++++++++++++++-
 .../components/frontend/services.yaml         |  11 ++
 homeassistant/const.py                        |   1 +
 tests/components/test_frontend.py             |  72 ++++++++++++-
 4 files changed, 183 insertions(+), 2 deletions(-)
 create mode 100644 homeassistant/components/frontend/services.yaml

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'