Allow managing Lovelace storage dashboards (#32241)

* Allow managing Lovelace storage dashboards

* Make sure we do not allow duplicate url paths

* Allow setting sidebar to None

* Fix tests

* Delete storage file on delete

* List all dashboards
This commit is contained in:
Paulus Schoutsen 2020-02-28 12:43:17 -08:00 committed by GitHub
parent ede39454a2
commit deda2f86e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 447 additions and 90 deletions

View File

@ -171,6 +171,8 @@ def async_register_built_in_panel(
frontend_url_path=None, frontend_url_path=None,
config=None, config=None,
require_admin=False, require_admin=False,
*,
update=False,
): ):
"""Register a built-in panel.""" """Register a built-in panel."""
panel = Panel( panel = Panel(
@ -184,7 +186,7 @@ def async_register_built_in_panel(
panels = hass.data.setdefault(DATA_PANELS, {}) panels = hass.data.setdefault(DATA_PANELS, {})
if panel.frontend_url_path in panels: if not update and panel.frontend_url_path in panels:
raise ValueError(f"Overwriting panel {panel.frontend_url_path}") raise ValueError(f"Overwriting panel {panel.frontend_url_path}")
panels[panel.frontend_url_path] = panel panels[panel.frontend_url_path] = panel

View File

@ -1,65 +1,48 @@
"""Support for the Lovelace UI.""" """Support for the Lovelace UI."""
import logging import logging
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components import frontend from homeassistant.components import frontend
from homeassistant.const import CONF_FILENAME, CONF_ICON from homeassistant.const import CONF_FILENAME
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers import collection, config_validation as cv
from homeassistant.util import sanitize_filename, slugify from homeassistant.util import sanitize_filename
from . import dashboard, resources, websocket from . import dashboard, resources, websocket
from .const import ( from .const import (
CONF_ICON,
CONF_MODE,
CONF_REQUIRE_ADMIN,
CONF_RESOURCES, CONF_RESOURCES,
CONF_SIDEBAR,
CONF_TITLE,
CONF_URL_PATH,
DASHBOARD_BASE_CREATE_FIELDS,
DOMAIN, DOMAIN,
LOVELACE_CONFIG_FILE,
MODE_STORAGE, MODE_STORAGE,
MODE_YAML, MODE_YAML,
RESOURCE_CREATE_FIELDS, RESOURCE_CREATE_FIELDS,
RESOURCE_SCHEMA, RESOURCE_SCHEMA,
RESOURCE_UPDATE_FIELDS, RESOURCE_UPDATE_FIELDS,
STORAGE_DASHBOARD_CREATE_FIELDS,
STORAGE_DASHBOARD_UPDATE_FIELDS,
url_slug,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_MODE = "mode"
CONF_DASHBOARDS = "dashboards" CONF_DASHBOARDS = "dashboards"
CONF_SIDEBAR = "sidebar"
CONF_TITLE = "title"
CONF_REQUIRE_ADMIN = "require_admin"
DASHBOARD_BASE_SCHEMA = vol.Schema( YAML_DASHBOARD_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(
{ {
**DASHBOARD_BASE_CREATE_FIELDS,
vol.Required(CONF_MODE): MODE_YAML, vol.Required(CONF_MODE): MODE_YAML,
vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename), 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( CONFIG_SCHEMA = vol.Schema(
{ {
vol.Optional(DOMAIN, default={}): vol.Schema( vol.Optional(DOMAIN, default={}): vol.Schema(
@ -80,14 +63,13 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the Lovelace commands.""" """Set up the Lovelace commands."""
# Pass in default to `get` because defaults not set if loaded as dep
mode = config[DOMAIN][CONF_MODE] mode = config[DOMAIN][CONF_MODE]
yaml_resources = config[DOMAIN].get(CONF_RESOURCES) yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode}) frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode})
if mode == MODE_YAML: if mode == MODE_YAML:
default_config = dashboard.LovelaceYAML(hass, None, LOVELACE_CONFIG_FILE) default_config = dashboard.LovelaceYAML(hass, None, None)
if yaml_resources is None: if yaml_resources is None:
try: try:
@ -134,6 +116,10 @@ async def async_setup(hass, config):
websocket.websocket_lovelace_resources websocket.websocket_lovelace_resources
) )
hass.components.websocket_api.async_register_command(
websocket.websocket_lovelace_dashboards
)
hass.components.system_health.async_register_info(DOMAIN, system_health_info) hass.components.system_health.async_register_info(DOMAIN, system_health_info)
hass.data[DOMAIN] = { hass.data[DOMAIN] = {
@ -142,34 +128,87 @@ async def async_setup(hass, config):
"resources": resource_collection, "resources": resource_collection,
} }
if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]: if hass.config.safe_mode:
return True return True
for url_path, dashboard_conf in config[DOMAIN][CONF_DASHBOARDS].items(): # Process YAML dashboards
for url_path, dashboard_conf in config[DOMAIN].get(CONF_DASHBOARDS, {}).items():
# For now always mode=yaml # For now always mode=yaml
config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf[CONF_FILENAME]) config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf)
hass.data[DOMAIN]["dashboards"][url_path] = config 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: try:
frontend.async_register_built_in_panel(**kwargs) _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False)
except ValueError: except ValueError:
_LOGGER.warning("Panel url path %s is not unique", url_path) _LOGGER.warning("Panel url path %s is not unique", url_path)
# Process storage dashboards
dashboards_collection = dashboard.DashboardsCollection(hass)
async def storage_dashboard_changed(change_type, item_id, item):
"""Handle a storage dashboard change."""
url_path = item[CONF_URL_PATH]
if change_type == collection.CHANGE_REMOVED:
frontend.async_remove_panel(hass, url_path)
await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete()
return
if change_type == collection.CHANGE_ADDED:
existing = hass.data[DOMAIN]["dashboards"].get(url_path)
if existing:
_LOGGER.warning(
"Cannot register panel at %s, it is already defined in %s",
url_path,
existing,
)
return
hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage(
hass, item
)
update = False
else:
update = True
try:
_register_panel(hass, url_path, MODE_STORAGE, item, update)
except ValueError:
_LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path)
dashboards_collection.async_add_listener(storage_dashboard_changed)
await dashboards_collection.async_load()
collection.StorageCollectionWebsocket(
dashboards_collection,
"lovelace/dashboards",
"dashboard",
STORAGE_DASHBOARD_CREATE_FIELDS,
STORAGE_DASHBOARD_UPDATE_FIELDS,
).async_setup(hass, create_list=False)
return True return True
async def system_health_info(hass): async def system_health_info(hass):
"""Get info for the info page.""" """Get info for the info page."""
return await hass.data[DOMAIN]["dashboards"][None].async_get_info() return await hass.data[DOMAIN]["dashboards"][None].async_get_info()
@callback
def _register_panel(hass, url_path, mode, config, update):
"""Register a panel."""
kwargs = {
"frontend_url_path": url_path,
"require_admin": config[CONF_REQUIRE_ADMIN],
"config": {"mode": mode},
"update": update,
}
if CONF_SIDEBAR in config:
kwargs["sidebar_title"] = config[CONF_SIDEBAR][CONF_TITLE]
kwargs["sidebar_icon"] = config[CONF_SIDEBAR][CONF_ICON]
frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)

View File

@ -1,13 +1,17 @@
"""Constants for Lovelace.""" """Constants for Lovelace."""
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_TYPE, CONF_URL from homeassistant.const import CONF_ICON, CONF_TYPE, CONF_URL
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util import slugify
DOMAIN = "lovelace" DOMAIN = "lovelace"
EVENT_LOVELACE_UPDATED = "lovelace_updated" EVENT_LOVELACE_UPDATED = "lovelace_updated"
CONF_MODE = "mode"
MODE_YAML = "yaml" MODE_YAML = "yaml"
MODE_STORAGE = "storage" MODE_STORAGE = "storage"
@ -35,6 +39,50 @@ RESOURCE_UPDATE_FIELDS = {
vol.Optional(CONF_URL): cv.string, vol.Optional(CONF_URL): cv.string,
} }
CONF_SIDEBAR = "sidebar"
CONF_TITLE = "title"
CONF_REQUIRE_ADMIN = "require_admin"
SIDEBAR_FIELDS = {
vol.Required(CONF_ICON): cv.icon,
vol.Required(CONF_TITLE): cv.string,
}
DASHBOARD_BASE_CREATE_FIELDS = {
vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
vol.Optional(CONF_SIDEBAR): SIDEBAR_FIELDS,
}
DASHBOARD_BASE_UPDATE_FIELDS = {
vol.Optional(CONF_REQUIRE_ADMIN): cv.boolean,
vol.Optional(CONF_SIDEBAR): vol.Any(None, SIDEBAR_FIELDS),
}
STORAGE_DASHBOARD_CREATE_FIELDS = {
**DASHBOARD_BASE_CREATE_FIELDS,
vol.Required(CONF_URL_PATH): cv.string,
# For now we write "storage" as all modes.
# In future we can adjust this to be other modes.
vol.Optional(CONF_MODE, default=MODE_STORAGE): MODE_STORAGE,
}
STORAGE_DASHBOARD_UPDATE_FIELDS = {
**DASHBOARD_BASE_UPDATE_FIELDS,
}
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})")
class ConfigNotFound(HomeAssistantError): class ConfigNotFound(HomeAssistantError):
"""When no config available.""" """When no config available."""

View File

@ -1,32 +1,53 @@
"""Lovelace dashboard support.""" """Lovelace dashboard support."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import logging
import os import os
import time import time
import voluptuous as vol
from homeassistant.const import CONF_FILENAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import storage from homeassistant.helpers import collection, storage
from homeassistant.util.yaml import load_yaml from homeassistant.util.yaml import load_yaml
from .const import ( from .const import (
CONF_SIDEBAR,
CONF_URL_PATH,
DOMAIN, DOMAIN,
EVENT_LOVELACE_UPDATED, EVENT_LOVELACE_UPDATED,
LOVELACE_CONFIG_FILE,
MODE_STORAGE, MODE_STORAGE,
MODE_YAML, MODE_YAML,
STORAGE_DASHBOARD_CREATE_FIELDS,
STORAGE_DASHBOARD_UPDATE_FIELDS,
ConfigNotFound, ConfigNotFound,
) )
CONFIG_STORAGE_KEY_DEFAULT = DOMAIN CONFIG_STORAGE_KEY_DEFAULT = DOMAIN
CONFIG_STORAGE_KEY = "lovelace.{}"
CONFIG_STORAGE_VERSION = 1 CONFIG_STORAGE_VERSION = 1
DASHBOARDS_STORAGE_KEY = f"{DOMAIN}_dashboards"
DASHBOARDS_STORAGE_VERSION = 1
_LOGGER = logging.getLogger(__name__)
class LovelaceConfig(ABC): class LovelaceConfig(ABC):
"""Base class for Lovelace config.""" """Base class for Lovelace config."""
def __init__(self, hass, url_path): def __init__(self, hass, url_path, config):
"""Initialize Lovelace config.""" """Initialize Lovelace config."""
self.hass = hass self.hass = hass
self.url_path = url_path if config:
self.config = {**config, CONF_URL_PATH: url_path}
else:
self.config = None
@property
def url_path(self) -> str:
"""Return url path."""
return self.config[CONF_URL_PATH] if self.config else None
@property @property
@abstractmethod @abstractmethod
@ -58,13 +79,16 @@ class LovelaceConfig(ABC):
class LovelaceStorage(LovelaceConfig): class LovelaceStorage(LovelaceConfig):
"""Class to handle Storage based Lovelace config.""" """Class to handle Storage based Lovelace config."""
def __init__(self, hass, url_path): def __init__(self, hass, config):
"""Initialize Lovelace config based on storage helper.""" """Initialize Lovelace config based on storage helper."""
super().__init__(hass, url_path) if config is None:
if url_path is None: url_path = None
storage_key = CONFIG_STORAGE_KEY_DEFAULT storage_key = CONFIG_STORAGE_KEY_DEFAULT
else: else:
raise ValueError("Storage-based dashboards are not supported") url_path = config[CONF_URL_PATH]
storage_key = CONFIG_STORAGE_KEY.format(url_path)
super().__init__(hass, url_path, config)
self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key)
self._data = None self._data = None
@ -115,7 +139,9 @@ class LovelaceStorage(LovelaceConfig):
if self.hass.config.safe_mode: if self.hass.config.safe_mode:
raise HomeAssistantError("Deleting not supported in safe mode") raise HomeAssistantError("Deleting not supported in safe mode")
await self.async_save(None) await self._store.async_remove()
self._data = None
self._config_updated()
async def _load(self): async def _load(self):
"""Load the config.""" """Load the config."""
@ -126,10 +152,13 @@ class LovelaceStorage(LovelaceConfig):
class LovelaceYAML(LovelaceConfig): class LovelaceYAML(LovelaceConfig):
"""Class to handle YAML-based Lovelace config.""" """Class to handle YAML-based Lovelace config."""
def __init__(self, hass, url_path, path): def __init__(self, hass, url_path, config):
"""Initialize the YAML config.""" """Initialize the YAML config."""
super().__init__(hass, url_path) super().__init__(hass, url_path, config)
self.path = hass.config.path(path)
self.path = hass.config.path(
config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
)
self._cache = None self._cache = None
@property @property
@ -185,3 +214,39 @@ def _config_info(mode, config):
"resources": len(config.get("resources", [])), "resources": len(config.get("resources", [])),
"views": len(config.get("views", [])), "views": len(config.get("views", [])),
} }
class DashboardsCollection(collection.StorageCollection):
"""Collection of dashboards."""
CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS)
UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS)
def __init__(self, hass):
"""Initialize the dashboards collection."""
super().__init__(
storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY),
_LOGGER,
)
async def _process_create_data(self, data: dict) -> dict:
"""Validate the config is valid."""
if data[CONF_URL_PATH] in self.hass.data[DOMAIN]["dashboards"]:
raise vol.Invalid("Dashboard url path needs to be unique")
return self.CREATE_SCHEMA(data)
@callback
def _get_suggested_id(self, info: dict) -> str:
"""Suggest an ID based on the config."""
return info[CONF_URL_PATH]
async def _update_data(self, data: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.UPDATE_SCHEMA(update_data)
updated = {**data, **update_data}
if CONF_SIDEBAR in updated and updated[CONF_SIDEBAR] is None:
updated.pop(CONF_SIDEBAR)
return updated

View File

@ -4,6 +4,7 @@ from functools import wraps
import voluptuous as vol import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
@ -96,3 +97,17 @@ async def websocket_lovelace_save_config(hass, connection, msg, config):
async def websocket_lovelace_delete_config(hass, connection, msg, config): async def websocket_lovelace_delete_config(hass, connection, msg, config):
"""Delete Lovelace UI configuration.""" """Delete Lovelace UI configuration."""
await config.async_delete() await config.async_delete()
@websocket_api.websocket_command({"type": "lovelace/dashboards/list"})
@callback
def websocket_lovelace_dashboards(hass, connection, msg):
"""Delete Lovelace UI configuration."""
connection.send_result(
msg["id"],
[
dashboard.config
for dashboard in hass.data[DOMAIN]["dashboards"].values()
if dashboard.config
],
)

View File

@ -189,9 +189,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
).async_setup(hass) ).async_setup(hass)
async def _collection_changed( async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None:
change_type: str, item_id: str, config: Optional[Dict]
) -> None:
"""Handle a collection change: clean up entity registry on removals.""" """Handle a collection change: clean up entity registry on removals."""
if change_type != collection.CHANGE_REMOVED: if change_type != collection.CHANGE_REMOVED:
return return

View File

@ -31,8 +31,8 @@ ChangeListener = Callable[
str, str,
# Item ID # Item ID
str, str,
# New config (None if removed) # New or removed config
Optional[dict], dict,
], ],
Awaitable[None], Awaitable[None],
] ]
@ -104,9 +104,7 @@ class ObservableCollection(ABC):
""" """
self.listeners.append(listener) self.listeners.append(listener)
async def notify_change( async def notify_change(self, change_type: str, item_id: str, item: dict) -> None:
self, change_type: str, item_id: str, item: Optional[dict]
) -> None:
"""Notify listeners of a change.""" """Notify listeners of a change."""
self.logger.debug("%s %s: %s", change_type, item_id, item) self.logger.debug("%s %s: %s", change_type, item_id, item)
for listener in self.listeners: for listener in self.listeners:
@ -136,8 +134,8 @@ class YamlCollection(ObservableCollection):
await self.notify_change(event, item_id, item) await self.notify_change(event, item_id, item)
for item_id in old_ids: for item_id in old_ids:
self.data.pop(item_id)
await self.notify_change(CHANGE_REMOVED, item_id, None) await self.notify_change(CHANGE_REMOVED, item_id, self.data.pop(item_id))
class StorageCollection(ObservableCollection): class StorageCollection(ObservableCollection):
@ -219,10 +217,10 @@ class StorageCollection(ObservableCollection):
if item_id not in self.data: if item_id not in self.data:
raise ItemNotFound(item_id) raise ItemNotFound(item_id)
self.data.pop(item_id) item = self.data.pop(item_id)
self._async_schedule_save() self._async_schedule_save()
await self.notify_change(CHANGE_REMOVED, item_id, None) await self.notify_change(CHANGE_REMOVED, item_id, item)
@callback @callback
def _async_schedule_save(self) -> None: def _async_schedule_save(self) -> None:
@ -242,8 +240,8 @@ class IDLessCollection(ObservableCollection):
async def async_load(self, data: List[dict]) -> None: async def async_load(self, data: List[dict]) -> None:
"""Load the collection. Overrides existing data.""" """Load the collection. Overrides existing data."""
for item_id in list(self.data): for item_id, item in list(self.data.items()):
await self.notify_change(CHANGE_REMOVED, item_id, None) await self.notify_change(CHANGE_REMOVED, item_id, item)
self.data.clear() self.data.clear()
@ -264,12 +262,10 @@ def attach_entity_component_collection(
"""Map a collection to an entity component.""" """Map a collection to an entity component."""
entities = {} entities = {}
async def _collection_changed( async def _collection_changed(change_type: str, item_id: str, config: dict) -> None:
change_type: str, item_id: str, config: Optional[dict]
) -> None:
"""Handle a collection change.""" """Handle a collection change."""
if change_type == CHANGE_ADDED: if change_type == CHANGE_ADDED:
entity = create_entity(cast(dict, config)) entity = create_entity(config)
await entity_component.async_add_entities([entity]) # type: ignore await entity_component.async_add_entities([entity]) # type: ignore
entities[item_id] = entity entities[item_id] = entity
return return
@ -294,9 +290,7 @@ def attach_entity_registry_cleaner(
) -> None: ) -> None:
"""Attach a listener to clean up entity registry on collection changes.""" """Attach a listener to clean up entity registry on collection changes."""
async def _collection_changed( async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None:
change_type: str, item_id: str, config: Optional[Dict]
) -> None:
"""Handle a collection change: clean up entity registry on removals.""" """Handle a collection change: clean up entity registry on removals."""
if change_type != CHANGE_REMOVED: if change_type != CHANGE_REMOVED:
return return

View File

@ -210,3 +210,10 @@ class Store:
async def _async_migrate_func(self, old_version, old_data): async def _async_migrate_func(self, old_version, old_data):
"""Migrate to the new version.""" """Migrate to the new version."""
raise NotImplementedError raise NotImplementedError
async def async_remove(self):
"""Remove all data."""
try:
await self.hass.async_add_executor_job(os.unlink, self.path)
except FileNotFoundError:
pass

View File

@ -992,6 +992,10 @@ def mock_storage(data=None):
# To ensure that the data can be serialized # To ensure that the data can be serialized
data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder)) data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder))
async def mock_remove(store):
"""Remove data."""
data.pop(store.key, None)
with patch( with patch(
"homeassistant.helpers.storage.Store._async_load", "homeassistant.helpers.storage.Store._async_load",
side_effect=mock_async_load, side_effect=mock_async_load,
@ -1000,6 +1004,10 @@ def mock_storage(data=None):
"homeassistant.helpers.storage.Store._write_data", "homeassistant.helpers.storage.Store._write_data",
side_effect=mock_write_data, side_effect=mock_write_data,
autospec=True, autospec=True,
), patch(
"homeassistant.helpers.storage.Store.async_remove",
side_effect=mock_remove,
autospec=True,
): ):
yield data yield data

