From d66ddeb69eac2c6f3f67be126559453ec89ef403 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Aug 2020 17:42:23 +0200 Subject: [PATCH] Allow to set default dark theme and persist frontend default themes (#38548) Co-authored-by: Paulus Schoutsen --- homeassistant/components/frontend/__init__.py | 78 +++++++++-- .../components/frontend/services.yaml | 5 +- tests/components/frontend/test_init.py | 132 +++++++++++++++++- 3 files changed, 199 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f950ca3441d..90089d50340 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -74,9 +74,16 @@ DATA_EXTRA_HTML_URL = "frontend_extra_html_url" DATA_EXTRA_HTML_URL_ES5 = "frontend_extra_html_url_es5" DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" + +THEMES_STORAGE_KEY = f"{DOMAIN}_theme" +THEMES_STORAGE_VERSION = 1 +THEMES_SAVE_DELAY = 60 +DATA_THEMES_STORE = "frontend_themes_store" DATA_THEMES = "frontend_themes" DATA_DEFAULT_THEME = "frontend_default_theme" +DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" +VALUE_NO_THEME = "none" PRIMARY_COLOR = "primary-color" @@ -114,6 +121,7 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" +CONF_MODE = "mode" class Panel: @@ -321,17 +329,31 @@ async def async_setup(hass, config): for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): add_extra_js_url(hass, url, True) - _async_setup_themes(hass, conf.get(CONF_THEMES)) + await _async_setup_themes(hass, conf.get(CONF_THEMES)) return True -@callback -def _async_setup_themes(hass, themes): +async def _async_setup_themes(hass, themes): """Set up themes data and services.""" - hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME hass.data[DATA_THEMES] = themes or {} + store = hass.data[DATA_THEMES_STORE] = hass.helpers.storage.Store( + THEMES_STORAGE_VERSION, THEMES_STORAGE_KEY + ) + + theme_data = await store.async_load() or {} + theme_name = theme_data.get(DATA_DEFAULT_THEME, DEFAULT_THEME) + dark_theme_name = theme_data.get(DATA_DEFAULT_DARK_THEME) + + if theme_name == DEFAULT_THEME or theme_name in hass.data[DATA_THEMES]: + hass.data[DATA_DEFAULT_THEME] = theme_name + else: + hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + + if dark_theme_name == DEFAULT_THEME or dark_theme_name in hass.data[DATA_THEMES]: + hass.data[DATA_DEFAULT_DARK_THEME] = dark_theme_name + @callback def update_theme_and_fire_event(): """Update theme_color in manifest.""" @@ -348,14 +370,35 @@ def _async_setup_themes(hass, themes): @callback def set_theme(call): """Set backend-preferred 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() + name = call.data[CONF_NAME] + mode = call.data.get("mode", "light") + + if ( + name not in (DEFAULT_THEME, VALUE_NO_THEME) + and name not in hass.data[DATA_THEMES] + ): + _LOGGER.warning("Theme %s not found", name) + return + + light_mode = mode == "light" + + theme_key = DATA_DEFAULT_THEME if light_mode else DATA_DEFAULT_DARK_THEME + + if name == VALUE_NO_THEME: + to_set = DEFAULT_THEME if light_mode else None else: - _LOGGER.warning("Theme %s is not defined", name) + _LOGGER.info("Theme %s set as default %s theme", name, mode) + to_set = name + + hass.data[theme_key] = to_set + store.async_delay_save( + lambda: { + DATA_DEFAULT_THEME: hass.data[DATA_DEFAULT_THEME], + DATA_DEFAULT_DARK_THEME: hass.data.get(DATA_DEFAULT_DARK_THEME), + }, + THEMES_SAVE_DELAY, + ) + update_theme_and_fire_event() async def reload_themes(_): """Reload themes.""" @@ -364,6 +407,11 @@ def _async_setup_themes(hass, themes): hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + if ( + hass.data.get(DATA_DEFAULT_DARK_THEME) + and hass.data.get(DATA_DEFAULT_DARK_THEME) not in new_themes + ): + hass.data[DATA_DEFAULT_DARK_THEME] = None update_theme_and_fire_event() service.async_register_admin_service( @@ -371,7 +419,12 @@ def _async_setup_themes(hass, themes): DOMAIN, SERVICE_SET_THEME, set_theme, - vol.Schema({vol.Required(CONF_NAME): cv.string}), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_MODE): vol.Any("dark", "light"), + } + ), ) service.async_register_admin_service( @@ -536,6 +589,7 @@ def websocket_get_themes(hass, connection, msg): { "themes": hass.data[DATA_THEMES], "default_theme": hass.data[DATA_DEFAULT_THEME], + "default_dark_theme": hass.data.get(DATA_DEFAULT_DARK_THEME), }, ) ) diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 489164ce7bd..31eb4d5d1ca 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -4,8 +4,11 @@ set_theme: description: Set a theme unless the client selected per-device theme. fields: name: - description: Name of a predefined theme or 'default'. + description: Name of a predefined theme, 'default' or 'none'. example: "light" + mode: + description: The mode the theme is for, either 'dark' or 'light' (default). + example: "dark" reload_themes: description: Reload themes from yaml configuration. diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 10f55bd4db3..5e6bbe8b2d4 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -1,4 +1,5 @@ """The tests for Home Assistant frontend.""" +from datetime import timedelta import re import pytest @@ -10,16 +11,25 @@ from homeassistant.components.frontend import ( CONF_THEMES, DOMAIN, EVENT_PANELS_UPDATED, + THEMES_STORAGE_KEY, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import HTTP_NOT_FOUND from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util import dt from tests.async_mock import patch -from tests.common import async_capture_events +from tests.common import async_capture_events, async_fire_time_changed -CONFIG_THEMES = {DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "red"}}}} +CONFIG_THEMES = { + DOMAIN: { + CONF_THEMES: { + "happy": {"primary-color": "red"}, + "dark": {"primary-color": "black"}, + } + } +} @pytest.fixture @@ -117,7 +127,11 @@ async def test_themes_api(hass, hass_ws_client): msg = await client.receive_json() assert msg["result"]["default_theme"] == "default" - assert msg["result"]["themes"] == {"happy": {"primary-color": "red"}} + assert msg["result"]["default_dark_theme"] is None + assert msg["result"]["themes"] == { + "happy": {"primary-color": "red"}, + "dark": {"primary-color": "black"}, + } # safe mode hass.config.safe_mode = True @@ -130,6 +144,58 @@ async def test_themes_api(hass, hass_ws_client): } +async def test_themes_persist(hass, hass_ws_client, hass_storage): + """Test that theme settings are restores after restart.""" + + hass_storage[THEMES_STORAGE_KEY] = { + "key": THEMES_STORAGE_KEY, + "version": 1, + "data": { + "frontend_default_theme": "happy", + "frontend_default_dark_theme": "dark", + }, + } + + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "happy" + assert msg["result"]["default_dark_theme"] == "dark" + + +async def test_themes_save_storage(hass, hass_storage): + """Test that theme settings are restores after restart.""" + + hass_storage[THEMES_STORAGE_KEY] = { + "key": THEMES_STORAGE_KEY, + "version": 1, + "data": {}, + } + + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True + ) + + # To trigger the call_later + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=60)) + # To execute the save + await hass.async_block_till_done() + + assert hass_storage[THEMES_STORAGE_KEY]["data"] == { + "frontend_default_theme": "happy", + "frontend_default_dark_theme": "dark", + } + + async def test_themes_set_theme(hass, hass_ws_client): """Test frontend.set_theme service.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES) @@ -153,6 +219,17 @@ async def test_themes_set_theme(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await hass.services.async_call(DOMAIN, "set_theme", {"name": "none"}, blocking=True) + + await client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "default" + async def test_themes_set_theme_wrong_name(hass, hass_ws_client): """Test frontend.set_theme service called with wrong name.""" @@ -170,6 +247,55 @@ async def test_themes_set_theme_wrong_name(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" +async def test_themes_set_dark_theme(hass, hass_ws_client): + """Test frontend.set_theme service called with dark mode.""" + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] == "dark" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "default", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] == "default" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "none", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] is None + + +async def test_themes_set_dark_theme_wrong_name(hass, hass_ws_client): + """Test frontend.set_theme service called with mode dark and wrong name.""" + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "wrong", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] is None + + async def test_themes_reload_themes(hass, hass_ws_client): """Test frontend.reload_themes service.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES)