mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 21:27:38 +00:00
Support multiple Lovelace dashboards (#32134)
* Support multiple Lovelace dashboards * Mark collection maintenance as unfinished * Fix import * Add websockets commands for resource management * Revert "Add websockets commands for resource management" This reverts commit 7d140b2bccd27543db55c51930ad97e15e938ea5. Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
2d6b80470f
commit
536b31305a
@ -185,7 +185,7 @@ def async_register_built_in_panel(
|
||||
panels = hass.data.setdefault(DATA_PANELS, {})
|
||||
|
||||
if panel.frontend_url_path in panels:
|
||||
_LOGGER.warning("Overwriting integration %s", panel.frontend_url_path)
|
||||
raise ValueError(f"Overwriting panel {panel.frontend_url_path}")
|
||||
|
||||
panels[panel.frontend_url_path] = panel
|
||||
|
||||
|
@ -1,247 +1,165 @@
|
||||
"""Support for the Lovelace UI."""
|
||||
from functools import wraps
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.const import CONF_FILENAME, CONF_ICON
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util.yaml import load_yaml
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import sanitize_filename, slugify
|
||||
|
||||
from . import dashboard, resources, websocket
|
||||
from .const import (
|
||||
CONF_RESOURCES,
|
||||
DOMAIN,
|
||||
LOVELACE_CONFIG_FILE,
|
||||
MODE_STORAGE,
|
||||
MODE_YAML,
|
||||
RESOURCE_SCHEMA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "lovelace"
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
CONF_MODE = "mode"
|
||||
MODE_YAML = "yaml"
|
||||
MODE_STORAGE = "storage"
|
||||
|
||||
CONF_DASHBOARDS = "dashboards"
|
||||
CONF_SIDEBAR = "sidebar"
|
||||
CONF_TITLE = "title"
|
||||
CONF_REQUIRE_ADMIN = "require_admin"
|
||||
|
||||
DASHBOARD_BASE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
|
||||
vol.Optional(CONF_SIDEBAR): {
|
||||
vol.Required(CONF_ICON): cv.icon,
|
||||
vol.Required(CONF_TITLE): cv.string,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
YAML_DASHBOARD_SCHEMA = DASHBOARD_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_MODE): MODE_YAML,
|
||||
vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def url_slug(value: Any) -> str:
|
||||
"""Validate value is a valid url slug."""
|
||||
if value is None:
|
||||
raise vol.Invalid("Slug should not be None")
|
||||
str_value = str(value)
|
||||
slg = slugify(str_value, separator="-")
|
||||
if str_value == slg:
|
||||
return str_value
|
||||
raise vol.Invalid(f"invalid slug {value} (try {slg})")
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
vol.Optional(DOMAIN, default={}): vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_MODE, default=MODE_STORAGE): vol.All(
|
||||
vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys(
|
||||
YAML_DASHBOARD_SCHEMA, slug_validator=url_slug,
|
||||
),
|
||||
vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA],
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
EVENT_LOVELACE_UPDATED = "lovelace_updated"
|
||||
|
||||
LOVELACE_CONFIG_FILE = "ui-lovelace.yaml"
|
||||
|
||||
|
||||
class ConfigNotFound(HomeAssistantError):
|
||||
"""When no config available."""
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the Lovelace commands."""
|
||||
# Pass in default to `get` because defaults not set if loaded as dep
|
||||
mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE)
|
||||
mode = config[DOMAIN][CONF_MODE]
|
||||
yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
|
||||
|
||||
hass.components.frontend.async_register_built_in_panel(
|
||||
DOMAIN, config={"mode": mode}
|
||||
)
|
||||
frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode})
|
||||
|
||||
if mode == MODE_YAML:
|
||||
hass.data[DOMAIN] = LovelaceYAML(hass)
|
||||
default_config = dashboard.LovelaceYAML(hass, None, LOVELACE_CONFIG_FILE)
|
||||
|
||||
if yaml_resources is None:
|
||||
try:
|
||||
ll_conf = await default_config.async_load(False)
|
||||
except HomeAssistantError:
|
||||
pass
|
||||
else:
|
||||
if CONF_RESOURCES in ll_conf:
|
||||
_LOGGER.warning(
|
||||
"Resources need to be specified in your configuration.yaml. Please see the docs."
|
||||
)
|
||||
yaml_resources = ll_conf[CONF_RESOURCES]
|
||||
|
||||
resource_collection = resources.ResourceYAMLCollection(yaml_resources or [])
|
||||
|
||||
else:
|
||||
hass.data[DOMAIN] = LovelaceStorage(hass)
|
||||
default_config = dashboard.LovelaceStorage(hass, None)
|
||||
|
||||
hass.components.websocket_api.async_register_command(websocket_lovelace_config)
|
||||
if yaml_resources is not None:
|
||||
_LOGGER.warning(
|
||||
"Lovelace is running in storage mode. Define resources via user interface"
|
||||
)
|
||||
|
||||
hass.components.websocket_api.async_register_command(websocket_lovelace_save_config)
|
||||
resource_collection = resources.ResourceStorageCollection(hass, default_config)
|
||||
|
||||
hass.components.websocket_api.async_register_command(
|
||||
websocket_lovelace_delete_config
|
||||
websocket.websocket_lovelace_config
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
websocket.websocket_lovelace_save_config
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
websocket.websocket_lovelace_delete_config
|
||||
)
|
||||
hass.components.websocket_api.async_register_command(
|
||||
websocket.websocket_lovelace_resources
|
||||
)
|
||||
|
||||
hass.components.system_health.async_register_info(DOMAIN, system_health_info)
|
||||
|
||||
hass.data[DOMAIN] = {
|
||||
# We store a dictionary mapping url_path: config. None is the default.
|
||||
"dashboards": {None: default_config},
|
||||
"resources": resource_collection,
|
||||
}
|
||||
|
||||
if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]:
|
||||
return True
|
||||
|
||||
for url_path, dashboard_conf in config[DOMAIN][CONF_DASHBOARDS].items():
|
||||
# For now always mode=yaml
|
||||
config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf[CONF_FILENAME])
|
||||
hass.data[DOMAIN]["dashboards"][url_path] = config
|
||||
|
||||
kwargs = {
|
||||
"hass": hass,
|
||||
"component_name": DOMAIN,
|
||||
"frontend_url_path": url_path,
|
||||
"require_admin": dashboard_conf[CONF_REQUIRE_ADMIN],
|
||||
"config": {"mode": dashboard_conf[CONF_MODE]},
|
||||
}
|
||||
|
||||
if CONF_SIDEBAR in dashboard_conf:
|
||||
kwargs["sidebar_title"] = dashboard_conf[CONF_SIDEBAR][CONF_TITLE]
|
||||
kwargs["sidebar_icon"] = dashboard_conf[CONF_SIDEBAR][CONF_ICON]
|
||||
|
||||
try:
|
||||
frontend.async_register_built_in_panel(**kwargs)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Panel url path %s is not unique", url_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class LovelaceStorage:
|
||||
"""Class to handle Storage based Lovelace config."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize Lovelace config based on storage helper."""
|
||||
self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
|
||||
self._data = None
|
||||
self._hass = hass
|
||||
|
||||
async def async_get_info(self):
|
||||
"""Return the YAML storage mode."""
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
|
||||
if self._data["config"] is None:
|
||||
return {"mode": "auto-gen"}
|
||||
|
||||
return _config_info("storage", self._data["config"])
|
||||
|
||||
async def async_load(self, force):
|
||||
"""Load config."""
|
||||
if self._hass.config.safe_mode:
|
||||
raise ConfigNotFound
|
||||
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
|
||||
config = self._data["config"]
|
||||
|
||||
if config is None:
|
||||
raise ConfigNotFound
|
||||
|
||||
return config
|
||||
|
||||
async def async_save(self, config):
|
||||
"""Save config."""
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
self._data["config"] = config
|
||||
self._hass.bus.async_fire(EVENT_LOVELACE_UPDATED)
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
async def async_delete(self):
|
||||
"""Delete config."""
|
||||
await self.async_save(None)
|
||||
|
||||
async def _load(self):
|
||||
"""Load the config."""
|
||||
data = await self._store.async_load()
|
||||
self._data = data if data else {"config": None}
|
||||
|
||||
|
||||
class LovelaceYAML:
|
||||
"""Class to handle YAML-based Lovelace config."""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Initialize the YAML config."""
|
||||
self.hass = hass
|
||||
self._cache = None
|
||||
|
||||
async def async_get_info(self):
|
||||
"""Return the YAML storage mode."""
|
||||
try:
|
||||
config = await self.async_load(False)
|
||||
except ConfigNotFound:
|
||||
return {
|
||||
"mode": "yaml",
|
||||
"error": "{} not found".format(
|
||||
self.hass.config.path(LOVELACE_CONFIG_FILE)
|
||||
),
|
||||
}
|
||||
|
||||
return _config_info("yaml", config)
|
||||
|
||||
async def async_load(self, force):
|
||||
"""Load config."""
|
||||
is_updated, config = await self.hass.async_add_executor_job(
|
||||
self._load_config, force
|
||||
)
|
||||
if is_updated:
|
||||
self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED)
|
||||
return config
|
||||
|
||||
def _load_config(self, force):
|
||||
"""Load the actual config."""
|
||||
fname = self.hass.config.path(LOVELACE_CONFIG_FILE)
|
||||
# Check for a cached version of the config
|
||||
if not force and self._cache is not None:
|
||||
config, last_update = self._cache
|
||||
modtime = os.path.getmtime(fname)
|
||||
if config and last_update > modtime:
|
||||
return False, config
|
||||
|
||||
is_updated = self._cache is not None
|
||||
|
||||
try:
|
||||
config = load_yaml(fname)
|
||||
except FileNotFoundError:
|
||||
raise ConfigNotFound from None
|
||||
|
||||
self._cache = (config, time.time())
|
||||
return is_updated, config
|
||||
|
||||
async def async_save(self, config):
|
||||
"""Save config."""
|
||||
raise HomeAssistantError("Not supported")
|
||||
|
||||
async def async_delete(self):
|
||||
"""Delete config."""
|
||||
raise HomeAssistantError("Not supported")
|
||||
|
||||
|
||||
def handle_yaml_errors(func):
|
||||
"""Handle error with WebSocket calls."""
|
||||
|
||||
@wraps(func)
|
||||
async def send_with_error_handling(hass, connection, msg):
|
||||
error = None
|
||||
try:
|
||||
result = await func(hass, connection, msg)
|
||||
except ConfigNotFound:
|
||||
error = "config_not_found", "No config found."
|
||||
except HomeAssistantError as err:
|
||||
error = "error", str(err)
|
||||
|
||||
if error is not None:
|
||||
connection.send_error(msg["id"], *error)
|
||||
return
|
||||
|
||||
if msg is not None:
|
||||
await connection.send_big_result(msg["id"], result)
|
||||
else:
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
return send_with_error_handling
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command(
|
||||
{"type": "lovelace/config", vol.Optional("force", default=False): bool}
|
||||
)
|
||||
@handle_yaml_errors
|
||||
async def websocket_lovelace_config(hass, connection, msg):
|
||||
"""Send Lovelace UI config over WebSocket configuration."""
|
||||
return await hass.data[DOMAIN].async_load(msg["force"])
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command(
|
||||
{"type": "lovelace/config/save", "config": vol.Any(str, dict)}
|
||||
)
|
||||
@handle_yaml_errors
|
||||
async def websocket_lovelace_save_config(hass, connection, msg):
|
||||
"""Save Lovelace UI configuration."""
|
||||
await hass.data[DOMAIN].async_save(msg["config"])
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command({"type": "lovelace/config/delete"})
|
||||
@handle_yaml_errors
|
||||
async def websocket_lovelace_delete_config(hass, connection, msg):
|
||||
"""Delete Lovelace UI configuration."""
|
||||
await hass.data[DOMAIN].async_delete()
|
||||
|
||||
|
||||
async def system_health_info(hass):
|
||||
"""Get info for the info page."""
|
||||
return await hass.data[DOMAIN].async_get_info()
|
||||
|
||||
|
||||
def _config_info(mode, config):
|
||||
"""Generate info about the config."""
|
||||
return {
|
||||
"mode": mode,
|
||||
"resources": len(config.get("resources", [])),
|
||||
"views": len(config.get("views", [])),
|
||||
}
|
||||
return await hass.data[DOMAIN]["dashboards"][None].async_get_info()
|
||||
|
26
homeassistant/components/lovelace/const.py
Normal file
26
homeassistant/components/lovelace/const.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Constants for Lovelace."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_TYPE, CONF_URL
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
DOMAIN = "lovelace"
|
||||
EVENT_LOVELACE_UPDATED = "lovelace_updated"
|
||||
|
||||
MODE_YAML = "yaml"
|
||||
MODE_STORAGE = "storage"
|
||||
|
||||
LOVELACE_CONFIG_FILE = "ui-lovelace.yaml"
|
||||
CONF_RESOURCES = "resources"
|
||||
CONF_URL_PATH = "url_path"
|
||||
|
||||
RESOURCE_FIELDS = {
|
||||
CONF_TYPE: vol.In(["js", "css", "module", "html"]),
|
||||
CONF_URL: cv.string,
|
||||
}
|
||||
RESOURCE_SCHEMA = vol.Schema(RESOURCE_FIELDS)
|
||||
|
||||
|
||||
class ConfigNotFound(HomeAssistantError):
|
||||
"""When no config available."""
|
181
homeassistant/components/lovelace/dashboard.py
Normal file
181
homeassistant/components/lovelace/dashboard.py
Normal file
@ -0,0 +1,181 @@
|
||||
"""Lovelace dashboard support."""
|
||||
from abc import ABC, abstractmethod
|
||||
import os
|
||||
import time
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import storage
|
||||
from homeassistant.util.yaml import load_yaml
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
MODE_STORAGE,
|
||||
MODE_YAML,
|
||||
ConfigNotFound,
|
||||
)
|
||||
|
||||
CONFIG_STORAGE_KEY_DEFAULT = DOMAIN
|
||||
CONFIG_STORAGE_VERSION = 1
|
||||
|
||||
|
||||
class LovelaceConfig(ABC):
|
||||
"""Base class for Lovelace config."""
|
||||
|
||||
def __init__(self, hass, url_path):
|
||||
"""Initialize Lovelace config."""
|
||||
self.hass = hass
|
||||
self.url_path = url_path
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def mode(self) -> str:
|
||||
"""Return mode of the lovelace config."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_get_info(self):
|
||||
"""Return the config info."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_load(self, force):
|
||||
"""Load config."""
|
||||
|
||||
async def async_save(self, config):
|
||||
"""Save config."""
|
||||
raise HomeAssistantError("Not supported")
|
||||
|
||||
async def async_delete(self):
|
||||
"""Delete config."""
|
||||
raise HomeAssistantError("Not supported")
|
||||
|
||||
@callback
|
||||
def _config_updated(self):
|
||||
"""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, url_path):
|
||||
"""Initialize Lovelace config based on storage helper."""
|
||||
super().__init__(hass, url_path)
|
||||
if url_path is None:
|
||||
storage_key = CONFIG_STORAGE_KEY_DEFAULT
|
||||
else:
|
||||
raise ValueError("Storage-based dashboards are not supported")
|
||||
|
||||
self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key)
|
||||
self._data = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
"""Return mode of the lovelace config."""
|
||||
return MODE_STORAGE
|
||||
|
||||
async def async_get_info(self):
|
||||
"""Return the YAML storage mode."""
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
|
||||
if self._data["config"] is None:
|
||||
return {"mode": "auto-gen"}
|
||||
|
||||
return _config_info(self.mode, self._data["config"])
|
||||
|
||||
async def async_load(self, force):
|
||||
"""Load config."""
|
||||
if self.hass.config.safe_mode:
|
||||
raise ConfigNotFound
|
||||
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
|
||||
config = self._data["config"]
|
||||
|
||||
if config is None:
|
||||
raise ConfigNotFound
|
||||
|
||||
return config
|
||||
|
||||
async def async_save(self, config):
|
||||
"""Save config."""
|
||||
if self._data is None:
|
||||
await self._load()
|
||||
self._data["config"] = config
|
||||
self._config_updated()
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
async def async_delete(self):
|
||||
"""Delete config."""
|
||||
await self.async_save(None)
|
||||
|
||||
async def _load(self):
|
||||
"""Load the config."""
|
||||
data = await self._store.async_load()
|
||||
self._data = data if data else {"config": None}
|
||||
|
||||
|
||||
class LovelaceYAML(LovelaceConfig):
|
||||
"""Class to handle YAML-based Lovelace config."""
|
||||
|
||||
def __init__(self, hass, url_path, path):
|
||||
"""Initialize the YAML config."""
|
||||
super().__init__(hass, url_path)
|
||||
self.path = hass.config.path(path)
|
||||
self._cache = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
"""Return mode of the lovelace config."""
|
||||
return MODE_YAML
|
||||
|
||||
async def async_get_info(self):
|
||||
"""Return the YAML storage mode."""
|
||||
try:
|
||||
config = await self.async_load(False)
|
||||
except ConfigNotFound:
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"error": "{} not found".format(self.path),
|
||||
}
|
||||
|
||||
return _config_info(self.mode, config)
|
||||
|
||||
async def async_load(self, force):
|
||||
"""Load config."""
|
||||
is_updated, config = await self.hass.async_add_executor_job(
|
||||
self._load_config, force
|
||||
)
|
||||
if is_updated:
|
||||
self._config_updated()
|
||||
return config
|
||||
|
||||
def _load_config(self, force):
|
||||
"""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
|
||||
modtime = os.path.getmtime(self.path)
|
||||
if config and last_update > modtime:
|
||||
return False, config
|
||||
|
||||
is_updated = self._cache is not None
|
||||
|
||||
try:
|
||||
config = load_yaml(self.path)
|
||||
except FileNotFoundError:
|
||||
raise ConfigNotFound from None
|
||||
|
||||
self._cache = (config, time.time())
|
||||
return is_updated, config
|
||||
|
||||
|
||||
def _config_info(mode, config):
|
||||
"""Generate info about the config."""
|
||||
return {
|
||||
"mode": mode,
|
||||
"resources": len(config.get("resources", [])),
|
||||
"views": len(config.get("views", [])),
|
||||
}
|
96
homeassistant/components/lovelace/resources.py
Normal file
96
homeassistant/components/lovelace/resources.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""Lovelace resources support."""
|
||||
import logging
|
||||
from typing import List, Optional, cast
|
||||
import uuid
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import collection, storage
|
||||
|
||||
from .const import CONF_RESOURCES, DOMAIN, RESOURCE_SCHEMA
|
||||
from .dashboard import LovelaceConfig
|
||||
|
||||
RESOURCE_STORAGE_KEY = f"{DOMAIN}_resources"
|
||||
RESOURCES_STORAGE_VERSION = 1
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceYAMLCollection:
|
||||
"""Collection representing static YAML."""
|
||||
|
||||
loaded = True
|
||||
|
||||
def __init__(self, data):
|
||||
"""Initialize a resource YAML collection."""
|
||||
self.data = data
|
||||
|
||||
@callback
|
||||
def async_items(self) -> List[dict]:
|
||||
"""Return list of items in collection."""
|
||||
return self.data
|
||||
|
||||
|
||||
class ResourceStorageCollection(collection.StorageCollection):
|
||||
"""Collection to store resources."""
|
||||
|
||||
loaded = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig):
|
||||
"""Initialize the storage collection."""
|
||||
super().__init__(
|
||||
storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY),
|
||||
_LOGGER,
|
||||
)
|
||||
self.ll_config = ll_config
|
||||
|
||||
async def _async_load_data(self) -> Optional[dict]:
|
||||
"""Load the data."""
|
||||
data = await self.store.async_load()
|
||||
|
||||
if data is not None:
|
||||
return cast(Optional[dict], data)
|
||||
|
||||
# Import it from config.
|
||||
try:
|
||||
conf = await self.ll_config.async_load(False)
|
||||
except HomeAssistantError:
|
||||
return None
|
||||
|
||||
if CONF_RESOURCES not in conf:
|
||||
return None
|
||||
|
||||
# Remove it from config and save both resources + config
|
||||
data = conf[CONF_RESOURCES]
|
||||
|
||||
try:
|
||||
vol.Schema([RESOURCE_SCHEMA])(data)
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.warning("Resource import failed. Data invalid: %s", err)
|
||||
return None
|
||||
|
||||
conf.pop(CONF_RESOURCES)
|
||||
|
||||
for item in data:
|
||||
item[collection.CONF_ID] = uuid.uuid4().hex
|
||||
|
||||
data = {"items": data}
|
||||
|
||||
await self.store.async_save(data)
|
||||
await self.ll_config.async_save(conf)
|
||||
|
||||
return data
|
||||
|
||||
async def _process_create_data(self, data: dict) -> dict:
|
||||
"""Validate the config is valid."""
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def _get_suggested_id(self, info: dict) -> str:
|
||||
"""Suggest an ID based on the config."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def _update_data(self, data: dict, update_data: dict) -> dict:
|
||||
"""Return a new updated data object."""
|
||||
raise NotImplementedError
|
98
homeassistant/components/lovelace/websocket.py
Normal file
98
homeassistant/components/lovelace/websocket.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Websocket API for Lovelace."""
|
||||
from functools import wraps
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound
|
||||
|
||||
|
||||
def _handle_errors(func):
|
||||
"""Handle error with WebSocket calls."""
|
||||
|
||||
@wraps(func)
|
||||
async def send_with_error_handling(hass, connection, msg):
|
||||
url_path = msg.get(CONF_URL_PATH)
|
||||
config = hass.data[DOMAIN]["dashboards"].get(url_path)
|
||||
|
||||
if config is None:
|
||||
connection.send_error(
|
||||
msg["id"], "config_not_found", f"Unknown config specified: {url_path}"
|
||||
)
|
||||
return
|
||||
|
||||
error = None
|
||||
try:
|
||||
result = await func(hass, connection, msg, config)
|
||||
except ConfigNotFound:
|
||||
error = "config_not_found", "No config found."
|
||||
except HomeAssistantError as err:
|
||||
error = "error", str(err)
|
||||
|
||||
if error is not None:
|
||||
connection.send_error(msg["id"], *error)
|
||||
return
|
||||
|
||||
if msg is not None:
|
||||
await connection.send_big_result(msg["id"], result)
|
||||
else:
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
return send_with_error_handling
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command({"type": "lovelace/resources"})
|
||||
async def websocket_lovelace_resources(hass, connection, msg):
|
||||
"""Send Lovelace UI resources over WebSocket configuration."""
|
||||
resources = hass.data[DOMAIN]["resources"]
|
||||
|
||||
if not resources.loaded:
|
||||
await resources.async_load()
|
||||
resources.loaded = True
|
||||
|
||||
connection.send_result(msg["id"], resources.async_items())
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "lovelace/config",
|
||||
vol.Optional("force", default=False): bool,
|
||||
vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
@_handle_errors
|
||||
async def websocket_lovelace_config(hass, connection, msg, config):
|
||||
"""Send Lovelace UI config over WebSocket configuration."""
|
||||
return await config.async_load(msg["force"])
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "lovelace/config/save",
|
||||
"config": vol.Any(str, dict),
|
||||
vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
@_handle_errors
|
||||
async def websocket_lovelace_save_config(hass, connection, msg, config):
|
||||
"""Save Lovelace UI configuration."""
|
||||
await config.async_save(msg["config"])
|
||||
|
||||
|
||||
@websocket_api.async_response
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "lovelace/config/delete",
|
||||
vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string),
|
||||
}
|
||||
)
|
||||
@_handle_errors
|
||||
async def websocket_lovelace_delete_config(hass, connection, msg, config):
|
||||
"""Delete Lovelace UI configuration."""
|
||||
await config.async_delete()
|
@ -3,7 +3,7 @@
|
||||
Separate file to avoid circular imports.
|
||||
"""
|
||||
from homeassistant.components.frontend import EVENT_PANELS_UPDATED
|
||||
from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED
|
||||
from homeassistant.components.lovelace.const import EVENT_LOVELACE_UPDATED
|
||||
from homeassistant.components.persistent_notification import (
|
||||
EVENT_PERSISTENT_NOTIFICATIONS_UPDATED,
|
||||
)
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""Support for the definition of zones."""
|
||||
import logging
|
||||
from typing import Dict, List, Optional, cast
|
||||
from typing import Dict, Optional, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -159,32 +159,12 @@ class ZoneStorageCollection(collection.StorageCollection):
|
||||
return {**data, **update_data}
|
||||
|
||||
|
||||
class IDLessCollection(collection.ObservableCollection):
|
||||
"""A collection without IDs."""
|
||||
|
||||
counter = 0
|
||||
|
||||
async def async_load(self, data: List[dict]) -> None:
|
||||
"""Load the collection. Overrides existing data."""
|
||||
for item_id in list(self.data):
|
||||
await self.notify_change(collection.CHANGE_REMOVED, item_id, None)
|
||||
|
||||
self.data.clear()
|
||||
|
||||
for item in data:
|
||||
self.counter += 1
|
||||
item_id = f"fakeid-{self.counter}"
|
||||
|
||||
self.data[item_id] = item
|
||||
await self.notify_change(collection.CHANGE_ADDED, item_id, item)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
|
||||
"""Set up configured zones as well as Home Assistant zone if necessary."""
|
||||
component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass)
|
||||
id_manager = collection.IDManager()
|
||||
|
||||
yaml_collection = IDLessCollection(
|
||||
yaml_collection = collection.IDLessCollection(
|
||||
logging.getLogger(f"{__name__}.yaml_collection"), id_manager
|
||||
)
|
||||
collection.attach_entity_component_collection(
|
||||
|
@ -235,6 +235,26 @@ class StorageCollection(ObservableCollection):
|
||||
return {"items": list(self.data.values())}
|
||||
|
||||
|
||||
class IDLessCollection(ObservableCollection):
|
||||
"""A collection without IDs."""
|
||||
|
||||
counter = 0
|
||||
|
||||
async def async_load(self, data: List[dict]) -> None:
|
||||
"""Load the collection. Overrides existing data."""
|
||||
for item_id in list(self.data):
|
||||
await self.notify_change(CHANGE_REMOVED, item_id, None)
|
||||
|
||||
self.data.clear()
|
||||
|
||||
for item in data:
|
||||
self.counter += 1
|
||||
item_id = f"fakeid-{self.counter}"
|
||||
|
||||
self.data[item_id] = item
|
||||
await self.notify_change(CHANGE_ADDED, item_id, item)
|
||||
|
||||
|
||||
@callback
|
||||
def attach_entity_component_collection(
|
||||
entity_component: EntityComponent,
|
||||
|
@ -402,7 +402,20 @@ def service(value: Any) -> str:
|
||||
raise vol.Invalid(f"Service {value} does not match format <domain>.<name>")
|
||||
|
||||
|
||||
def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable:
|
||||
def slug(value: Any) -> str:
|
||||
"""Validate value is a valid slug."""
|
||||
if value is None:
|
||||
raise vol.Invalid("Slug should not be None")
|
||||
str_value = str(value)
|
||||
slg = util_slugify(str_value)
|
||||
if str_value == slg:
|
||||
return str_value
|
||||
raise vol.Invalid(f"invalid slug {value} (try {slg})")
|
||||
|
||||
|
||||
def schema_with_slug_keys(
|
||||
value_schema: Union[T, Callable], *, slug_validator: Callable[[Any], str] = slug
|
||||
) -> Callable:
|
||||
"""Ensure dicts have slugs as keys.
|
||||
|
||||
Replacement of vol.Schema({cv.slug: value_schema}) to prevent misleading
|
||||
@ -416,24 +429,13 @@ def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable:
|
||||
raise vol.Invalid("expected dictionary")
|
||||
|
||||
for key in value.keys():
|
||||
slug(key)
|
||||
slug_validator(key)
|
||||
|
||||
return cast(Dict, schema(value))
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
def slug(value: Any) -> str:
|
||||
"""Validate value is a valid slug."""
|
||||
if value is None:
|
||||
raise vol.Invalid("Slug should not be None")
|
||||
str_value = str(value)
|
||||
slg = util_slugify(str_value)
|
||||
if str_value == slg:
|
||||
return str_value
|
||||
raise vol.Invalid(f"invalid slug {value} (try {slg})")
|
||||
|
||||
|
||||
def slugify(value: Any) -> str:
|
||||
"""Coerce a value to a slug."""
|
||||
if value is None:
|
||||
|
@ -44,9 +44,9 @@ def sanitize_path(path: str) -> str:
|
||||
return RE_SANITIZE_PATH.sub("", path)
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
def slugify(text: str, *, separator: str = "_") -> str:
|
||||
"""Slugify a given text."""
|
||||
return unicode_slug.slugify(text, separator="_") # type: ignore
|
||||
return unicode_slug.slugify(text, separator=separator) # type: ignore
|
||||
|
||||
|
||||
def repr_helper(inp: Any) -> str:
|
||||
|
@ -1,7 +1,10 @@
|
||||
"""Test the Lovelace initialization."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components import frontend, lovelace
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import frontend
|
||||
from homeassistant.components.lovelace import const, dashboard
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import async_capture_events, get_system_health_info
|
||||
@ -21,14 +24,16 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage):
|
||||
assert response["error"]["code"] == "config_not_found"
|
||||
|
||||
# Store new config
|
||||
events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED)
|
||||
events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
|
||||
|
||||
await client.send_json(
|
||||
{"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": {"yo": "hello"}
|
||||
}
|
||||
assert len(events) == 1
|
||||
|
||||
# Load new config
|
||||
@ -59,7 +64,9 @@ async def test_lovelace_from_storage_save_before_load(
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": {"yo": "hello"}
|
||||
}
|
||||
|
||||
|
||||
async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
|
||||
@ -73,13 +80,17 @@ async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}}
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": {"yo": "hello"}
|
||||
}
|
||||
|
||||
# Delete config
|
||||
await client.send_json({"id": 7, "type": "lovelace/config/delete"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": None}
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": None
|
||||
}
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 8, "type": "lovelace/config"})
|
||||
@ -110,10 +121,11 @@ async def test_lovelace_from_yaml(hass, hass_ws_client):
|
||||
assert not response["success"]
|
||||
|
||||
# Patch data
|
||||
events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED)
|
||||
events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo"}
|
||||
"homeassistant.components.lovelace.dashboard.load_yaml",
|
||||
return_value={"hello": "yo"},
|
||||
):
|
||||
await client.send_json({"id": 7, "type": "lovelace/config"})
|
||||
response = await client.receive_json()
|
||||
@ -125,7 +137,8 @@ async def test_lovelace_from_yaml(hass, hass_ws_client):
|
||||
|
||||
# Fake new data to see we fire event
|
||||
with patch(
|
||||
"homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo2"}
|
||||
"homeassistant.components.lovelace.dashboard.load_yaml",
|
||||
return_value={"hello": "yo2"},
|
||||
):
|
||||
await client.send_json({"id": 8, "type": "lovelace/config", "force": True})
|
||||
response = await client.receive_json()
|
||||
@ -145,7 +158,7 @@ async def test_system_health_info_autogen(hass):
|
||||
|
||||
async def test_system_health_info_storage(hass, hass_storage):
|
||||
"""Test system health info endpoint."""
|
||||
hass_storage[lovelace.STORAGE_KEY] = {
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"key": "lovelace",
|
||||
"version": 1,
|
||||
"data": {"config": {"resources": [], "views": []}},
|
||||
@ -159,7 +172,7 @@ async def test_system_health_info_yaml(hass):
|
||||
"""Test system health info endpoint."""
|
||||
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
|
||||
with patch(
|
||||
"homeassistant.components.lovelace.load_yaml",
|
||||
"homeassistant.components.lovelace.dashboard.load_yaml",
|
||||
return_value={"views": [{"cards": []}]},
|
||||
):
|
||||
info = await get_system_health_info(hass, "lovelace")
|
||||
@ -174,3 +187,81 @@ async def test_system_health_info_yaml_not_found(hass):
|
||||
"mode": "yaml",
|
||||
"error": "{} not found".format(hass.config.path("ui-lovelace.yaml")),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url_path", ("test-panel", "test-panel-no-sidebar"))
|
||||
async def test_dashboard_from_yaml(hass, hass_ws_client, url_path):
|
||||
"""Test we load lovelace dashboard config from yaml."""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"lovelace",
|
||||
{
|
||||
"lovelace": {
|
||||
"dashboards": {
|
||||
"test-panel": {
|
||||
"mode": "yaml",
|
||||
"filename": "bla.yaml",
|
||||
"sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"},
|
||||
},
|
||||
"test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"}
|
||||
assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == {
|
||||
"mode": "yaml"
|
||||
}
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path})
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
|
||||
assert response["error"]["code"] == "config_not_found"
|
||||
|
||||
# Store new config not allowed
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 6,
|
||||
"type": "lovelace/config/save",
|
||||
"config": {"yo": "hello"},
|
||||
"url_path": url_path,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
|
||||
# Patch data
|
||||
events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.lovelace.dashboard.load_yaml",
|
||||
return_value={"hello": "yo"},
|
||||
):
|
||||
await client.send_json(
|
||||
{"id": 7, "type": "lovelace/config", "url_path": url_path}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {"hello": "yo"}
|
||||
|
||||
assert len(events) == 0
|
||||
|
||||
# Fake new data to see we fire event
|
||||
with patch(
|
||||
"homeassistant.components.lovelace.dashboard.load_yaml",
|
||||
return_value={"hello": "yo2"},
|
||||
):
|
||||
await client.send_json(
|
||||
{"id": 8, "type": "lovelace/config", "force": True, "url_path": url_path}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {"hello": "yo2"}
|
||||
|
||||
assert len(events) == 1
|
113
tests/components/lovelace/test_resources.py
Normal file
113
tests/components/lovelace/test_resources.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Test Lovelace resources."""
|
||||
import copy
|
||||
import uuid
|
||||
|
||||
from asynctest import patch
|
||||
|
||||
from homeassistant.components.lovelace import dashboard, resources
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
RESOURCE_EXAMPLES = [
|
||||
{"type": "js", "url": "/local/bla.js"},
|
||||
{"type": "css", "url": "/local/bla.css"},
|
||||
]
|
||||
|
||||
|
||||
async def test_yaml_resources(hass, hass_ws_client):
|
||||
"""Test defining resources in configuration.yaml."""
|
||||
assert await async_setup_component(
|
||||
hass, "lovelace", {"lovelace": {"mode": "yaml", "resources": RESOURCE_EXAMPLES}}
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/resources"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == RESOURCE_EXAMPLES
|
||||
|
||||
|
||||
async def test_yaml_resources_backwards(hass, hass_ws_client):
|
||||
"""Test defining resources in YAML ll config (legacy)."""
|
||||
with patch(
|
||||
"homeassistant.components.lovelace.dashboard.load_yaml",
|
||||
return_value={"resources": RESOURCE_EXAMPLES},
|
||||
):
|
||||
assert await async_setup_component(
|
||||
hass, "lovelace", {"lovelace": {"mode": "yaml"}}
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/resources"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == RESOURCE_EXAMPLES
|
||||
|
||||
|
||||
async def test_storage_resources(hass, hass_ws_client, hass_storage):
|
||||
"""Test defining resources in storage config."""
|
||||
resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES]
|
||||
hass_storage[resources.RESOURCE_STORAGE_KEY] = {
|
||||
"key": resources.RESOURCE_STORAGE_KEY,
|
||||
"version": 1,
|
||||
"data": {"items": resource_config},
|
||||
}
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/resources"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == resource_config
|
||||
|
||||
|
||||
async def test_storage_resources_import(hass, hass_ws_client, hass_storage):
|
||||
"""Test importing resources from storage config."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"key": "lovelace",
|
||||
"version": 1,
|
||||
"data": {"config": {"resources": copy.deepcopy(RESOURCE_EXAMPLES)}},
|
||||
}
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/resources"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert (
|
||||
response["result"]
|
||||
== hass_storage[resources.RESOURCE_STORAGE_KEY]["data"]["items"]
|
||||
)
|
||||
assert (
|
||||
"resources"
|
||||
not in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"]
|
||||
)
|
||||
|
||||
|
||||
async def test_storage_resources_import_invalid(hass, hass_ws_client, hass_storage):
|
||||
"""Test importing resources from storage config."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = {
|
||||
"key": "lovelace",
|
||||
"version": 1,
|
||||
"data": {"config": {"resources": [{"invalid": "resource"}]}},
|
||||
}
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Fetch data
|
||||
await client.send_json({"id": 5, "type": "lovelace/resources"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == []
|
||||
assert (
|
||||
"resources"
|
||||
in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"]
|
||||
)
|
Loading…
x
Reference in New Issue
Block a user