Add json cache to lovelace config (#117843)

This commit is contained in:
J. Nick Koston 2024-05-24 02:07:43 -10:00 committed by GitHub
parent 2c09f72c33
commit 2308ff2cbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 110 additions and 34 deletions

View file

@ -7,14 +7,16 @@ import logging
import os import os
from pathlib import Path from pathlib import Path
import time import time
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components.frontend import DATA_PANELS from homeassistant.components.frontend import DATA_PANELS
from homeassistant.const import CONF_FILENAME from homeassistant.const import CONF_FILENAME
from homeassistant.core import callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, storage 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 homeassistant.util.yaml import Secrets, load_yaml_dict
from .const import ( from .const import (
@ -42,11 +44,13 @@ _LOGGER = logging.getLogger(__name__)
class LovelaceConfig(ABC): class LovelaceConfig(ABC):
"""Base class for Lovelace config.""" """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.""" """Initialize Lovelace config."""
self.hass = hass self.hass = hass
if config: if config:
self.config = {**config, CONF_URL_PATH: url_path} self.config: dict[str, Any] | None = {**config, CONF_URL_PATH: url_path}
else: else:
self.config = None self.config = None
@ -65,7 +69,7 @@ class LovelaceConfig(ABC):
"""Return the config info.""" """Return the config info."""
@abstractmethod @abstractmethod
async def async_load(self, force): async def async_load(self, force: bool) -> dict[str, Any]:
"""Load config.""" """Load config."""
async def async_save(self, config): async def async_save(self, config):
@ -77,7 +81,7 @@ class LovelaceConfig(ABC):
raise HomeAssistantError("Not supported") raise HomeAssistantError("Not supported")
@callback @callback
def _config_updated(self): def _config_updated(self) -> None:
"""Fire config updated event.""" """Fire config updated event."""
self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path}) self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path})
@ -85,10 +89,10 @@ class LovelaceConfig(ABC):
class LovelaceStorage(LovelaceConfig): class LovelaceStorage(LovelaceConfig):
"""Class to handle Storage based Lovelace config.""" """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.""" """Initialize Lovelace config based on storage helper."""
if config is None: if config is None:
url_path = None url_path: str | None = None
storage_key = CONFIG_STORAGE_KEY_DEFAULT storage_key = CONFIG_STORAGE_KEY_DEFAULT
else: else:
url_path = config[CONF_URL_PATH] url_path = config[CONF_URL_PATH]
@ -96,8 +100,11 @@ class LovelaceStorage(LovelaceConfig):
super().__init__(hass, url_path, config) super().__init__(hass, url_path, config)
self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) self._store = storage.Store[dict[str, Any]](
self._data = None hass, CONFIG_STORAGE_VERSION, storage_key
)
self._data: dict[str, Any] | None = None
self._json_config: json_fragment | None = None
@property @property
def mode(self) -> str: def mode(self) -> str:
@ -106,27 +113,30 @@ class LovelaceStorage(LovelaceConfig):
async def async_get_info(self): async def async_get_info(self):
"""Return the Lovelace storage info.""" """Return the Lovelace storage info."""
if self._data is None: data = self._data or await self._load()
await self._load() if data["config"] is None:
if self._data["config"] is None:
return {"mode": "auto-gen"} 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: bool) -> dict[str, Any]:
async def async_load(self, force):
"""Load config.""" """Load config."""
if self.hass.config.recovery_mode: if self.hass.config.recovery_mode:
raise ConfigNotFound raise ConfigNotFound
if self._data is None: data = self._data or await self._load()
await self._load() if (config := data["config"]) is None:
if (config := self._data["config"]) is None:
raise ConfigNotFound raise ConfigNotFound
return config 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): async def async_save(self, config):
"""Save config.""" """Save config."""
if self.hass.config.recovery_mode: if self.hass.config.recovery_mode:
@ -135,6 +145,7 @@ class LovelaceStorage(LovelaceConfig):
if self._data is None: if self._data is None:
await self._load() await self._load()
self._data["config"] = config self._data["config"] = config
self._json_config = None
self._config_updated() self._config_updated()
await self._store.async_save(self._data) await self._store.async_save(self._data)
@ -145,25 +156,37 @@ class LovelaceStorage(LovelaceConfig):
await self._store.async_remove() await self._store.async_remove()
self._data = None self._data = None
self._json_config = None
self._config_updated() self._config_updated()
async def _load(self): async def _load(self) -> dict[str, Any]:
"""Load the config.""" """Load the config."""
data = await self._store.async_load() data = await self._store.async_load()
self._data = data if data else {"config": None} 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 LovelaceYAML(LovelaceConfig):
"""Class to handle YAML-based Lovelace config.""" """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.""" """Initialize the YAML config."""
super().__init__(hass, url_path, config) super().__init__(hass, url_path, config)
self.path = hass.config.path( self.path = hass.config.path(
config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
) )
self._cache = None self._cache: tuple[dict[str, Any], float, json_fragment] | None = None
@property @property
def mode(self) -> str: def mode(self) -> str:
@ -182,23 +205,35 @@ class LovelaceYAML(LovelaceConfig):
return _config_info(self.mode, config) return _config_info(self.mode, config)
async def async_load(self, force): async def async_load(self, force: bool) -> dict[str, Any]:
"""Load config.""" """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 self._load_config, force
) )
if is_updated: if is_updated:
self._config_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.""" """Load the actual config."""
# Check for a cached version of the config # Check for a cached version of the config
if not force and self._cache is not None: 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) modtime = os.path.getmtime(self.path)
if config and last_update > modtime: if config and last_update > modtime:
return False, config return False, config, json
is_updated = self._cache is not None is_updated = self._cache is not None
@ -209,8 +244,9 @@ class LovelaceYAML(LovelaceConfig):
except FileNotFoundError: except FileNotFoundError:
raise ConfigNotFound from None raise ConfigNotFound from None
self._cache = (config, time.time()) json = json_fragment(json_bytes(config))
return is_updated, config self._cache = (config, time.time(), json)
return is_updated, config, json
def _config_info(mode, config): def _config_info(mode, config):

View file

@ -11,6 +11,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.json import json_fragment
from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound
from .dashboard import LovelaceStorage from .dashboard import LovelaceStorage
@ -86,9 +87,9 @@ async def websocket_lovelace_config(
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
config: LovelaceStorage, config: LovelaceStorage,
) -> None: ) -> json_fragment:
"""Send Lovelace UI config over WebSocket configuration.""" """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 @websocket_api.require_admin
@ -137,7 +138,7 @@ def websocket_lovelace_dashboards(
connection: websocket_api.ActiveConnection, connection: websocket_api.ActiveConnection,
msg: dict[str, Any], msg: dict[str, Any],
) -> None: ) -> None:
"""Delete Lovelace UI configuration.""" """Send Lovelace dashboard configuration."""
connection.send_result( connection.send_result(
msg["id"], msg["id"],
[ [

View file

@ -1,6 +1,7 @@
"""Test the Lovelace initialization.""" """Test the Lovelace initialization."""
from collections.abc import Generator from collections.abc import Generator
import time
from typing import Any from typing import Any
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -180,6 +181,44 @@ async def test_lovelace_from_yaml(
assert len(events) == 1 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"]) @pytest.mark.parametrize("url_path", ["test-panel", "test-panel-no-sidebar"])
async def test_dashboard_from_yaml( async def test_dashboard_from_yaml(