Home Assistant Unofficial Reference 2024.12.1
exposed_entities.py
Go to the documentation of this file.
1 """Control which entities are exposed to voice assistants."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Mapping
6 import dataclasses
7 from itertools import chain
8 from typing import Any, TypedDict
9 
10 import voluptuous as vol
11 
12 from homeassistant.components import websocket_api
13 from homeassistant.components.binary_sensor import BinarySensorDeviceClass
14 from homeassistant.components.sensor import SensorDeviceClass
15 from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
16 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
17 from homeassistant.exceptions import HomeAssistantError
18 from homeassistant.helpers import entity_registry as er
19 from homeassistant.helpers.entity import get_device_class
20 from homeassistant.helpers.storage import Store
21 from homeassistant.util.read_only_dict import ReadOnlyDict
22 
23 from .const import DATA_EXPOSED_ENTITIES, DOMAIN
24 
25 KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant", "conversation")
26 
27 STORAGE_KEY = f"{DOMAIN}.exposed_entities"
28 STORAGE_VERSION = 1
29 
30 SAVE_DELAY = 10
31 
32 DEFAULT_EXPOSED_DOMAINS = {
33  "climate",
34  "cover",
35  "fan",
36  "humidifier",
37  "light",
38  "media_player",
39  "scene",
40  "switch",
41  "todo",
42  "vacuum",
43  "water_heater",
44 }
45 
46 DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = {
47  BinarySensorDeviceClass.DOOR,
48  BinarySensorDeviceClass.GARAGE_DOOR,
49  BinarySensorDeviceClass.LOCK,
50  BinarySensorDeviceClass.MOTION,
51  BinarySensorDeviceClass.OPENING,
52  BinarySensorDeviceClass.PRESENCE,
53  BinarySensorDeviceClass.WINDOW,
54 }
55 
56 DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = {
57  SensorDeviceClass.AQI,
58  SensorDeviceClass.CO,
59  SensorDeviceClass.CO2,
60  SensorDeviceClass.HUMIDITY,
61  SensorDeviceClass.PM10,
62  SensorDeviceClass.PM25,
63  SensorDeviceClass.TEMPERATURE,
64  SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
65 }
66 
67 DEFAULT_EXPOSED_ASSISTANT = {
68  "conversation": True,
69 }
70 
71 
72 @dataclasses.dataclass(frozen=True)
74  """Preferences for an assistant."""
75 
76  expose_new: bool
77 
78  def to_json(self) -> dict[str, Any]:
79  """Return a JSON serializable representation for storage."""
80  return {"expose_new": self.expose_new}
81 
82 
83 @dataclasses.dataclass(frozen=True)
85  """An exposed entity without a unique_id."""
86 
87  assistants: dict[str, dict[str, Any]]
88 
89  def to_json(self) -> dict[str, Any]:
90  """Return a JSON serializable representation for storage."""
91  return {
92  "assistants": self.assistants,
93  }
94 
95 
96 class SerializedExposedEntities(TypedDict):
97  """Serialized exposed entities storage storage collection."""
98 
99  assistants: dict[str, dict[str, Any]]
100  exposed_entities: dict[str, dict[str, Any]]
101 
102 
104  """Control assistant settings.
105 
106  Settings for entities without a unique_id are stored in the store.
107  Settings for entities with a unique_id are stored in the entity registry.
108  """
109 
110  _assistants: dict[str, AssistantPreferences]
111  entities: dict[str, ExposedEntity]
112 
113  def __init__(self, hass: HomeAssistant) -> None:
114  """Initialize."""
115  self._hass_hass = hass
116  self._listeners: dict[str, list[Callable[[], None]]] = {}
117  self._store: Store[SerializedExposedEntities] = Store(
118  hass, STORAGE_VERSION, STORAGE_KEY
119  )
120 
121  async def async_initialize(self) -> None:
122  """Finish initializing."""
123  websocket_api.async_register_command(self._hass_hass, ws_expose_entity)
124  websocket_api.async_register_command(self._hass_hass, ws_expose_new_entities_get)
125  websocket_api.async_register_command(self._hass_hass, ws_expose_new_entities_set)
126  websocket_api.async_register_command(self._hass_hass, ws_list_exposed_entities)
127  await self._async_load_data_async_load_data()
128 
129  @callback
131  self, assistant: str, listener: Callable[[], None]
132  ) -> CALLBACK_TYPE:
133  """Listen for updates to entity expose settings."""
134 
135  def unsubscribe() -> None:
136  """Stop listening to entity updates."""
137  self._listeners[assistant].remove(listener)
138 
139  self._listeners.setdefault(assistant, []).append(listener)
140 
141  return unsubscribe
142 
143  @callback
145  self, assistant: str, entity_id: str, key: str, value: Any
146  ) -> None:
147  """Set an option for an assistant.
148 
149  Notify listeners if expose flag was changed.
150  """
151  entity_registry = er.async_get(self._hass_hass)
152  if not (registry_entry := entity_registry.async_get(entity_id)):
153  self._async_set_legacy_assistant_option_async_set_legacy_assistant_option(assistant, entity_id, key, value)
154  return
155 
156  assistant_options: ReadOnlyDict[str, Any] | dict[str, Any]
157  if (
158  assistant_options := registry_entry.options.get(assistant, {})
159  ) and assistant_options.get(key) == value:
160  return
161 
162  assistant_options = assistant_options | {key: value}
163  entity_registry.async_update_entity_options(
164  entity_id, assistant, assistant_options
165  )
166  for listener in self._listeners.get(assistant, []):
167  listener()
168 
170  self, assistant: str, entity_id: str, key: str, value: Any
171  ) -> None:
172  """Set an option for an assistant.
173 
174  Notify listeners if expose flag was changed.
175  """
176  if (
177  (exposed_entity := self.entitiesentities.get(entity_id))
178  and (assistant_options := exposed_entity.assistants.get(assistant, {}))
179  and assistant_options.get(key) == value
180  ):
181  return
182 
183  if exposed_entity:
184  new_exposed_entity = self._update_exposed_entity_update_exposed_entity(
185  assistant, entity_id, key, value
186  )
187  else:
188  new_exposed_entity = self._new_exposed_entity_new_exposed_entity(assistant, key, value)
189  self.entitiesentities[entity_id] = new_exposed_entity
190  self._async_schedule_save_async_schedule_save()
191  for listener in self._listeners.get(assistant, []):
192  listener()
193 
194  @callback
195  def async_get_expose_new_entities(self, assistant: str) -> bool:
196  """Check if new entities are exposed to an assistant."""
197  if prefs := self._assistants_assistants.get(assistant):
198  return prefs.expose_new
199  return DEFAULT_EXPOSED_ASSISTANT.get(assistant, False)
200 
201  @callback
202  def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None:
203  """Enable an assistant to expose new entities."""
204  self._assistants_assistants[assistant] = AssistantPreferences(expose_new=expose_new)
205  self._async_schedule_save_async_schedule_save()
206 
207  @callback
209  self, assistant: str
210  ) -> dict[str, Mapping[str, Any]]:
211  """Get all entity expose settings for an assistant."""
212  entity_registry = er.async_get(self._hass_hass)
213  result: dict[str, Mapping[str, Any]] = {}
214 
215  options: Mapping | None
216  for entity_id, exposed_entity in self.entitiesentities.items():
217  if options := exposed_entity.assistants.get(assistant):
218  result[entity_id] = options
219 
220  for entity_id, entry in entity_registry.entities.items():
221  if options := entry.options.get(assistant):
222  result[entity_id] = options
223 
224  return result
225 
226  @callback
227  def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]:
228  """Get assistant expose settings for an entity."""
229  entity_registry = er.async_get(self._hass_hass)
230  result: dict[str, Mapping[str, Any]] = {}
231 
232  assistant_settings: Mapping
233  if registry_entry := entity_registry.async_get(entity_id):
234  assistant_settings = registry_entry.options
235  elif exposed_entity := self.entitiesentities.get(entity_id):
236  assistant_settings = exposed_entity.assistants
237  else:
238  raise HomeAssistantError("Unknown entity")
239 
240  for assistant in KNOWN_ASSISTANTS:
241  if options := assistant_settings.get(assistant):
242  result[assistant] = options
243 
244  return result
245 
246  @callback
247  def async_should_expose(self, assistant: str, entity_id: str) -> bool:
248  """Return True if an entity should be exposed to an assistant."""
249  should_expose: bool
250 
251  if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
252  return False
253 
254  entity_registry = er.async_get(self._hass_hass)
255  if not (registry_entry := entity_registry.async_get(entity_id)):
256  return self._async_should_expose_legacy_entity_async_should_expose_legacy_entity(assistant, entity_id)
257  if assistant in registry_entry.options:
258  if "should_expose" in registry_entry.options[assistant]:
259  should_expose = registry_entry.options[assistant]["should_expose"]
260  return should_expose
261 
262  if self.async_get_expose_new_entitiesasync_get_expose_new_entities(assistant):
263  should_expose = self._is_default_exposed_is_default_exposed(entity_id, registry_entry)
264  else:
265  should_expose = False
266 
267  assistant_options: ReadOnlyDict[str, Any] | dict[str, Any]
268  assistant_options = registry_entry.options.get(assistant, {})
269  assistant_options = assistant_options | {"should_expose": should_expose}
270  entity_registry.async_update_entity_options(
271  entity_id, assistant, assistant_options
272  )
273 
274  return should_expose
275 
277  self, assistant: str, entity_id: str
278  ) -> bool:
279  """Return True if an entity should be exposed to an assistant."""
280  should_expose: bool
281 
282  if (
283  exposed_entity := self.entitiesentities.get(entity_id)
284  ) and assistant in exposed_entity.assistants:
285  if "should_expose" in exposed_entity.assistants[assistant]:
286  should_expose = exposed_entity.assistants[assistant]["should_expose"]
287  return should_expose
288 
289  if self.async_get_expose_new_entitiesasync_get_expose_new_entities(assistant):
290  should_expose = self._is_default_exposed_is_default_exposed(entity_id, None)
291  else:
292  should_expose = False
293 
294  if exposed_entity:
295  new_exposed_entity = self._update_exposed_entity_update_exposed_entity(
296  assistant, entity_id, "should_expose", should_expose
297  )
298  else:
299  new_exposed_entity = self._new_exposed_entity_new_exposed_entity(
300  assistant, "should_expose", should_expose
301  )
302  self.entitiesentities[entity_id] = new_exposed_entity
303  self._async_schedule_save_async_schedule_save()
304 
305  return should_expose
306 
308  self, entity_id: str, registry_entry: er.RegistryEntry | None
309  ) -> bool:
310  """Return True if an entity is exposed by default."""
311  if registry_entry and (
312  registry_entry.entity_category is not None
313  or registry_entry.hidden_by is not None
314  ):
315  return False
316 
317  domain = split_entity_id(entity_id)[0]
318  if domain in DEFAULT_EXPOSED_DOMAINS:
319  return True
320 
321  try:
322  device_class = get_device_class(self._hass_hass, entity_id)
323  except HomeAssistantError:
324  # The entity no longer exists
325  return False
326  if (
327  domain == "binary_sensor"
328  and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
329  ):
330  return True
331 
332  if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES:
333  return True
334 
335  return False
336 
338  self, assistant: str, entity_id: str, key: str, value: Any
339  ) -> ExposedEntity:
340  """Update an exposed entity."""
341  entity = self.entitiesentities[entity_id]
342  assistants = dict(entity.assistants)
343  old_settings = assistants.get(assistant, {})
344  assistants[assistant] = old_settings | {key: value}
345  return ExposedEntity(assistants)
346 
348  self, assistant: str, key: str, value: Any
349  ) -> ExposedEntity:
350  """Create a new exposed entity."""
351  return ExposedEntity(
352  assistants={assistant: {key: value}},
353  )
354 
355  async def _async_load_data(self) -> SerializedExposedEntities | None:
356  """Load from the store."""
357  data = await self._store.async_load()
358 
359  assistants: dict[str, AssistantPreferences] = {}
360  exposed_entities: dict[str, ExposedEntity] = {}
361 
362  if data:
363  for domain, preferences in data["assistants"].items():
364  assistants[domain] = AssistantPreferences(**preferences)
365 
366  if data and "exposed_entities" in data:
367  for entity_id, preferences in data["exposed_entities"].items():
368  exposed_entities[entity_id] = ExposedEntity(**preferences)
369 
370  self._assistants_assistants = assistants
371  self.entitiesentities = exposed_entities
372 
373  return data
374 
375  @callback
376  def _async_schedule_save(self) -> None:
377  """Schedule saving the preferences."""
378  self._store.async_delay_save(self._data_to_save_data_to_save, SAVE_DELAY)
379 
380  @callback
381  def _data_to_save(self) -> SerializedExposedEntities:
382  """Return JSON-compatible date for storing to file."""
383  return {
384  "assistants": {
385  domain: preferences.to_json()
386  for domain, preferences in self._assistants_assistants.items()
387  },
388  "exposed_entities": {
389  entity_id: entity.to_json()
390  for entity_id, entity in self.entitiesentities.items()
391  },
392  }
393 
394 
395 @callback
396 @websocket_api.require_admin
397 @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity",
398  vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)],
399  vol.Required("entity_ids"): [str],
400  vol.Required("should_expose"): bool,
401  }
402 )
403 def ws_expose_entity(
404  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
405 ) -> None:
406  """Expose an entity to an assistant."""
407  entity_ids: str = msg["entity_ids"]
408 
409  if blocked := next(
410  (
411  entity_id
412  for entity_id in entity_ids
413  if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES
414  ),
415  None,
416  ):
417  connection.send_error(
418  msg["id"], websocket_api.ERR_NOT_ALLOWED, f"can't expose '{blocked}'"
419  )
420  return
421 
422  for entity_id in entity_ids:
423  for assistant in msg["assistants"]:
424  async_expose_entity(hass, assistant, entity_id, msg["should_expose"])
425  connection.send_result(msg["id"])
426 
427 
428 @callback
429 @websocket_api.require_admin
430 @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_entity/list",
431  }
432 )
434  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
435 ) -> None:
436  """Expose an entity to an assistant."""
437  result: dict[str, Any] = {}
438 
439  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
440  entity_registry = er.async_get(hass)
441  for entity_id in chain(exposed_entities.entities, entity_registry.entities):
442  result[entity_id] = {}
443  entity_settings = async_get_entity_settings(hass, entity_id)
444  for assistant, settings in entity_settings.items():
445  if "should_expose" not in settings:
446  continue
447  result[entity_id][assistant] = settings["should_expose"]
448  connection.send_result(msg["id"], {"exposed_entities": result})
449 
450 
451 @callback
452 @websocket_api.require_admin
453 @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_new_entities/get",
454  vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS),
455  }
456 )
458  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
459 ) -> None:
460  """Check if new entities are exposed to an assistant."""
461  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
462  expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"])
463  connection.send_result(msg["id"], {"expose_new": expose_new})
464 
465 
466 @callback
467 @websocket_api.require_admin
468 @websocket_api.websocket_command( { vol.Required("type"): "homeassistant/expose_new_entities/set",
469  vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS),
470  vol.Required("expose_new"): bool,
471  }
472 )
474  hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
475 ) -> None:
476  """Expose new entities to an assistant."""
477  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
478  exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"])
479  connection.send_result(msg["id"])
480 
481 
482 @callback
484  hass: HomeAssistant, assistant: str, listener: Callable[[], None]
485 ) -> CALLBACK_TYPE:
486  """Listen for updates to entity expose settings."""
487  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
488  return exposed_entities.async_listen_entity_updates(assistant, listener)
489 
490 
491 @callback
493  hass: HomeAssistant, assistant: str
494 ) -> dict[str, Mapping[str, Any]]:
495  """Get all entity expose settings for an assistant."""
496  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
497  return exposed_entities.async_get_assistant_settings(assistant)
498 
499 
500 @callback
502  hass: HomeAssistant, entity_id: str
503 ) -> dict[str, Mapping[str, Any]]:
504  """Get assistant expose settings for an entity."""
505  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
506  return exposed_entities.async_get_entity_settings(entity_id)
507 
508 
509 @callback
511  hass: HomeAssistant,
512  assistant: str,
513  entity_id: str,
514  should_expose: bool,
515 ) -> None:
516  """Get assistant expose settings for an entity."""
518  hass, assistant, entity_id, "should_expose", should_expose
519  )
520 
521 
522 @callback
523 def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool:
524  """Return True if an entity should be exposed to an assistant."""
525  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
526  return exposed_entities.async_should_expose(assistant, entity_id)
527 
528 
529 @callback
531  hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any
532 ) -> None:
533  """Set an option for an assistant.
534 
535  Notify listeners if expose flag was changed.
536  """
537  exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
538  exposed_entities.async_set_assistant_option(assistant, entity_id, option, value)
539 
bool _is_default_exposed(self, str entity_id, er.RegistryEntry|None registry_entry)
CALLBACK_TYPE async_listen_entity_updates(self, str assistant, Callable[[], None] listener)
None _async_set_legacy_assistant_option(self, str assistant, str entity_id, str key, Any value)
dict[str, Mapping[str, Any]] async_get_entity_settings(self, str entity_id)
ExposedEntity _new_exposed_entity(self, str assistant, str key, Any value)
None async_set_expose_new_entities(self, str assistant, bool expose_new)
None async_set_assistant_option(self, str assistant, str entity_id, str key, Any value)
bool _async_should_expose_legacy_entity(self, str assistant, str entity_id)
dict[str, Mapping[str, Any]] async_get_assistant_settings(self, str assistant)
ExposedEntity _update_exposed_entity(self, str assistant, str entity_id, str key, Any value)
bool remove(self, _T matcher)
Definition: match.py:214
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_set_assistant_option(HomeAssistant hass, str assistant, str entity_id, str option, Any value)
None ws_list_exposed_entities(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
None ws_expose_new_entities_set(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
CALLBACK_TYPE async_listen_entity_updates(HomeAssistant hass, str assistant, Callable[[], None] listener)
bool async_should_expose(HomeAssistant hass, str assistant, str entity_id)
None async_expose_entity(HomeAssistant hass, str assistant, str entity_id, bool should_expose)
None ws_expose_new_entities_get(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
None ws_expose_entity(HomeAssistant hass, websocket_api.ActiveConnection connection, dict[str, Any] msg)
dict[str, Mapping[str, Any]] async_get_entity_settings(HomeAssistant hass, str entity_id)
dict[str, Mapping[str, Any]] async_get_assistant_settings(HomeAssistant hass, str assistant)
tuple[str, str] split_entity_id(str entity_id)
Definition: core.py:214
None async_load(HomeAssistant hass)
str|None get_device_class(HomeAssistant hass, str entity_id)
Definition: entity.py:154
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)
Definition: storage.py:444