Home Assistant Unofficial Reference 2024.12.1
google_config.py
Go to the documentation of this file.
1 """Google config for Cloud."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from http import HTTPStatus
7 import logging
8 from typing import TYPE_CHECKING, Any
9 
10 from hass_nabucasa import Cloud, cloud_api
11 from hass_nabucasa.google_report_state import ErrorResponse
12 
13 from homeassistant.components.binary_sensor import BinarySensorDeviceClass
14 from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
17  async_expose_entity,
18  async_get_assistant_settings,
19  async_get_entity_settings,
20  async_listen_entity_updates,
21  async_set_assistant_option,
22  async_should_expose,
23 )
24 from homeassistant.components.sensor import SensorDeviceClass
25 from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
26 from homeassistant.core import (
27  CoreState,
28  Event,
29  HomeAssistant,
30  State,
31  callback,
32  split_entity_id,
33 )
34 from homeassistant.exceptions import HomeAssistantError
35 from homeassistant.helpers import device_registry as dr, entity_registry as er, start
36 from homeassistant.helpers.entity import get_device_class
37 from homeassistant.helpers.entityfilter import EntityFilter
38 from homeassistant.setup import async_setup_component
39 
40 from .const import (
41  CONF_ENTITY_CONFIG,
42  CONF_FILTER,
43  DEFAULT_DISABLE_2FA,
44  DOMAIN as CLOUD_DOMAIN,
45  PREF_DISABLE_2FA,
46  PREF_SHOULD_EXPOSE,
47 )
48 from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences
49 
50 if TYPE_CHECKING:
51  from .client import CloudClient
52 
53 _LOGGER = logging.getLogger(__name__)
54 
55 CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
56 
57 
58 SUPPORTED_DOMAINS = {
59  "alarm_control_panel",
60  "button",
61  "camera",
62  "climate",
63  "cover",
64  "fan",
65  "group",
66  "humidifier",
67  "input_boolean",
68  "input_button",
69  "input_select",
70  "light",
71  "lock",
72  "media_player",
73  "scene",
74  "script",
75  "select",
76  "switch",
77  "vacuum",
78 }
79 
80 SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = {
81  BinarySensorDeviceClass.DOOR,
82  BinarySensorDeviceClass.GARAGE_DOOR,
83  BinarySensorDeviceClass.LOCK,
84  BinarySensorDeviceClass.MOTION,
85  BinarySensorDeviceClass.OPENING,
86  BinarySensorDeviceClass.PRESENCE,
87  BinarySensorDeviceClass.WINDOW,
88 }
89 
90 SUPPORTED_SENSOR_DEVICE_CLASSES = {
91  SensorDeviceClass.AQI,
92  SensorDeviceClass.CO,
93  SensorDeviceClass.CO2,
94  SensorDeviceClass.HUMIDITY,
95  SensorDeviceClass.PM10,
96  SensorDeviceClass.PM25,
97  SensorDeviceClass.TEMPERATURE,
98  SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
99 }
100 
101 
102 def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool:
103  """Return if the entity is supported.
104 
105  This is called when migrating from legacy config format to avoid exposing
106  all binary sensors and sensors.
107  """
108  domain = split_entity_id(entity_id)[0]
109  if domain in SUPPORTED_DOMAINS:
110  return True
111 
112  try:
113  device_class = get_device_class(hass, entity_id)
114  except HomeAssistantError:
115  # The entity no longer exists
116  return False
117 
118  if (
119  domain == "binary_sensor"
120  and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
121  ):
122  return True
123 
124  if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
125  return True
126 
127  return False
128 
129 
131  """HA Cloud Configuration for Google Assistant."""
132 
133  def __init__(
134  self,
135  hass: HomeAssistant,
136  config: dict[str, Any],
137  cloud_user: str,
138  prefs: CloudPreferences,
139  cloud: Cloud[CloudClient],
140  ) -> None:
141  """Initialize the Google config."""
142  super().__init__(hass)
143  self._config_config = config
144  self._user_user = cloud_user
145  self._prefs_prefs = prefs
146  self._cloud_cloud = cloud
147  self._sync_entities_lock_sync_entities_lock = asyncio.Lock()
148 
149  @property
150  def enabled(self) -> bool:
151  """Return if Google is enabled."""
152  return (
153  self._cloud_cloud.is_logged_in
154  and not self._cloud_cloud.subscription_expired
155  and self._prefs_prefs.google_enabled
156  )
157 
158  @property
159  def entity_config(self) -> dict[str, Any]:
160  """Return entity config."""
161  return self._config_config.get(CONF_ENTITY_CONFIG) or {}
162 
163  @property
164  def secure_devices_pin(self) -> str | None:
165  """Return entity config."""
166  return self._prefs_prefs.google_secure_devices_pin
167 
168  @property
169  def should_report_state(self) -> bool:
170  """Return if states should be proactively reported."""
171  return self.enabledenabledenabled and self._prefs_prefs.google_report_state
172 
173  def get_local_webhook_id(self, agent_user_id: Any) -> str:
174  """Return the webhook ID to be used for actions for a given agent user id via the local SDK."""
175  return self._prefs_prefs.google_local_webhook_id
176 
177  def get_local_user_id(self, webhook_id: Any) -> str:
178  """Map webhook ID to a Home Assistant user ID.
179 
180  Any action initiated by Google Assistant via the local SDK will be attributed
181  to the returned user ID.
182  """
183  return self._user_user
184 
185  @property
186  def cloud_user(self) -> str:
187  """Return Cloud User account."""
188  return self._user_user
189 
191  """Migrate Google entity settings to entity registry options."""
192  if not self._config_config[CONF_FILTER].empty_filter:
193  # Don't migrate if there's a YAML config
194  return
195 
196  for entity_id in {
197  *self.hasshass.states.async_entity_ids(),
198  *self._prefs_prefs.google_entity_configs,
199  }:
201  self.hasshass,
202  CLOUD_GOOGLE,
203  entity_id,
204  self._should_expose_legacy_should_expose_legacy(entity_id),
205  )
206  if _2fa_disabled := (self._2fa_disabled_legacy_2fa_disabled_legacy(entity_id) is not None):
208  self.hasshass,
209  CLOUD_GOOGLE,
210  entity_id,
211  PREF_DISABLE_2FA,
212  _2fa_disabled,
213  )
214 
215  async def async_initialize(self) -> None:
216  """Perform async initialization of config."""
217  _LOGGER.debug("async_initialize")
218  await super().async_initialize()
219 
220  async def on_hass_started(hass: HomeAssistant) -> None:
221  _LOGGER.debug("async_initialize on_hass_started")
222  if self._prefs_prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
223  _LOGGER.info(
224  "Start migration of Google Assistant settings from v%s to v%s",
225  self._prefs_prefs.google_settings_version,
226  GOOGLE_SETTINGS_VERSION,
227  )
228  if self._prefs_prefs.google_settings_version < 2 or (
229  # Recover from a bug we had in 2023.5.0 where entities didn't get exposed
230  self._prefs_prefs.google_settings_version < 3
231  and not any(
232  settings.get("should_expose", False)
233  for settings in async_get_assistant_settings(
234  hass, CLOUD_GOOGLE
235  ).values()
236  )
237  ):
238  self._migrate_google_entity_settings_v1_migrate_google_entity_settings_v1()
239 
240  _LOGGER.info(
241  "Finished migration of Google Assistant settings from v%s to v%s",
242  self._prefs_prefs.google_settings_version,
243  GOOGLE_SETTINGS_VERSION,
244  )
245  await self._prefs_prefs.async_update(
246  google_settings_version=GOOGLE_SETTINGS_VERSION
247  )
248  self._on_deinitialize.append(
250  self.hasshass, CLOUD_GOOGLE, self._async_exposed_entities_updated_async_exposed_entities_updated
251  )
252  )
253 
254  async def on_hass_start(hass: HomeAssistant) -> None:
255  _LOGGER.debug("async_initialize on_hass_start")
256  if self.enabledenabledenabled and GOOGLE_DOMAIN not in self.hasshass.config.components:
257  await async_setup_component(self.hasshass, GOOGLE_DOMAIN, {})
258 
259  self._on_deinitialize.append(start.async_at_start(self.hasshass, on_hass_start))
260  self._on_deinitialize.append(start.async_at_started(self.hasshass, on_hass_started))
261 
262  self._on_deinitialize.append(
263  self._prefs_prefs.async_listen_updates(self._async_prefs_updated_async_prefs_updated)
264  )
265  self._on_deinitialize.append(
266  self.hasshass.bus.async_listen(
267  er.EVENT_ENTITY_REGISTRY_UPDATED,
268  self._handle_entity_registry_updated_handle_entity_registry_updated,
269  )
270  )
271  self._on_deinitialize.append(
272  self.hasshass.bus.async_listen(
273  dr.EVENT_DEVICE_REGISTRY_UPDATED,
274  self._handle_device_registry_updated_handle_device_registry_updated,
275  )
276  )
277 
278  def should_expose(self, state: State) -> bool:
279  """If a state object should be exposed."""
280  return self._should_expose_entity_id_should_expose_entity_id(state.entity_id)
281 
282  def _should_expose_legacy(self, entity_id: str) -> bool:
283  """If an entity ID should be exposed."""
284  if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
285  return False
286 
287  entity_configs = self._prefs_prefs.google_entity_configs
288  entity_config = entity_configs.get(entity_id, {})
289  entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE)
290  if entity_expose is not None:
291  return entity_expose
292 
293  entity_registry = er.async_get(self.hasshass)
294  if registry_entry := entity_registry.async_get(entity_id):
295  auxiliary_entity = (
296  registry_entry.entity_category is not None
297  or registry_entry.hidden_by is not None
298  )
299  else:
300  auxiliary_entity = False
301 
302  default_expose = self._prefs_prefs.google_default_expose
303 
304  # Backwards compat
305  if default_expose is None:
306  return not auxiliary_entity and _supported_legacy(self.hasshass, entity_id)
307 
308  return (
309  not auxiliary_entity
310  and split_entity_id(entity_id)[0] in default_expose
311  and _supported_legacy(self.hasshass, entity_id)
312  )
313 
314  def _should_expose_entity_id(self, entity_id: str) -> bool:
315  """If an entity should be exposed."""
316  entity_filter: EntityFilter = self._config_config[CONF_FILTER]
317  if not entity_filter.empty_filter:
318  if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
319  return False
320  return entity_filter(entity_id)
321 
322  return async_should_expose(self.hasshass, CLOUD_GOOGLE, entity_id)
323 
324  @property
325  def agent_user_id(self) -> str:
326  """Return Agent User Id to use for query responses."""
327  return self._cloud_cloud.username
328 
329  @property
330  def has_registered_user_agent(self) -> bool:
331  """Return if we have a Agent User Id registered."""
332  return len(self.async_get_agent_usersasync_get_agent_usersasync_get_agent_users()) > 0
333 
334  def get_agent_user_id_from_context(self, context: Any) -> str:
335  """Get agent user ID making request."""
336  return self.agent_user_idagent_user_id
337 
338  def get_agent_user_id_from_webhook(self, webhook_id: str) -> str | None:
339  """Map webhook ID to a Google agent user ID.
340 
341  Return None if no agent user id is found for the webhook_id.
342  """
343  if webhook_id != self._prefs_prefs.google_local_webhook_id:
344  return None
345 
346  return self.agent_user_idagent_user_id
347 
348  def _2fa_disabled_legacy(self, entity_id: str) -> bool | None:
349  """If an entity should be checked for 2FA."""
350  entity_configs = self._prefs_prefs.google_entity_configs
351  entity_config = entity_configs.get(entity_id, {})
352  return entity_config.get(PREF_DISABLE_2FA)
353 
354  def should_2fa(self, state: State) -> bool:
355  """If an entity should be checked for 2FA."""
356  try:
357  settings = async_get_entity_settings(self.hasshass, state.entity_id)
358  except HomeAssistantError:
359  # Handle the entity has been removed
360  return False
361 
362  assistant_options = settings.get(CLOUD_GOOGLE, {})
363  return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
364 
366  self, message: Any, agent_user_id: str, event_id: str | None = None
367  ) -> None:
368  """Send a state report to Google."""
369  try:
370  await self._cloud_cloud.google_report_state.async_send_message(message)
371  except ErrorResponse as err:
372  _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message)
373 
374  async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus | int:
375  """Trigger a sync with Google."""
376  if self._sync_entities_lock_sync_entities_lock.locked():
377  return HTTPStatus.OK
378 
379  async with self._sync_entities_lock_sync_entities_lock:
380  resp = await cloud_api.async_google_actions_request_sync(self._cloud_cloud)
381  return resp.status
382 
383  async def async_connect_agent_user(self, agent_user_id: str) -> None:
384  """Add a synced and known agent_user_id.
385 
386  Called before sending a sync response to Google.
387  """
388  await self._prefs_prefs.async_update(google_connected=True)
389 
390  async def async_disconnect_agent_user(self, agent_user_id: str) -> None:
391  """Turn off report state and disable further state reporting.
392 
393  Called when:
394  - The user disconnects their account from Google.
395  - When the cloud configuration is initialized
396  - When sync entities fails with 404
397  """
398  await self._prefs_prefs.async_update(google_connected=False)
399 
400  @callback
401  def async_get_agent_users(self) -> tuple:
402  """Return known agent users."""
403  if (
404  not self._cloud_cloud.is_logged_in # Can't call Cloud.username if not logged in
405  or not self._prefs_prefs.google_connected
406  or not self._cloud_cloud.username
407  ):
408  return ()
409  return (self._cloud_cloud.username,)
410 
411  async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:
412  """Handle updated preferences."""
413  _LOGGER.debug("_async_prefs_updated")
414  if not self._cloud_cloud.is_logged_in:
415  if self.is_reporting_stateis_reporting_state:
416  self.async_disable_report_stateasync_disable_report_state()
417  if self.is_local_sdk_activeis_local_sdk_active:
418  self.async_disable_local_sdkasync_disable_local_sdk()
419  return
420 
421  if (
422  self.enabledenabledenabled
423  and GOOGLE_DOMAIN not in self.hasshass.config.components
424  and self.hasshass.is_running
425  ):
426  await async_setup_component(self.hasshass, GOOGLE_DOMAIN, {})
427 
428  sync_entities = False
429 
430  if self.should_report_stateshould_report_stateshould_report_state != self.is_reporting_stateis_reporting_state:
431  if self.should_report_stateshould_report_stateshould_report_state:
432  self.async_enable_report_stateasync_enable_report_state()
433  else:
434  self.async_disable_report_stateasync_disable_report_state()
435 
436  # State reporting is reported as a property on entities.
437  # So when we change it, we need to sync all entities.
438  sync_entities = True
439 
440  if self.enabledenabledenabled and not self.is_local_sdk_activeis_local_sdk_active:
441  self.async_enable_local_sdkasync_enable_local_sdk()
442  sync_entities = True
443  elif not self.enabledenabledenabled and self.is_local_sdk_activeis_local_sdk_active:
444  self.async_disable_local_sdkasync_disable_local_sdk()
445  sync_entities = True
446 
447  if sync_entities and self.hasshass.is_running:
448  await self.async_sync_entities_allasync_sync_entities_all()
449 
450  @callback
452  """Handle updated preferences."""
453  self.async_schedule_google_sync_allasync_schedule_google_sync_all()
454 
455  @callback
457  self, event: Event[er.EventEntityRegistryUpdatedData]
458  ) -> None:
459  """Handle when entity registry updated."""
460  if (
461  not self.enabledenabledenabled
462  or not self._cloud_cloud.is_logged_in
463  or self.hasshass.state is not CoreState.running
464  ):
465  return
466 
467  # Only consider entity registry updates if info relevant for Google has changed
468  if event.data["action"] == "update" and not bool(
469  set(event.data["changes"]) & er.ENTITY_DESCRIBING_ATTRIBUTES
470  ):
471  return
472 
473  entity_id = event.data["entity_id"]
474 
475  if not self._should_expose_entity_id_should_expose_entity_id(entity_id):
476  return
477 
478  self.async_schedule_google_sync_allasync_schedule_google_sync_all()
479 
480  @callback
482  self, event: Event[dr.EventDeviceRegistryUpdatedData]
483  ) -> None:
484  """Handle when device registry updated."""
485  if (
486  not self.enabledenabledenabled
487  or not self._cloud_cloud.is_logged_in
488  or self.hasshass.state is not CoreState.running
489  ):
490  return
491 
492  # Device registry is only used for area changes. All other changes are ignored.
493  if event.data["action"] != "update" or "area_id" not in event.data["changes"]:
494  return
495 
496  # Check if any exposed entity uses the device area
497  if not any(
498  entity_entry.area_id is None
499  and self._should_expose_entity_id_should_expose_entity_id(entity_entry.entity_id)
500  for entity_entry in er.async_entries_for_device(
501  er.async_get(self.hasshass), event.data["device_id"]
502  )
503  ):
504  return
505 
506  self.async_schedule_google_sync_allasync_schedule_google_sync_all()
None _handle_device_registry_updated(self, Event[dr.EventDeviceRegistryUpdatedData] event)
None _handle_entity_registry_updated(self, Event[er.EventEntityRegistryUpdatedData] event)
None async_report_state(self, Any message, str agent_user_id, str|None event_id=None)
HTTPStatus|int _async_request_sync_devices(self, str agent_user_id)
None __init__(self, HomeAssistant hass, dict[str, Any] config, str cloud_user, CloudPreferences prefs, Cloud[CloudClient] cloud)
bool _supported_legacy(HomeAssistant hass, str entity_id)
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)
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)
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
str|None get_device_class(HomeAssistant hass, str entity_id)
Definition: entity.py:154
bool async_setup_component(core.HomeAssistant hass, str domain, ConfigType config)
Definition: setup.py:147