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 dashboardspull/32289/head^2
parent
ede39454a2
commit
deda2f86e7
|
@ -171,6 +171,8 @@ def async_register_built_in_panel(
|
|||
frontend_url_path=None,
|
||||
config=None,
|
||||
require_admin=False,
|
||||
*,
|
||||
update=False,
|
||||
):
|
||||
"""Register a built-in panel."""
|
||||
panel = Panel(
|
||||
|
@ -184,7 +186,7 @@ def async_register_built_in_panel(
|
|||
|
||||
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}")
|
||||
|
||||
panels[panel.frontend_url_path] = panel
|
||||
|
|
|
@ -1,65 +1,48 @@
|
|||
"""Support for the Lovelace UI."""
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
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.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 .const import (
|
||||
CONF_ICON,
|
||||
CONF_MODE,
|
||||
CONF_REQUIRE_ADMIN,
|
||||
CONF_RESOURCES,
|
||||
CONF_SIDEBAR,
|
||||
CONF_TITLE,
|
||||
CONF_URL_PATH,
|
||||
DASHBOARD_BASE_CREATE_FIELDS,
|
||||
DOMAIN,
|
||||
LOVELACE_CONFIG_FILE,
|
||||
MODE_STORAGE,
|
||||
MODE_YAML,
|
||||
RESOURCE_CREATE_FIELDS,
|
||||
RESOURCE_SCHEMA,
|
||||
RESOURCE_UPDATE_FIELDS,
|
||||
STORAGE_DASHBOARD_CREATE_FIELDS,
|
||||
STORAGE_DASHBOARD_UPDATE_FIELDS,
|
||||
url_slug,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_MODE = "mode"
|
||||
|
||||
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(
|
||||
YAML_DASHBOARD_SCHEMA = vol.Schema(
|
||||
{
|
||||
**DASHBOARD_BASE_CREATE_FIELDS,
|
||||
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(
|
||||
{
|
||||
vol.Optional(DOMAIN, default={}): vol.Schema(
|
||||
|
@ -80,14 +63,13 @@ CONFIG_SCHEMA = vol.Schema(
|
|||
|
||||
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[DOMAIN][CONF_MODE]
|
||||
yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
|
||||
|
||||
frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode})
|
||||
|
||||
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:
|
||||
try:
|
||||
|
@ -134,6 +116,10 @@ async def async_setup(hass, config):
|
|||
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.data[DOMAIN] = {
|
||||
|
@ -142,34 +128,87 @@ async def async_setup(hass, config):
|
|||
"resources": resource_collection,
|
||||
}
|
||||
|
||||
if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]:
|
||||
if hass.config.safe_mode:
|
||||
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
|
||||
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
|
||||
|
||||
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)
|
||||
_register_panel(hass, url_path, MODE_YAML, dashboard_conf, False)
|
||||
except ValueError:
|
||||
_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
|
||||
|
||||
|
||||
async def system_health_info(hass):
|
||||
"""Get info for the info page."""
|
||||
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)
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
"""Constants for Lovelace."""
|
||||
from typing import Any
|
||||
|
||||
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.helpers import config_validation as cv
|
||||
from homeassistant.util import slugify
|
||||
|
||||
DOMAIN = "lovelace"
|
||||
EVENT_LOVELACE_UPDATED = "lovelace_updated"
|
||||
|
||||
CONF_MODE = "mode"
|
||||
MODE_YAML = "yaml"
|
||||
MODE_STORAGE = "storage"
|
||||
|
||||
|
@ -35,6 +39,50 @@ RESOURCE_UPDATE_FIELDS = {
|
|||
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):
|
||||
"""When no config available."""
|
||||
|
|
|
@ -1,32 +1,53 @@
|
|||
"""Lovelace dashboard support."""
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_FILENAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import storage
|
||||
from homeassistant.helpers import collection, storage
|
||||
from homeassistant.util.yaml import load_yaml
|
||||
|
||||
from .const import (
|
||||
CONF_SIDEBAR,
|
||||
CONF_URL_PATH,
|
||||
DOMAIN,
|
||||
EVENT_LOVELACE_UPDATED,
|
||||
LOVELACE_CONFIG_FILE,
|
||||
MODE_STORAGE,
|
||||
MODE_YAML,
|
||||
STORAGE_DASHBOARD_CREATE_FIELDS,
|
||||
STORAGE_DASHBOARD_UPDATE_FIELDS,
|
||||
ConfigNotFound,
|
||||
)
|
||||
|
||||
CONFIG_STORAGE_KEY_DEFAULT = DOMAIN
|
||||
CONFIG_STORAGE_KEY = "lovelace.{}"
|
||||
CONFIG_STORAGE_VERSION = 1
|
||||
DASHBOARDS_STORAGE_KEY = f"{DOMAIN}_dashboards"
|
||||
DASHBOARDS_STORAGE_VERSION = 1
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LovelaceConfig(ABC):
|
||||
"""Base class for Lovelace config."""
|
||||
|
||||
def __init__(self, hass, url_path):
|
||||
def __init__(self, hass, url_path, config):
|
||||
"""Initialize Lovelace config."""
|
||||
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
|
||||
@abstractmethod
|
||||
|
@ -58,13 +79,16 @@ class LovelaceConfig(ABC):
|
|||
class LovelaceStorage(LovelaceConfig):
|
||||
"""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."""
|
||||
super().__init__(hass, url_path)
|
||||
if url_path is None:
|
||||
if config is None:
|
||||
url_path = None
|
||||
storage_key = CONFIG_STORAGE_KEY_DEFAULT
|
||||
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._data = None
|
||||
|
@ -115,7 +139,9 @@ class LovelaceStorage(LovelaceConfig):
|
|||
if self.hass.config.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):
|
||||
"""Load the config."""
|
||||
|
@ -126,10 +152,13 @@ class LovelaceStorage(LovelaceConfig):
|
|||
class LovelaceYAML(LovelaceConfig):
|
||||
"""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."""
|
||||
super().__init__(hass, url_path)
|
||||
self.path = hass.config.path(path)
|
||||
super().__init__(hass, url_path, config)
|
||||
|
||||
self.path = hass.config.path(
|
||||
config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
|
||||
)
|
||||
self._cache = None
|
||||
|
||||
@property
|
||||
|
@ -185,3 +214,39 @@ def _config_info(mode, config):
|
|||
"resources": len(config.get("resources", [])),
|
||||
"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
|
||||
|
|
|
@ -4,6 +4,7 @@ from functools import wraps
|
|||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
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):
|
||||
"""Delete Lovelace UI configuration."""
|
||||
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
|
||||
],
|
||||
)
|
||||
|
|
|
@ -189,9 +189,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
|
|||
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
|
||||
).async_setup(hass)
|
||||
|
||||
async def _collection_changed(
|
||||
change_type: str, item_id: str, config: Optional[Dict]
|
||||
) -> None:
|
||||
async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None:
|
||||
"""Handle a collection change: clean up entity registry on removals."""
|
||||
if change_type != collection.CHANGE_REMOVED:
|
||||
return
|
||||
|
|
|
@ -31,8 +31,8 @@ ChangeListener = Callable[
|
|||
str,
|
||||
# Item ID
|
||||
str,
|
||||
# New config (None if removed)
|
||||
Optional[dict],
|
||||
# New or removed config
|
||||
dict,
|
||||
],
|
||||
Awaitable[None],
|
||||
]
|
||||
|
@ -104,9 +104,7 @@ class ObservableCollection(ABC):
|
|||
"""
|
||||
self.listeners.append(listener)
|
||||
|
||||
async def notify_change(
|
||||
self, change_type: str, item_id: str, item: Optional[dict]
|
||||
) -> None:
|
||||
async def notify_change(self, change_type: str, item_id: str, item: dict) -> None:
|
||||
"""Notify listeners of a change."""
|
||||
self.logger.debug("%s %s: %s", change_type, item_id, item)
|
||||
for listener in self.listeners:
|
||||
|
@ -136,8 +134,8 @@ class YamlCollection(ObservableCollection):
|
|||
await self.notify_change(event, item_id, item)
|
||||
|
||||
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):
|
||||
|
@ -219,10 +217,10 @@ class StorageCollection(ObservableCollection):
|
|||
if item_id not in self.data:
|
||||
raise ItemNotFound(item_id)
|
||||
|
||||
self.data.pop(item_id)
|
||||
item = self.data.pop(item_id)
|
||||
self._async_schedule_save()
|
||||
|
||||
await self.notify_change(CHANGE_REMOVED, item_id, None)
|
||||
await self.notify_change(CHANGE_REMOVED, item_id, item)
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
|
@ -242,8 +240,8 @@ class IDLessCollection(ObservableCollection):
|
|||
|
||||
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)
|
||||
for item_id, item in list(self.data.items()):
|
||||
await self.notify_change(CHANGE_REMOVED, item_id, item)
|
||||
|
||||
self.data.clear()
|
||||
|
||||
|
@ -264,12 +262,10 @@ def attach_entity_component_collection(
|
|||
"""Map a collection to an entity component."""
|
||||
entities = {}
|
||||
|
||||
async def _collection_changed(
|
||||
change_type: str, item_id: str, config: Optional[dict]
|
||||
) -> None:
|
||||
async def _collection_changed(change_type: str, item_id: str, config: dict) -> None:
|
||||
"""Handle a collection change."""
|
||||
if change_type == CHANGE_ADDED:
|
||||
entity = create_entity(cast(dict, config))
|
||||
entity = create_entity(config)
|
||||
await entity_component.async_add_entities([entity]) # type: ignore
|
||||
entities[item_id] = entity
|
||||
return
|
||||
|
@ -294,9 +290,7 @@ def attach_entity_registry_cleaner(
|
|||
) -> None:
|
||||
"""Attach a listener to clean up entity registry on collection changes."""
|
||||
|
||||
async def _collection_changed(
|
||||
change_type: str, item_id: str, config: Optional[Dict]
|
||||
) -> None:
|
||||
async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None:
|
||||
"""Handle a collection change: clean up entity registry on removals."""
|
||||
if change_type != CHANGE_REMOVED:
|
||||
return
|
||||
|
|
|
@ -210,3 +210,10 @@ class Store:
|
|||
async def _async_migrate_func(self, old_version, old_data):
|
||||
"""Migrate to the new version."""
|
||||
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
|
||||
|
|
|
@ -992,6 +992,10 @@ def mock_storage(data=None):
|
|||
# To ensure that the data can be serialized
|
||||
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(
|
||||
"homeassistant.helpers.storage.Store._async_load",
|
||||
side_effect=mock_async_load,
|
||||
|
@ -1000,6 +1004,10 @@ def mock_storage(data=None):
|
|||
"homeassistant.helpers.storage.Store._write_data",
|
||||
side_effect=mock_write_data,
|
||||
autospec=True,
|
||||
), patch(
|
||||
"homeassistant.helpers.storage.Store.async_remove",
|
||||
side_effect=mock_remove,
|
||||
autospec=True,
|
||||
):
|
||||
yield data
|
||||
|
||||
|
|
|
@ -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"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == {
|
||||
"config": None
|
||||
}
|
||||
assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage
|
||||
|
||||
# Fetch data
|
||||
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",
|
||||
"filename": "bla.yaml",
|
||||
"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)
|
||||
|
||||
# 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
|
||||
await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path})
|
||||
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 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"
|
||||
|
|
|
@ -133,7 +133,11 @@ async def test_yaml_collection():
|
|||
"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():
|
||||
|
@ -370,4 +374,12 @@ async def test_storage_collection_websocket(hass, hass_ws_client):
|
|||
assert response["success"]
|
||||
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue