mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
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:
parent
ede39454a2
commit
deda2f86e7
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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."""
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user