Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions homeassistant/components/frontend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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),
),
)

Expand Down
14 changes: 6 additions & 8 deletions homeassistant/components/frontend/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
22 changes: 7 additions & 15 deletions homeassistant/components/frontend/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
97 changes: 87 additions & 10 deletions tests/components/frontend/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"})

Expand Down Expand Up @@ -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()
Expand All @@ -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"})
Expand Down
Loading