diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 17116a011a4..ef2b3075b34 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -7,14 +7,16 @@ import logging import os from pathlib import Path import time +from typing import Any import voluptuous as vol from homeassistant.components.frontend import DATA_PANELS from homeassistant.const import CONF_FILENAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage +from homeassistant.helpers.json import json_bytes, json_fragment from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( @@ -42,11 +44,13 @@ _LOGGER = logging.getLogger(__name__) class LovelaceConfig(ABC): """Base class for Lovelace config.""" - def __init__(self, hass, url_path, config): + def __init__( + self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None + ) -> None: """Initialize Lovelace config.""" self.hass = hass if config: - self.config = {**config, CONF_URL_PATH: url_path} + self.config: dict[str, Any] | None = {**config, CONF_URL_PATH: url_path} else: self.config = None @@ -65,7 +69,7 @@ class LovelaceConfig(ABC): """Return the config info.""" @abstractmethod - async def async_load(self, force): + async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" async def async_save(self, config): @@ -77,7 +81,7 @@ class LovelaceConfig(ABC): raise HomeAssistantError("Not supported") @callback - def _config_updated(self): + def _config_updated(self) -> None: """Fire config updated event.""" self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path}) @@ -85,10 +89,10 @@ class LovelaceConfig(ABC): class LovelaceStorage(LovelaceConfig): """Class to handle Storage based Lovelace config.""" - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: dict[str, Any] | None) -> None: """Initialize Lovelace config based on storage helper.""" if config is None: - url_path = None + url_path: str | None = None storage_key = CONFIG_STORAGE_KEY_DEFAULT else: url_path = config[CONF_URL_PATH] @@ -96,8 +100,11 @@ class LovelaceStorage(LovelaceConfig): super().__init__(hass, url_path, config) - self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) - self._data = None + self._store = storage.Store[dict[str, Any]]( + hass, CONFIG_STORAGE_VERSION, storage_key + ) + self._data: dict[str, Any] | None = None + self._json_config: json_fragment | None = None @property def mode(self) -> str: @@ -106,27 +113,30 @@ class LovelaceStorage(LovelaceConfig): async def async_get_info(self): """Return the Lovelace storage info.""" - if self._data is None: - await self._load() - - if self._data["config"] is None: + data = self._data or await self._load() + if data["config"] is None: return {"mode": "auto-gen"} + return _config_info(self.mode, data["config"]) - return _config_info(self.mode, self._data["config"]) - - async def async_load(self, force): + async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" if self.hass.config.recovery_mode: raise ConfigNotFound - if self._data is None: - await self._load() - - if (config := self._data["config"]) is None: + data = self._data or await self._load() + if (config := data["config"]) is None: raise ConfigNotFound return config + async def async_json(self, force: bool) -> json_fragment: + """Return JSON representation of the config.""" + if self.hass.config.recovery_mode: + raise ConfigNotFound + if self._data is None: + await self._load() + return self._json_config or self._async_build_json() + async def async_save(self, config): """Save config.""" if self.hass.config.recovery_mode: @@ -135,6 +145,7 @@ class LovelaceStorage(LovelaceConfig): if self._data is None: await self._load() self._data["config"] = config + self._json_config = None self._config_updated() await self._store.async_save(self._data) @@ -145,25 +156,37 @@ class LovelaceStorage(LovelaceConfig): await self._store.async_remove() self._data = None + self._json_config = None self._config_updated() - async def _load(self): + async def _load(self) -> dict[str, Any]: """Load the config.""" data = await self._store.async_load() self._data = data if data else {"config": None} + return self._data + + @callback + def _async_build_json(self) -> json_fragment: + """Build JSON representation of the config.""" + if self._data is None or self._data["config"] is None: + raise ConfigNotFound + self._json_config = json_fragment(json_bytes(self._data["config"])) + return self._json_config class LovelaceYAML(LovelaceConfig): """Class to handle YAML-based Lovelace config.""" - def __init__(self, hass, url_path, config): + def __init__( + self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None + ) -> None: """Initialize the YAML config.""" super().__init__(hass, url_path, config) self.path = hass.config.path( config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE ) - self._cache = None + self._cache: tuple[dict[str, Any], float, json_fragment] | None = None @property def mode(self) -> str: @@ -182,23 +205,35 @@ class LovelaceYAML(LovelaceConfig): return _config_info(self.mode, config) - async def async_load(self, force): + async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" - is_updated, config = await self.hass.async_add_executor_job( + config, json = await self._async_load_or_cached(force) + return config + + async def async_json(self, force: bool) -> json_fragment: + """Return JSON representation of the config.""" + config, json = await self._async_load_or_cached(force) + return json + + async def _async_load_or_cached( + self, force: bool + ) -> tuple[dict[str, Any], json_fragment]: + """Load the config or return a cached version.""" + is_updated, config, json = await self.hass.async_add_executor_job( self._load_config, force ) if is_updated: self._config_updated() - return config + return config, json - def _load_config(self, force): + def _load_config(self, force: bool) -> tuple[bool, dict[str, Any], json_fragment]: """Load the actual config.""" # Check for a cached version of the config if not force and self._cache is not None: - config, last_update = self._cache + config, last_update, json = self._cache modtime = os.path.getmtime(self.path) if config and last_update > modtime: - return False, config + return False, config, json is_updated = self._cache is not None @@ -209,8 +244,9 @@ class LovelaceYAML(LovelaceConfig): except FileNotFoundError: raise ConfigNotFound from None - self._cache = (config, time.time()) - return is_updated, config + json = json_fragment(json_bytes(config)) + self._cache = (config, time.time(), json) + return is_updated, config, json def _config_info(mode, config): diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index e4eaa42073f..3049ae38542 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -11,6 +11,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.json import json_fragment from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound from .dashboard import LovelaceStorage @@ -86,9 +87,9 @@ async def websocket_lovelace_config( connection: websocket_api.ActiveConnection, msg: dict[str, Any], config: LovelaceStorage, -) -> None: +) -> json_fragment: """Send Lovelace UI config over WebSocket configuration.""" - return await config.async_load(msg["force"]) + return await config.async_json(msg["force"]) @websocket_api.require_admin @@ -137,7 +138,7 @@ def websocket_lovelace_dashboards( connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Delete Lovelace UI configuration.""" + """Send Lovelace dashboard configuration.""" connection.send_result( msg["id"], [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 47c4981ba2a..3353b2eea51 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -1,6 +1,7 @@ """Test the Lovelace initialization.""" from collections.abc import Generator +import time from typing import Any from unittest.mock import MagicMock, patch @@ -180,6 +181,44 @@ async def test_lovelace_from_yaml( assert len(events) == 1 + # Make sure when the mtime changes, we reload the config + with ( + patch( + "homeassistant.components.lovelace.dashboard.load_yaml_dict", + return_value={"hello": "yo3"}, + ), + patch( + "homeassistant.components.lovelace.dashboard.os.path.getmtime", + return_value=time.time(), + ), + ): + await client.send_json({"id": 9, "type": "lovelace/config", "force": False}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"hello": "yo3"} + + assert len(events) == 2 + + # If the mtime is lower, preserve the cache + with ( + patch( + "homeassistant.components.lovelace.dashboard.load_yaml_dict", + return_value={"hello": "yo4"}, + ), + patch( + "homeassistant.components.lovelace.dashboard.os.path.getmtime", + return_value=0, + ), + ): + await client.send_json({"id": 10, "type": "lovelace/config", "force": False}) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"hello": "yo3"} + + assert len(events) == 2 + @pytest.mark.parametrize("url_path", ["test-panel", "test-panel-no-sidebar"]) async def test_dashboard_from_yaml(