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:
Paulus Schoutsen 2020-02-25 11:18:21 -08:00 committed by GitHub
parent 2d6b80470f
commit 536b31305a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 779 additions and 254 deletions

View File

@ -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

View File

@ -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()

View 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."""

View 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", [])),
}

View 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

View 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()

View File

@ -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,
)

View File

@ -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(

View File

@ -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,

View File

@ -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:

View File

@ -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:

View File

@ -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

View 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"]
)