View File

@ -98,9 +98,7 @@ async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage):
await client.send_json({"id": 7, "type": "lovelace/config/delete"}) await client.send_json({"id": 7, "type": "lovelace/config/delete"})
response = await client.receive_json() response = await client.receive_json()
assert response["success"] assert response["success"]
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
"config": None
}
# Fetch data # Fetch data
await client.send_json({"id": 8, "type": "lovelace/config"}) await client.send_json({"id": 8, "type": "lovelace/config"})
@ -212,8 +210,9 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path):
"mode": "yaml", "mode": "yaml",
"filename": "bla.yaml", "filename": "bla.yaml",
"sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"}, "sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"},
"require_admin": True,
}, },
"test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla2.yaml"},
} }
} }
}, },
@ -225,6 +224,25 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path):
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
# List dashboards
await client.send_json({"id": 4, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 2
with_sb, without_sb = response["result"]
assert with_sb["mode"] == "yaml"
assert with_sb["filename"] == "bla.yaml"
assert with_sb["sidebar"] == {"title": "Test Panel", "icon": "mdi:test-icon"}
assert with_sb["require_admin"] is True
assert with_sb["url_path"] == "test-panel"
assert without_sb["mode"] == "yaml"
assert without_sb["filename"] == "bla2.yaml"
assert "sidebar" not in without_sb
assert without_sb["require_admin"] is False
assert without_sb["url_path"] == "test-panel-no-sidebar"
# Fetch data # Fetch data
await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path}) await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path})
response = await client.receive_json() response = await client.receive_json()
@ -275,3 +293,154 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path):
assert response["result"] == {"hello": "yo2"} assert response["result"] == {"hello": "yo2"}
assert len(events) == 1 assert len(events) == 1
async def test_storage_dashboards(hass, hass_ws_client, hass_storage):
"""Test we load lovelace config from storage."""
assert await async_setup_component(hass, "lovelace", {})
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"}
client = await hass_ws_client(hass)
# Fetch data
await client.send_json({"id": 5, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert response["result"] == []
# Add a dashboard
await client.send_json(
{
"id": 6,
"type": "lovelace/dashboards/create",
"url_path": "created_url_path",
"require_admin": True,
"sidebar": {"title": "Updated Title", "icon": "mdi:map"},
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["require_admin"] is True
assert response["result"]["sidebar"] == {
"title": "Updated Title",
"icon": "mdi:map",
}
dashboard_id = response["result"]["id"]
assert "created_url_path" in hass.data[frontend.DATA_PANELS]
await client.send_json({"id": 7, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 1
assert response["result"][0]["mode"] == "storage"
assert response["result"][0]["sidebar"] == {
"title": "Updated Title",
"icon": "mdi:map",
}
assert response["result"][0]["require_admin"] is True
# Fetch config
await client.send_json(
{"id": 8, "type": "lovelace/config", "url_path": "created_url_path"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "config_not_found"
# Store new config
events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED)
await client.send_json(
{
"id": 9,
"type": "lovelace/config/save",
"url_path": "created_url_path",
"config": {"yo": "hello"},
}
)
response = await client.receive_json()
assert response["success"]
assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == {
"config": {"yo": "hello"}
}
assert len(events) == 1
assert events[0].data["url_path"] == "created_url_path"
await client.send_json(
{"id": 10, "type": "lovelace/config", "url_path": "created_url_path"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {"yo": "hello"}
# Update a dashboard
await client.send_json(
{
"id": 11,
"type": "lovelace/dashboards/update",
"dashboard_id": dashboard_id,
"require_admin": False,
"sidebar": None,
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["require_admin"] is False
assert "sidebar" not in response["result"]
# Add dashboard with existing url path
await client.send_json(
{"id": 12, "type": "lovelace/dashboards/create", "url_path": "created_url_path"}
)
response = await client.receive_json()
assert not response["success"]
# Delete dashboards
await client.send_json(
{"id": 13, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id}
)
response = await client.receive_json()
assert response["success"]
assert "created_url_path" not in hass.data[frontend.DATA_PANELS]
assert dashboard.CONFIG_STORAGE_KEY.format(dashboard_id) not in hass_storage
async def test_websocket_list_dashboards(hass, hass_ws_client):
"""Test listing dashboards both storage + YAML."""
assert await async_setup_component(
hass,
"lovelace",
{
"lovelace": {
"dashboards": {
"test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"},
}
}
},
)
client = await hass_ws_client(hass)
# Create a storage dashboard
await client.send_json(
{"id": 6, "type": "lovelace/dashboards/create", "url_path": "created_url_path"}
)
response = await client.receive_json()
assert response["success"]
# List dashboards
await client.send_json({"id": 7, "type": "lovelace/dashboards/list"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 2
with_sb, without_sb = response["result"]
assert with_sb["mode"] == "yaml"
assert with_sb["filename"] == "bla.yaml"
assert with_sb["url_path"] == "test-panel-no-sidebar"
assert without_sb["mode"] == "storage"
assert without_sb["url_path"] == "created_url_path"

View File

@ -133,7 +133,11 @@ async def test_yaml_collection():
"mock-3", "mock-3",
{"id": "mock-3", "name": "Mock 3"}, {"id": "mock-3", "name": "Mock 3"},
) )
assert changes[4] == (collection.CHANGE_REMOVED, "mock-2", None,) assert changes[4] == (
collection.CHANGE_REMOVED,
"mock-2",
{"id": "mock-2", "name": "Mock 2"},
)
async def test_yaml_collection_skipping_duplicate_ids(): async def test_yaml_collection_skipping_duplicate_ids():
@ -370,4 +374,12 @@ async def test_storage_collection_websocket(hass, hass_ws_client):
assert response["success"] assert response["success"]
assert len(changes) == 3 assert len(changes) == 3
assert changes[2] == (collection.CHANGE_REMOVED, "initial_name", None) assert changes[2] == (
collection.CHANGE_REMOVED,
"initial_name",
{
"id": "initial_name",
"immutable_string": "no-changes",
"name": "Updated name",
},
)