Home Assistant Unofficial Reference 2024.12.1
view.py
Go to the documentation of this file.
1 """Component to configure Home Assistant via an API."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Coroutine
7 from http import HTTPStatus
8 import os
9 from typing import Any, cast
10 
11 from aiohttp import web
12 import voluptuous as vol
13 
14 from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
15 from homeassistant.const import CONF_ID
16 from homeassistant.core import HomeAssistant
17 from homeassistant.exceptions import HomeAssistantError
18 from homeassistant.util.file import write_utf8_file_atomic
19 from homeassistant.util.yaml import dump, load_yaml
20 from homeassistant.util.yaml.loader import JSON_TYPE
21 
22 from .const import ACTION_CREATE_UPDATE, ACTION_DELETE
23 
24 
25 class BaseEditConfigView[_DataT: (dict[str, dict[str, Any]], list[dict[str, Any]])](
26  HomeAssistantView
27 ):
28  """Configure a Group endpoint."""
29 
30  def __init__(
31  self,
32  component: str,
33  config_type: str,
34  path: str,
35  key_schema: Callable[[Any], str],
36  *,
37  post_write_hook: Callable[[str, str], Coroutine[Any, Any, None]] | None = None,
38  data_schema: Callable[[dict[str, Any]], Any] | None = None,
39  data_validator: Callable[
40  [HomeAssistant, str, dict[str, Any]],
41  Coroutine[Any, Any, dict[str, Any] | None],
42  ]
43  | None = None,
44  ) -> None:
45  """Initialize a config view."""
46  self.url = f"/api/config/{component}/{config_type}/{{config_key}}"
47  self.name = f"api:config:{component}:{config_type}"
48  self.path = path
49  self.key_schema = key_schema
50  self.data_schema = data_schema
51  self.post_write_hook = post_write_hook
52  self.data_validator = data_validator
53  self.mutation_lock = asyncio.Lock()
54  if (self.data_schema is None and self.data_validator is None) or (
55  self.data_schema is not None and self.data_validator is not None
56  ):
57  raise ValueError(
58  "Must specify exactly one of data_schema or data_validator"
59  )
60 
61  def _empty_config(self) -> _DataT:
62  """Empty config if file not found."""
63  raise NotImplementedError
64 
66  self, hass: HomeAssistant, data: _DataT, config_key: str
67  ) -> dict[str, Any] | None:
68  """Get value."""
69  raise NotImplementedError
70 
72  self,
73  hass: HomeAssistant,
74  data: _DataT,
75  config_key: str,
76  new_value: dict[str, Any],
77  ) -> None:
78  """Set value."""
79  raise NotImplementedError
80 
82  self, hass: HomeAssistant, data: _DataT, config_key: str
83  ) -> dict[str, Any] | None:
84  """Delete value."""
85  raise NotImplementedError
86 
87  @require_admin
88  async def get(self, request: web.Request, config_key: str) -> web.Response:
89  """Fetch device specific config."""
90  hass = request.app[KEY_HASS]
91  async with self.mutation_lock:
92  current = await self.read_config(hass)
93  value = self._get_value(hass, current, config_key)
94 
95  if value is None:
96  return self.json_message("Resource not found", HTTPStatus.NOT_FOUND)
97 
98  return self.json(value)
99 
100  @require_admin
101  async def post(self, request: web.Request, config_key: str) -> web.Response:
102  """Validate config and return results."""
103  try:
104  data = await request.json()
105  except ValueError:
106  return self.json_message("Invalid JSON specified", HTTPStatus.BAD_REQUEST)
107 
108  try:
109  self.key_schema(config_key)
110  except vol.Invalid as err:
111  return self.json_message(f"Key malformed: {err}", HTTPStatus.BAD_REQUEST)
112 
113  hass = request.app[KEY_HASS]
114 
115  try:
116  # We just validate, we don't store that data because
117  # we don't want to store the defaults.
118  if self.data_validator:
119  await self.data_validator(hass, config_key, data)
120  else:
121  # We either have a data_schema or a data_validator, ignore mypy
122  self.data_schema(data) # type: ignore[misc]
123  except (vol.Invalid, HomeAssistantError) as err:
124  return self.json_message(
125  f"Message malformed: {err}", HTTPStatus.BAD_REQUEST
126  )
127 
128  path = hass.config.path(self.path)
129 
130  async with self.mutation_lock:
131  current = await self.read_config(hass)
132  self._write_value(hass, current, config_key, data)
133 
134  await hass.async_add_executor_job(_write, path, current)
135 
136  if self.post_write_hook is not None:
137  hass.async_create_task(
138  self.post_write_hook(ACTION_CREATE_UPDATE, config_key)
139  )
140 
141  return self.json({"result": "ok"})
142 
143  @require_admin
144  async def delete(self, request: web.Request, config_key: str) -> web.Response:
145  """Remove an entry."""
146  hass = request.app[KEY_HASS]
147  async with self.mutation_lock:
148  current = await self.read_config(hass)
149  value = self._get_value(hass, current, config_key)
150  path = hass.config.path(self.path)
151 
152  if value is None:
153  return self.json_message("Resource not found", HTTPStatus.BAD_REQUEST)
154 
155  self._delete_value(hass, current, config_key)
156  await hass.async_add_executor_job(_write, path, current)
157 
158  if self.post_write_hook is not None:
159  hass.async_create_task(self.post_write_hook(ACTION_DELETE, config_key))
160 
161  return self.json({"result": "ok"})
162 
163  async def read_config(self, hass: HomeAssistant) -> _DataT:
164  """Read the config."""
165  current = await hass.async_add_executor_job(_read, hass.config.path(self.path))
166  if not current:
167  current = self._empty_config()
168  return cast(_DataT, current)
169 
170 
171 class EditKeyBasedConfigView(BaseEditConfigView[dict[str, dict[str, Any]]]):
172  """Configure a list of entries."""
173 
174  def _empty_config(self) -> dict[str, Any]:
175  """Return an empty config."""
176  return {}
177 
179  self, hass: HomeAssistant, data: dict[str, dict[str, Any]], config_key: str
180  ) -> dict[str, Any] | None:
181  """Get value."""
182  return data.get(config_key)
183 
185  self,
186  hass: HomeAssistant,
187  data: dict[str, dict[str, Any]],
188  config_key: str,
189  new_value: dict[str, Any],
190  ) -> None:
191  """Set value."""
192  data.setdefault(config_key, {}).update(new_value)
193 
195  self, hass: HomeAssistant, data: dict[str, dict[str, Any]], config_key: str
196  ) -> dict[str, Any]:
197  """Delete value."""
198  return data.pop(config_key)
199 
200 
201 class EditIdBasedConfigView(BaseEditConfigView[list[dict[str, Any]]]):
202  """Configure key based config entries."""
203 
204  def _empty_config(self) -> list[Any]:
205  """Return an empty config."""
206  return []
207 
209  self, hass: HomeAssistant, data: list[dict[str, Any]], config_key: str
210  ) -> dict[str, Any] | None:
211  """Get value."""
212  return next((val for val in data if val.get(CONF_ID) == config_key), None)
213 
215  self,
216  hass: HomeAssistant,
217  data: list[dict[str, Any]],
218  config_key: str,
219  new_value: dict[str, Any],
220  ) -> None:
221  """Set value."""
222  if (value := self._get_value_get_value(hass, data, config_key)) is None:
223  value = {CONF_ID: config_key}
224  data.append(value)
225 
226  value.update(new_value)
227 
229  self, hass: HomeAssistant, data: list[dict[str, Any]], config_key: str
230  ) -> None:
231  """Delete value."""
232  index = next(
233  idx for idx, val in enumerate(data) if val.get(CONF_ID) == config_key
234  )
235  data.pop(index)
236 
237 
238 def _read(path: str) -> JSON_TYPE | None:
239  """Read YAML helper."""
240  if not os.path.isfile(path):
241  return None
242 
243  return load_yaml(path)
244 
245 
246 def _write(path: str, data: dict | list) -> None:
247  """Write YAML helper."""
248  # Do it before opening file. If dump causes error it will now not
249  # truncate the file.
250  contents = dump(data)
251  write_utf8_file_atomic(path, contents)
None _delete_value(self, HomeAssistant hass, list[dict[str, Any]] data, str config_key)
Definition: view.py:230
dict[str, Any]|None _get_value(self, HomeAssistant hass, list[dict[str, Any]] data, str config_key)
Definition: view.py:210
None _write_value(self, HomeAssistant hass, list[dict[str, Any]] data, str config_key, dict[str, Any] new_value)
Definition: view.py:220
dict[str, Any]|None _get_value(self, HomeAssistant hass, dict[str, dict[str, Any]] data, str config_key)
Definition: view.py:180
dict[str, Any] _delete_value(self, HomeAssistant hass, dict[str, dict[str, Any]] data, str config_key)
Definition: view.py:196
None _write_value(self, HomeAssistant hass, dict[str, dict[str, Any]] data, str config_key, dict[str, Any] new_value)
Definition: view.py:190
None __init__(self, str component, str config_type, str path, Callable[[Any], str] key_schema, *Callable[[str, str], Coroutine[Any, Any, None]]|None post_write_hook=None, Callable[[dict[str, Any]], Any]|None data_schema=None, Callable[[HomeAssistant, str, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]|None],]|None data_validator=None)
Definition: view.py:44
dict[str, Any]|None _delete_value(self, HomeAssistant hass, _DataT data, str config_key)
Definition: view.py:83
dict[str, Any]|None _get_value(self, HomeAssistant hass, _DataT data, str config_key)
Definition: view.py:67
web.Response post(self, web.Request request, str config_key)
Definition: view.py:101
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
web.Response delete(self, web.Request request, str config_key)
Definition: view.py:144
None _write_value(self, HomeAssistant hass, _DataT data, str config_key, dict[str, Any] new_value)
Definition: view.py:77
None _write(str path, dict|list data)
Definition: view.py:246
JSON_TYPE|None _read(str path)
Definition: view.py:238
_DataT read_config(self, HomeAssistant hass)
Definition: view.py:163
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None write_utf8_file_atomic(str filename, bytes|str utf8_data, bool private=False, str mode="w")
Definition: file.py:22
str dump(dict|list _dict)
Definition: dumper.py:21
JSON_TYPE|None load_yaml(str|os.PathLike[str] fname, Secrets|None secrets=None)
Definition: loader.py:160