Skip to content

Commit

Permalink
Add json cache to lovelace config (#117843)
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco committed May 24, 2024
1 parent 2c09f72 commit 2308ff2
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 34 deletions.
98 changes: 67 additions & 31 deletions homeassistant/components/lovelace/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
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 (
Expand Down Expand Up @@ -42,11 +44,13 @@
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

Expand All @@ -65,7 +69,7 @@ async def async_get_info(self):
"""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):
Expand All @@ -77,27 +81,30 @@ async def async_delete(self):
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})


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]
storage_key = CONFIG_STORAGE_KEY.format(config["id"])

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:
Expand All @@ -106,27 +113,30 @@ def mode(self) -> str:

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:
Expand All @@ -135,6 +145,7 @@ async def async_save(self, config):
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)

Expand All @@ -145,25 +156,37 @@ async def async_delete(self):

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:
Expand All @@ -182,23 +205,35 @@ async def async_get_info(self):

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

Expand All @@ -209,8 +244,9 @@ def _load_config(self, force):
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):
Expand Down
7 changes: 4 additions & 3 deletions homeassistant/components/lovelace/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"],
[
Expand Down
39 changes: 39 additions & 0 deletions tests/components/lovelace/test_dashboard.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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(
Expand Down

0 comments on commit 2308ff2

Please sign in to comment.