diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index db721ff18a5..5864c642fa9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -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 diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index fc8cb67894b..c78356e0dd6 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -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() diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py new file mode 100644 index 00000000000..2bf2b34098c --- /dev/null +++ b/homeassistant/components/lovelace/const.py @@ -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.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py new file mode 100644 index 00000000000..dcd7a6c4e52 --- /dev/null +++ b/homeassistant/components/lovelace/dashboard.py @@ -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", [])), + } diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py new file mode 100644 index 00000000000..4244feb26dd --- /dev/null +++ b/homeassistant/components/lovelace/resources.py @@ -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 diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py new file mode 100644 index 00000000000..d80764f4ed9 --- /dev/null +++ b/homeassistant/components/lovelace/websocket.py @@ -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() diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index bd6013aac0a..8b00981fb04 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -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, ) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 33ac15853f4..d14e31273b7 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -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( diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index bea08fb322c..d03469e20bb 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -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, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 8e4454751bf..565cac4058c 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -402,7 +402,20 @@ def service(value: Any) -> str: raise vol.Invalid(f"Service {value} does not match format .") -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: diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index f39fa5f1e55..07b6a8d48f8 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -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: diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_dashboard.py similarity index 61% rename from tests/components/lovelace/test_init.py rename to tests/components/lovelace/test_dashboard.py index 82e7b3bc2ac..9511e001197 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_dashboard.py @@ -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 diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py new file mode 100644 index 00000000000..89464d95350 --- /dev/null +++ b/tests/components/lovelace/test_resources.py @@ -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"] + )