Add json cache to lovelace config (#117843)
This commit is contained in:
parent
2c09f72c33
commit
2308ff2cbf
3 changed files with 110 additions and 34 deletions
|
@ -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):
|
||||||
|
|
|
@ -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"],
|
||||||
[
|
[
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Add table
Reference in a new issue