1 """Control which entities are exposed to voice assistants."""
3 from __future__
import annotations
5 from collections.abc
import Callable, Mapping
7 from itertools
import chain
8 from typing
import Any, TypedDict
10 import voluptuous
as vol
23 from .const
import DATA_EXPOSED_ENTITIES, DOMAIN
25 KNOWN_ASSISTANTS = (
"cloud.alexa",
"cloud.google_assistant",
"conversation")
27 STORAGE_KEY = f
"{DOMAIN}.exposed_entities"
32 DEFAULT_EXPOSED_DOMAINS = {
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,
56 DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = {
57 SensorDeviceClass.AQI,
59 SensorDeviceClass.CO2,
60 SensorDeviceClass.HUMIDITY,
61 SensorDeviceClass.PM10,
62 SensorDeviceClass.PM25,
63 SensorDeviceClass.TEMPERATURE,
64 SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
67 DEFAULT_EXPOSED_ASSISTANT = {
72 @dataclasses.dataclass(frozen=True)
74 """Preferences for an assistant."""
79 """Return a JSON serializable representation for storage."""
80 return {
"expose_new": self.expose_new}
83 @dataclasses.dataclass(frozen=True)
85 """An exposed entity without a unique_id."""
87 assistants: dict[str, dict[str, Any]]
90 """Return a JSON serializable representation for storage."""
92 "assistants": self.assistants,
97 """Serialized exposed entities storage storage collection."""
99 assistants: dict[str, dict[str, Any]]
100 exposed_entities: dict[str, dict[str, Any]]
104 """Control assistant settings.
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.
110 _assistants: dict[str, AssistantPreferences]
111 entities: dict[str, ExposedEntity]
116 self._listeners: dict[str, list[Callable[[],
None]]] = {}
117 self._store: Store[SerializedExposedEntities] =
Store(
118 hass, STORAGE_VERSION, STORAGE_KEY
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)
131 self, assistant: str, listener: Callable[[],
None]
133 """Listen for updates to entity expose settings."""
135 def unsubscribe() -> None:
136 """Stop listening to entity updates."""
137 self._listeners[assistant].
remove(listener)
139 self._listeners.setdefault(assistant, []).append(listener)
145 self, assistant: str, entity_id: str, key: str, value: Any
147 """Set an option for an assistant.
149 Notify listeners if expose flag was changed.
151 entity_registry = er.async_get(self.
_hass_hass)
152 if not (registry_entry := entity_registry.async_get(entity_id)):
156 assistant_options: ReadOnlyDict[str, Any] | dict[str, Any]
158 assistant_options := registry_entry.options.get(assistant, {})
159 )
and assistant_options.get(key) == value:
162 assistant_options = assistant_options | {key: value}
163 entity_registry.async_update_entity_options(
164 entity_id, assistant, assistant_options
166 for listener
in self._listeners.
get(assistant, []):
170 self, assistant: str, entity_id: str, key: str, value: Any
172 """Set an option for an assistant.
174 Notify listeners if expose flag was changed.
177 (exposed_entity := self.
entitiesentities.
get(entity_id))
178 and (assistant_options := exposed_entity.assistants.get(assistant, {}))
179 and assistant_options.get(key) == value
185 assistant, entity_id, key, value
189 self.
entitiesentities[entity_id] = new_exposed_entity
191 for listener
in self._listeners.
get(assistant, []):
196 """Check if new entities are exposed to an assistant."""
198 return prefs.expose_new
199 return DEFAULT_EXPOSED_ASSISTANT.get(assistant,
False)
203 """Enable an assistant to expose new entities."""
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]] = {}
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
220 for entity_id, entry
in entity_registry.entities.items():
221 if options := entry.options.get(assistant):
222 result[entity_id] = options
228 """Get assistant expose settings for an entity."""
229 entity_registry = er.async_get(self.
_hass_hass)
230 result: dict[str, Mapping[str, Any]] = {}
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
240 for assistant
in KNOWN_ASSISTANTS:
241 if options := assistant_settings.get(assistant):
242 result[assistant] = options
248 """Return True if an entity should be exposed to an assistant."""
251 if entity_id
in CLOUD_NEVER_EXPOSED_ENTITIES:
254 entity_registry = er.async_get(self.
_hass_hass)
255 if not (registry_entry := entity_registry.async_get(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"]
265 should_expose =
False
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
277 self, assistant: str, entity_id: str
279 """Return True if an entity should be exposed to an assistant."""
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"]
292 should_expose =
False
296 assistant, entity_id,
"should_expose", should_expose
300 assistant,
"should_expose", should_expose
302 self.
entitiesentities[entity_id] = new_exposed_entity
308 self, entity_id: str, registry_entry: er.RegistryEntry |
None
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
318 if domain
in DEFAULT_EXPOSED_DOMAINS:
323 except HomeAssistantError:
327 domain ==
"binary_sensor"
328 and device_class
in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
332 if domain ==
"sensor" and device_class
in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES:
338 self, assistant: str, entity_id: str, key: str, value: Any
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}
348 self, assistant: str, key: str, value: Any
350 """Create a new exposed entity."""
352 assistants={assistant: {key: value}},
356 """Load from the store."""
359 assistants: dict[str, AssistantPreferences] = {}
360 exposed_entities: dict[str, ExposedEntity] = {}
363 for domain, preferences
in data[
"assistants"].items():
366 if data
and "exposed_entities" in data:
367 for entity_id, preferences
in data[
"exposed_entities"].items():
377 """Schedule saving the preferences."""
382 """Return JSON-compatible date for storing to file."""
385 domain: preferences.to_json()
386 for domain, preferences
in self.
_assistants_assistants.items()
388 "exposed_entities": {
389 entity_id: entity.to_json()
390 for entity_id, entity
in self.
entitiesentities.items()
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,
406 """Expose an entity to an assistant."""
407 entity_ids: str = msg[
"entity_ids"]
412 for entity_id
in entity_ids
413 if entity_id
in CLOUD_NEVER_EXPOSED_ENTITIES
417 connection.send_error(
418 msg[
"id"], websocket_api.ERR_NOT_ALLOWED, f
"can't expose '{blocked}'"
422 for entity_id
in entity_ids:
423 for assistant
in msg[
"assistants"]:
425 connection.send_result(msg[
"id"])
429 @websocket_api.require_admin
430 @websocket_api.websocket_command(
{
vol.Required("type"):
"homeassistant/expose_entity/list",
436 """Expose an entity to an assistant."""
437 result: dict[str, Any] = {}
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] = {}
444 for assistant, settings
in entity_settings.items():
445 if "should_expose" not in settings:
447 result[entity_id][assistant] = settings[
"should_expose"]
448 connection.send_result(msg[
"id"], {
"exposed_entities": result})
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),
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})
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,
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"])
484 hass: HomeAssistant, assistant: str, listener: Callable[[],
None]
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)
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)
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)
516 """Get assistant expose settings for an entity."""
518 hass, assistant, entity_id,
"should_expose", should_expose
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)
531 hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any
533 """Set an option for an assistant.
535 Notify listeners if expose flag was changed.
537 exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
538 exposed_entities.async_set_assistant_option(assistant, entity_id, option, value)
539
dict[str, Any] to_json(self)
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)
bool async_get_expose_new_entities(self, str assistant)
bool async_should_expose(self, str assistant, str entity_id)
None _async_schedule_save(self)
dict[str, Mapping[str, Any]] async_get_entity_settings(self, str entity_id)
None __init__(self, HomeAssistant hass)
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)
None async_initialize(self)
ExposedEntity _update_exposed_entity(self, str assistant, str entity_id, str key, Any value)
SerializedExposedEntities|None _async_load_data(self)
SerializedExposedEntities _data_to_save(self)
dict[str, Any] to_json(self)
bool remove(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
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)
None async_load(HomeAssistant hass)
str|None get_device_class(HomeAssistant hass, str entity_id)
None async_delay_save(self, Callable[[], _T] data_func, float delay=0)