diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e1fe8048400e1..5b899eee2878f 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -25,7 +25,7 @@ EVENT_PANELS_UPDATED, EVENT_THEMES_UPDATED, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons @@ -41,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "frontend" +CONF_NAME_DARK = "name_dark" CONF_THEMES = "themes" CONF_THEMES_MODES = "modes" CONF_THEMES_LIGHT = "light" @@ -526,6 +527,16 @@ def async_change_listener( return True +def _validate_selected_theme(theme: str) -> str: + """Validate that a user selected theme is a valid theme.""" + if theme in (DEFAULT_THEME, VALUE_NO_THEME): + return theme + hass = async_get_hass() + if theme not in hass.data[DATA_THEMES]: + raise vol.Invalid(f"Theme {theme} not found") + return theme + + async def _async_setup_themes( hass: HomeAssistant, themes: dict[str, Any] | None ) -> None: @@ -569,27 +580,32 @@ def update_theme_and_fire_event() -> None: @callback def set_theme(call: ServiceCall) -> None: """Set backend-preferred theme.""" - 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 + def _update_hass_theme(theme: str, light: bool) -> None: + theme_key = DATA_DEFAULT_THEME if light else DATA_DEFAULT_DARK_THEME + if theme == VALUE_NO_THEME: + to_set = DEFAULT_THEME if light else None + else: + _LOGGER.info( + "Theme %s set as default %s theme", + theme, + "light" if light else "dark", + ) + to_set = theme + hass.data[theme_key] = to_set + + name = call.data.get(CONF_NAME) + if name is not None and CONF_MODE in call.data: + mode = call.data.get("mode", "light") + light_mode = mode == "light" + _update_hass_theme(name, light_mode) else: - _LOGGER.info("Theme %s set as default %s theme", name, mode) - to_set = name + name_dark = call.data.get(CONF_NAME_DARK) + if name: + _update_hass_theme(name, True) + if name_dark: + _update_hass_theme(name_dark, False) - hass.data[theme_key] = to_set store.async_delay_save( lambda: { DATA_DEFAULT_THEME: hass.data[DATA_DEFAULT_THEME], @@ -624,11 +640,13 @@ async def reload_themes(_: ServiceCall) -> None: DOMAIN, SERVICE_SET_THEME, set_theme, - vol.Schema( + vol.All( { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_MODE): vol.Any("dark", "light"), - } + vol.Optional(CONF_NAME): _validate_selected_theme, + vol.Exclusive(CONF_NAME_DARK, "dark_modes"): _validate_selected_theme, + vol.Exclusive(CONF_MODE, "dark_modes"): vol.Any("dark", "light"), + }, + cv.has_at_least_one_key(CONF_NAME, CONF_NAME_DARK), ), ) diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 8e6820fb5bb21..7bd2dd8965870 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -3,17 +3,15 @@ set_theme: fields: name: - required: true + required: false example: "default" selector: theme: include_default: true - mode: - default: "light" + name_dark: + required: false + example: "default" selector: - select: - options: - - "dark" - - "light" - translation_key: mode + theme: + include_default: true reload_themes: diff --git a/homeassistant/components/frontend/strings.json b/homeassistant/components/frontend/strings.json index f483608895cc3..86da141b33303 100644 --- a/homeassistant/components/frontend/strings.json +++ b/homeassistant/components/frontend/strings.json @@ -7,32 +7,24 @@ "name": "Winter mode" } }, - "selector": { - "mode": { - "options": { - "dark": "Dark", - "light": "Light" - } - } - }, "services": { "reload_themes": { "description": "Reloads themes from the YAML-configuration.", "name": "Reload themes" }, "set_theme": { - "description": "Sets the default theme Home Assistant uses. Can be overridden by a user.", + "description": "Sets the theme Home Assistant uses. Can be overridden by a user.", "fields": { - "mode": { - "description": "Theme mode.", - "name": "Mode" - }, "name": { - "description": "Name of a theme.", + "description": "Name of the theme that is used by default.", "name": "Theme" + }, + "name_dark": { + "description": "Alternative dark-mode theme that is used by default.", + "name": "Dark theme override" } }, - "name": "Set the default theme" + "name": "Set theme" } } } diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 008aaf49008c7..ba6a3042279ad 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -11,6 +11,7 @@ from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol from homeassistant.components.frontend import ( CONF_EXTRA_JS_URL_ES5, @@ -279,6 +280,30 @@ async def test_themes_save_storage( } +@pytest.mark.usefixtures("frontend_themes") +async def test_themes_save_storage_new_schema( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test that theme settings are restores after restart.""" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy", "name_dark": "dark"}, blocking=True + ) + + # To trigger the call_later + freezer.tick(60.0) + async_fire_time_changed(hass) + # 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: HomeAssistant, themes_ws_client: MockHAClientWebSocket ) -> None: @@ -318,9 +343,13 @@ async def test_themes_set_theme_wrong_name( ) -> None: """Test frontend.set_theme service called with wrong name.""" - await hass.services.async_call( - DOMAIN, "set_theme", {"name": "wrong"}, blocking=True - ) + with pytest.raises( + vol.error.MultipleInvalid, + match="Theme wrong not found", + ): + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "wrong"}, blocking=True + ) await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) @@ -371,15 +400,59 @@ async def test_themes_set_dark_theme( assert msg["result"]["default_dark_theme"] == "light_and_dark" -@pytest.mark.usefixtures("frontend") -async def test_themes_set_dark_theme_wrong_name( +async def test_themes_set_combined_theme( hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket ) -> None: - """Test frontend.set_theme service called with mode dark and wrong name.""" + """Test frontend.set_theme service setting both light and dark modes.""" + await hass.services.async_call( - DOMAIN, "set_theme", {"name": "wrong", "mode": "dark"}, blocking=True + DOMAIN, "set_theme", {"name_dark": "dark"}, blocking=True ) + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() + + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["default_dark_theme"] == "dark" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() + + assert msg["result"]["default_theme"] == "happy" + assert msg["result"]["default_dark_theme"] == "dark" + + await hass.services.async_call( + DOMAIN, + "set_theme", + {"name": "light_only", "name_dark": "dark_only"}, + blocking=True, + ) + + await themes_ws_client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() + + assert msg["result"]["default_theme"] == "light_only" + assert msg["result"]["default_dark_theme"] == "dark_only" + + +@pytest.mark.usefixtures("frontend") +@pytest.mark.parametrize( + ("schema"), [{"name": "wrong", "mode": "dark"}, {"name_dark": "wrong"}] +) +async def test_themes_set_dark_theme_wrong_name( + hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket, schema +) -> None: + """Test frontend.set_theme service called with mode dark and wrong name.""" + with pytest.raises( + vol.error.MultipleInvalid, + match="Theme wrong not found", + ): + await hass.services.async_call(DOMAIN, "set_theme", schema, blocking=True) + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) msg = await themes_ws_client.receive_json() @@ -397,9 +470,13 @@ async def test_themes_reload_themes( "homeassistant.components.frontend.async_hass_config_yaml", return_value={DOMAIN: {CONF_THEMES: {"sad": {"primary-color": "blue"}}}}, ): - await hass.services.async_call( - DOMAIN, "set_theme", {"name": "happy"}, blocking=True - ) + with pytest.raises( + vol.error.MultipleInvalid, + match="Theme happy not found", + ): + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"})