Home Assistant Unofficial Reference 2024.12.1
dashboard.py
Go to the documentation of this file.
1 """Lovelace dashboard support."""
2 
3 from __future__ import annotations
4 
5 from abc import ABC, abstractmethod
6 import logging
7 import os
8 from pathlib import Path
9 import time
10 from typing import Any
11 
12 import voluptuous as vol
13 
14 from homeassistant.components import websocket_api
15 from homeassistant.components.frontend import DATA_PANELS
16 from homeassistant.const import CONF_FILENAME
17 from homeassistant.core import HomeAssistant, callback
18 from homeassistant.exceptions import HomeAssistantError
19 from homeassistant.helpers import collection, storage
20 from homeassistant.helpers.json import json_bytes, json_fragment
21 from homeassistant.util.yaml import Secrets, load_yaml_dict
22 
23 from .const import (
24  CONF_ALLOW_SINGLE_WORD,
25  CONF_ICON,
26  CONF_URL_PATH,
27  DOMAIN,
28  EVENT_LOVELACE_UPDATED,
29  LOVELACE_CONFIG_FILE,
30  MODE_STORAGE,
31  MODE_YAML,
32  STORAGE_DASHBOARD_CREATE_FIELDS,
33  STORAGE_DASHBOARD_UPDATE_FIELDS,
34  ConfigNotFound,
35 )
36 
37 CONFIG_STORAGE_KEY_DEFAULT = DOMAIN
38 CONFIG_STORAGE_KEY = "lovelace.{}"
39 CONFIG_STORAGE_VERSION = 1
40 DASHBOARDS_STORAGE_KEY = f"{DOMAIN}_dashboards"
41 DASHBOARDS_STORAGE_VERSION = 1
42 _LOGGER = logging.getLogger(__name__)
43 
44 
45 class LovelaceConfig(ABC):
46  """Base class for Lovelace config."""
47 
48  def __init__(
49  self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None
50  ) -> None:
51  """Initialize Lovelace config."""
52  self.hasshass = hass
53  if config:
54  self.configconfig: dict[str, Any] | None = {**config, CONF_URL_PATH: url_path}
55  else:
56  self.configconfig = None
57 
58  @property
59  def url_path(self) -> str | None:
60  """Return url path."""
61  return self.configconfig[CONF_URL_PATH] if self.configconfig else None
62 
63  @property
64  @abstractmethod
65  def mode(self) -> str:
66  """Return mode of the lovelace config."""
67 
68  @abstractmethod
69  async def async_get_info(self):
70  """Return the config info."""
71 
72  @abstractmethod
73  async def async_load(self, force: bool) -> dict[str, Any]:
74  """Load config."""
75 
76  async def async_save(self, config):
77  """Save config."""
78  raise HomeAssistantError("Not supported")
79 
80  async def async_delete(self):
81  """Delete config."""
82  raise HomeAssistantError("Not supported")
83 
84  @callback
85  def _config_updated(self) -> None:
86  """Fire config updated event."""
87  self.hasshass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_pathurl_path})
88 
89 
91  """Class to handle Storage based Lovelace config."""
92 
93  def __init__(self, hass: HomeAssistant, config: dict[str, Any] | None) -> None:
94  """Initialize Lovelace config based on storage helper."""
95  if config is None:
96  url_path: str | None = None
97  storage_key = CONFIG_STORAGE_KEY_DEFAULT
98  else:
99  url_path = config[CONF_URL_PATH]
100  storage_key = CONFIG_STORAGE_KEY.format(config["id"])
101 
102  super().__init__(hass, url_path, config)
103 
104  self._store_store = storage.Store[dict[str, Any]](
105  hass, CONFIG_STORAGE_VERSION, storage_key
106  )
107  self._data_data: dict[str, Any] | None = None
108  self._json_config_json_config: json_fragment | None = None
109 
110  @property
111  def mode(self) -> str:
112  """Return mode of the lovelace config."""
113  return MODE_STORAGE
114 
115  async def async_get_info(self):
116  """Return the Lovelace storage info."""
117  data = self._data_data or await self._load_load()
118  if data["config"] is None:
119  return {"mode": "auto-gen"}
120  return _config_info(self.modemodemode, data["config"])
121 
122  async def async_load(self, force: bool) -> dict[str, Any]:
123  """Load config."""
124  if self.hasshass.config.recovery_mode:
125  raise ConfigNotFound
126 
127  data = self._data_data or await self._load_load()
128  if (config := data["config"]) is None:
129  raise ConfigNotFound
130 
131  return config
132 
133  async def async_json(self, force: bool) -> json_fragment:
134  """Return JSON representation of the config."""
135  if self.hasshass.config.recovery_mode:
136  raise ConfigNotFound
137  if self._data_data is None:
138  await self._load_load()
139  return self._json_config_json_config or self._async_build_json_async_build_json()
140 
141  async def async_save(self, config):
142  """Save config."""
143  if self.hasshass.config.recovery_mode:
144  raise HomeAssistantError("Saving not supported in recovery mode")
145 
146  if self._data_data is None:
147  await self._load_load()
148  self._data_data["config"] = config
149  self._json_config_json_config = None
150  self._config_updated_config_updated()
151  await self._store_store.async_save(self._data_data)
152 
153  async def async_delete(self):
154  """Delete config."""
155  if self.hasshass.config.recovery_mode:
156  raise HomeAssistantError("Deleting not supported in recovery mode")
157 
158  await self._store_store.async_remove()
159  self._data_data = None
160  self._json_config_json_config = None
161  self._config_updated_config_updated()
162 
163  async def _load(self) -> dict[str, Any]:
164  """Load the config."""
165  data = await self._store_store.async_load()
166  self._data_data = data if data else {"config": None}
167  return self._data_data
168 
169  @callback
170  def _async_build_json(self) -> json_fragment:
171  """Build JSON representation of the config."""
172  if self._data_data is None or self._data_data["config"] is None:
173  raise ConfigNotFound
174  self._json_config_json_config = json_fragment(json_bytes(self._data_data["config"]))
175  return self._json_config_json_config
176 
177 
179  """Class to handle YAML-based Lovelace config."""
180 
181  def __init__(
182  self, hass: HomeAssistant, url_path: str | None, config: dict[str, Any] | None
183  ) -> None:
184  """Initialize the YAML config."""
185  super().__init__(hass, url_path, config)
186 
187  self.pathpath = hass.config.path(
188  config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE
189  )
190  self._cache_cache: tuple[dict[str, Any], float, json_fragment] | None = None
191 
192  @property
193  def mode(self) -> str:
194  """Return mode of the lovelace config."""
195  return MODE_YAML
196 
197  async def async_get_info(self):
198  """Return the YAML storage mode."""
199  try:
200  config = await self.async_loadasync_loadasync_load(False)
201  except ConfigNotFound:
202  return {
203  "mode": self.modemodemode,
204  "error": f"{self.path} not found",
205  }
206 
207  return _config_info(self.modemodemode, config)
208 
209  async def async_load(self, force: bool) -> dict[str, Any]:
210  """Load config."""
211  config, json = await self._async_load_or_cached_async_load_or_cached(force)
212  return config
213 
214  async def async_json(self, force: bool) -> json_fragment:
215  """Return JSON representation of the config."""
216  config, json = await self._async_load_or_cached_async_load_or_cached(force)
217  return json
218 
220  self, force: bool
221  ) -> tuple[dict[str, Any], json_fragment]:
222  """Load the config or return a cached version."""
223  is_updated, config, json = await self.hasshass.async_add_executor_job(
224  self._load_config_load_config, force
225  )
226  if is_updated:
227  self._config_updated_config_updated()
228  return config, json
229 
230  def _load_config(self, force: bool) -> tuple[bool, dict[str, Any], json_fragment]:
231  """Load the actual config."""
232  # Check for a cached version of the config
233  if not force and self._cache_cache is not None:
234  config, last_update, json = self._cache_cache
235  modtime = os.path.getmtime(self.pathpath)
236  if config and last_update > modtime:
237  return False, config, json
238 
239  is_updated = self._cache_cache is not None
240 
241  try:
242  config = load_yaml_dict(
243  self.pathpath, Secrets(Path(self.hasshass.config.config_dir))
244  )
245  except FileNotFoundError:
246  raise ConfigNotFound from None
247 
248  json = json_fragment(json_bytes(config))
249  self._cache_cache = (config, time.time(), json)
250  return is_updated, config, json
251 
252 
253 def _config_info(mode, config):
254  """Generate info about the config."""
255  return {
256  "mode": mode,
257  "views": len(config.get("views", [])),
258  }
259 
260 
261 class DashboardsCollection(collection.DictStorageCollection):
262  """Collection of dashboards."""
263 
264  CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS)
265  UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS)
266 
267  def __init__(self, hass):
268  """Initialize the dashboards collection."""
269  super().__init__(
270  storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY),
271  )
272 
273  async def _process_create_data(self, data: dict) -> dict:
274  """Validate the config is valid."""
275  url_path = data[CONF_URL_PATH]
276 
277  allow_single_word = data.pop(CONF_ALLOW_SINGLE_WORD, False)
278 
279  if not allow_single_word and "-" not in url_path:
280  raise vol.Invalid("Url path needs to contain a hyphen (-)")
281 
282  if url_path in self.hass.data[DATA_PANELS]:
283  raise vol.Invalid("Panel url path needs to be unique")
284 
285  return self.CREATE_SCHEMACREATE_SCHEMA(data)
286 
287  @callback
288  def _get_suggested_id(self, info: dict) -> str:
289  """Suggest an ID based on the config."""
290  return info[CONF_URL_PATH]
291 
292  async def _update_data(self, item: dict, update_data: dict) -> dict:
293  """Return a new updated data object."""
294  update_data = self.UPDATE_SCHEMAUPDATE_SCHEMA(update_data)
295  updated = {**item, **update_data}
296 
297  if CONF_ICON in updated and updated[CONF_ICON] is None:
298  updated.pop(CONF_ICON)
299 
300  return updated
301 
302 
303 class DashboardsCollectionWebSocket(collection.DictStorageCollectionWebsocket):
304  """Class to expose storage collection management over websocket."""
305 
306  @callback
308  self,
309  hass: HomeAssistant,
310  connection: websocket_api.ActiveConnection,
311  msg: dict[str, Any],
312  ) -> None:
313  """Send Lovelace UI resources over WebSocket connection."""
314  connection.send_result(
315  msg["id"],
316  [
317  dashboard.config
318  for dashboard in hass.data[DOMAIN]["dashboards"].values()
319  if dashboard.config
320  ],
321  )
None ws_list_item(self, HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
Definition: dashboard.py:312
dict _update_data(self, dict item, dict update_data)
Definition: dashboard.py:292
None __init__(self, HomeAssistant hass, str|None url_path, dict[str, Any]|None config)
Definition: dashboard.py:50
None __init__(self, HomeAssistant hass, dict[str, Any]|None config)
Definition: dashboard.py:93
tuple[bool, dict[str, Any], json_fragment] _load_config(self, bool force)
Definition: dashboard.py:230
tuple[dict[str, Any], json_fragment] _async_load_or_cached(self, bool force)
Definition: dashboard.py:221
None __init__(self, HomeAssistant hass, str|None url_path, dict[str, Any]|None config)
Definition: dashboard.py:183
None async_remove(HomeAssistant hass, str intent_type)
Definition: intent.py:90
dict load_yaml_dict(str|os.PathLike[str] fname, Secrets|None secrets=None)
Definition: loader.py:180