Home Assistant Unofficial Reference 2024.12.1
client.py
Go to the documentation of this file.
1 """Interface implementation for cloud client."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable
7 from datetime import datetime
8 from http import HTTPStatus
9 import logging
10 from pathlib import Path
11 from typing import Any, Literal
12 
13 import aiohttp
14 from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed
15 from webrtc_models import RTCIceServer
16 
17 from homeassistant.components import google_assistant, persistent_notification, webhook
19  errors as alexa_errors,
20  smart_home as alexa_smart_home,
21 )
22 from homeassistant.components.camera.webrtc import async_register_ice_servers
23 from homeassistant.components.google_assistant import smart_home as ga
24 from homeassistant.const import __version__ as HA_VERSION
25 from homeassistant.core import Context, HassJob, HomeAssistant, callback
26 from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
27 from homeassistant.helpers.dispatcher import async_dispatcher_send
28 from homeassistant.helpers.event import async_call_later
29 from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
30 from homeassistant.util.aiohttp import MockRequest, serialize_response
31 
32 from . import alexa_config, google_config
33 from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN, PREF_ENABLE_CLOUD_ICE_SERVERS
34 from .prefs import CloudPreferences
35 
36 _LOGGER = logging.getLogger(__name__)
37 
38 VALID_REPAIR_TRANSLATION_KEYS = {
39  "warn_bad_custom_domain_configuration",
40  "reset_bad_custom_domain_configuration",
41 }
42 
43 
44 class CloudClient(Interface):
45  """Interface class for Home Assistant Cloud."""
46 
47  def __init__(
48  self,
49  hass: HomeAssistant,
50  prefs: CloudPreferences,
51  websession: aiohttp.ClientSession,
52  alexa_user_config: dict[str, Any],
53  google_user_config: dict[str, Any],
54  ) -> None:
55  """Initialize client interface to Cloud."""
56  self._hass_hass = hass
57  self._prefs_prefs = prefs
58  self._websession_websession = websession
59  self.google_user_configgoogle_user_config = google_user_config
60  self.alexa_user_configalexa_user_config = alexa_user_config
61  self._alexa_config_alexa_config: alexa_config.CloudAlexaConfig | None = None
62  self._google_config_google_config: google_config.CloudGoogleConfig | None = None
63  self._alexa_config_init_lock_alexa_config_init_lock = asyncio.Lock()
64  self._google_config_init_lock_google_config_init_lock = asyncio.Lock()
65  self._relayer_region_relayer_region: str | None = None
66  self._cloud_ice_servers_listener_cloud_ice_servers_listener: Callable[[], None] | None = None
67 
68  @property
69  def base_path(self) -> Path:
70  """Return path to base dir."""
71  return Path(self._hass_hass.config.config_dir)
72 
73  @property
74  def prefs(self) -> CloudPreferences:
75  """Return Cloud preferences."""
76  return self._prefs_prefs
77 
78  @property
79  def loop(self) -> asyncio.AbstractEventLoop:
80  """Return client loop."""
81  return self._hass_hass.loop
82 
83  @property
84  def websession(self) -> aiohttp.ClientSession:
85  """Return client session for aiohttp."""
86  return self._websession_websession
87 
88  @property
89  def aiohttp_runner(self) -> aiohttp.web.AppRunner | None:
90  """Return client webinterface aiohttp application."""
91  return self._hass_hass.http.runner
92 
93  @property
94  def cloudhooks(self) -> dict[str, dict[str, str | bool]]:
95  """Return list of cloudhooks."""
96  return self._prefs_prefs.cloudhooks
97 
98  @property
99  def remote_autostart(self) -> bool:
100  """Return true if we want start a remote connection."""
101  return self._prefs_prefs.remote_enabled
102 
103  @property
104  def client_name(self) -> str:
105  """Return the client name that will be used for API calls."""
106  return SERVER_SOFTWARE
107 
108  @property
109  def relayer_region(self) -> str | None:
110  """Return the connected relayer region."""
111  return self._relayer_region_relayer_region
112 
113  async def get_alexa_config(self) -> alexa_config.CloudAlexaConfig:
114  """Return Alexa config."""
115  if self._alexa_config_alexa_config is None:
116  async with self._alexa_config_init_lock_alexa_config_init_lock:
117  if self._alexa_config_alexa_config is not None:
118  # This is reachable if the config was set while we waited
119  # for the lock
120  return self._alexa_config_alexa_config # type: ignore[unreachable]
121 
122  cloud_user = await self._prefs_prefs.get_cloud_user()
123 
124  alexa_conf = alexa_config.CloudAlexaConfig(
125  self._hass_hass,
126  self.alexa_user_configalexa_user_config,
127  cloud_user,
128  self._prefs_prefs,
129  self.cloud,
130  )
131  await alexa_conf.async_initialize()
132  self._alexa_config_alexa_config = alexa_conf
133 
134  return self._alexa_config_alexa_config
135 
136  async def get_google_config(self) -> google_config.CloudGoogleConfig:
137  """Return Google config."""
138  if not self._google_config_google_config:
139  async with self._google_config_init_lock_google_config_init_lock:
140  if self._google_config_google_config is not None:
141  return self._google_config_google_config
142 
143  cloud_user = await self._prefs_prefs.get_cloud_user()
144 
145  google_conf = google_config.CloudGoogleConfig(
146  self._hass_hass,
147  self.google_user_configgoogle_user_config,
148  cloud_user,
149  self._prefs_prefs,
150  self.cloud,
151  )
152  await google_conf.async_initialize()
153  self._google_config_google_config = google_conf
154 
155  return self._google_config_google_config
156 
157  async def cloud_connected(self) -> None:
158  """When cloud is connected."""
159  _LOGGER.debug("cloud_connected")
160  is_new_user = await self.prefsprefs.async_set_username(self.cloud.username)
161 
162  async def enable_alexa(_: Any) -> None:
163  """Enable Alexa."""
164  aconf = await self.get_alexa_configget_alexa_config()
165  try:
166  await aconf.async_enable_proactive_mode()
167  except aiohttp.ClientError as err: # If no internet available yet
168  if self._hass_hass.is_running:
169  logging.getLogger(__package__).warning(
170  (
171  "Unable to activate Alexa Report State: %s. Retrying in 30"
172  " seconds"
173  ),
174  err,
175  )
176  async_call_later(self._hass_hass, 30, enable_alexa_job)
177  except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink):
178  pass
179 
180  enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True)
181 
182  async def enable_google(_: datetime) -> None:
183  """Enable Google."""
184  gconf = await self.get_google_configget_google_config()
185 
186  gconf.async_enable_local_sdk()
187 
188  if gconf.should_report_state:
189  gconf.async_enable_report_state()
190 
191  if is_new_user:
192  await gconf.async_sync_entities(gconf.agent_user_id)
193 
194  async def setup_cloud_ice_servers(_: datetime) -> None:
195  async def register_cloud_ice_server(
196  ice_servers: list[RTCIceServer],
197  ) -> Callable[[], None]:
198  """Register cloud ice server."""
199 
200  def get_ice_servers() -> list[RTCIceServer]:
201  return ice_servers
202 
203  return async_register_ice_servers(self._hass_hass, get_ice_servers)
204 
205  async def async_register_cloud_ice_servers_listener(
206  prefs: CloudPreferences,
207  ) -> None:
208  is_cloud_ice_servers_enabled = (
209  self.cloud.is_logged_in
210  and not self.cloud.subscription_expired
211  and prefs.cloud_ice_servers_enabled
212  )
213  if is_cloud_ice_servers_enabled:
214  if self._cloud_ice_servers_listener_cloud_ice_servers_listener is None:
215  self._cloud_ice_servers_listener_cloud_ice_servers_listener = await self.cloud.ice_servers.async_register_ice_servers_listener(
216  register_cloud_ice_server
217  )
218  elif self._cloud_ice_servers_listener_cloud_ice_servers_listener:
219  self._cloud_ice_servers_listener_cloud_ice_servers_listener()
220  self._cloud_ice_servers_listener_cloud_ice_servers_listener = None
221 
222  async def async_prefs_updated(prefs: CloudPreferences) -> None:
223  updated_prefs = prefs.last_updated
224 
225  if (
226  updated_prefs is None
227  or PREF_ENABLE_CLOUD_ICE_SERVERS not in updated_prefs
228  ):
229  return
230 
231  await async_register_cloud_ice_servers_listener(prefs)
232 
233  await async_register_cloud_ice_servers_listener(self._prefs_prefs)
234 
235  self._prefs_prefs.async_listen_updates(async_prefs_updated)
236 
237  tasks = []
238 
239  if self._prefs_prefs.alexa_enabled and self._prefs_prefs.alexa_report_state:
240  tasks.append(enable_alexa)
241 
242  if self._prefs_prefs.google_enabled:
243  tasks.append(enable_google)
244 
245  tasks.append(setup_cloud_ice_servers)
246 
247  if tasks:
248  await asyncio.gather(*(task(None) for task in tasks))
249 
250  async def cloud_disconnected(self) -> None:
251  """When cloud disconnected."""
252  _LOGGER.debug("cloud_disconnected")
253  if self._google_config_google_config:
254  self._google_config_google_config.async_disable_local_sdk()
255 
256  async def cloud_started(self) -> None:
257  """When cloud is started."""
258 
259  async def cloud_stopped(self) -> None:
260  """When the cloud is stopped."""
261 
262  async def logout_cleanups(self) -> None:
263  """Cleanup some stuff after logout."""
264  await self.prefs.async_set_username(None)
265 
266  if self._alexa_config:
267  self._alexa_config.async_deinitialize()
268  self._alexa_config = None
269 
270  if self._google_config:
271  self._google_config.async_deinitialize()
272  self._google_config = None
273 
274  if self._cloud_ice_servers_listener:
275  self._cloud_ice_servers_listener()
276  self._cloud_ice_servers_listener = None
277 
278  @callback
279  def user_message(self, identifier: str, title: str, message: str) -> None:
280  """Create a message for user to UI."""
281  persistent_notification.async_create(self._hass_hass, message, title, identifier)
282 
283  @callback
284  def dispatcher_message(self, identifier: str, data: Any = None) -> None:
285  """Match cloud notification to dispatcher."""
286  if identifier.startswith("remote_"):
287  async_dispatcher_send(self._hass_hass, DISPATCHER_REMOTE_UPDATE, data)
288 
289  async def async_cloud_connect_update(self, connect: bool) -> None:
290  """Process cloud remote message to client."""
291  if not self._prefs_prefs.remote_allow_remote_enable:
292  raise RemoteActivationNotAllowed
293  await self._prefs_prefs.async_update(remote_enabled=connect)
294 
296  self, payload: dict[str, Any]
297  ) -> dict[str, Any]:
298  """Process cloud connection info message to client."""
299  return {
300  "remote": {
301  "can_enable": self._prefs_prefs.remote_allow_remote_enable,
302  "connected": self.cloud.remote.is_connected,
303  "enabled": self._prefs_prefs.remote_enabled,
304  "instance_domain": self.cloud.remote.instance_domain,
305  "alias": self.cloud.remote.alias,
306  },
307  "version": HA_VERSION,
308  "instance_id": self.prefsprefs.instance_id,
309  }
310 
311  async def async_alexa_message(self, payload: dict[Any, Any]) -> dict[Any, Any]:
312  """Process cloud alexa message to client."""
313  cloud_user = await self._prefs_prefs.get_cloud_user()
314  aconfig = await self.get_alexa_configget_alexa_config()
315  return await alexa_smart_home.async_handle_message(
316  self._hass_hass,
317  aconfig,
318  payload,
319  context=Context(user_id=cloud_user),
320  enabled=self._prefs_prefs.alexa_enabled,
321  )
322 
323  async def async_google_message(self, payload: dict[Any, Any]) -> dict[Any, Any]:
324  """Process cloud google message to client."""
325  gconf = await self.get_google_configget_google_config()
326 
327  msgid: Any = "<UNKNOWN>"
328  if isinstance(payload, dict):
329  msgid = payload.get("requestId")
330  _LOGGER.debug("Received cloud message %s", msgid)
331 
332  if not self._prefs_prefs.google_enabled:
333  return ga.api_disabled_response( # type: ignore[no-any-return, no-untyped-call]
334  payload, gconf.agent_user_id
335  )
336 
337  return await ga.async_handle_message( # type: ignore[no-any-return, no-untyped-call]
338  self._hass_hass,
339  gconf,
340  gconf.agent_user_id,
341  gconf.cloud_user,
342  payload,
343  google_assistant.SOURCE_CLOUD,
344  )
345 
346  async def async_webhook_message(self, payload: dict[Any, Any]) -> dict[Any, Any]:
347  """Process cloud webhook message to client."""
348  cloudhook_id = payload["cloudhook_id"]
349 
350  found = None
351  for cloudhook in self._prefs_prefs.cloudhooks.values():
352  if cloudhook["cloudhook_id"] == cloudhook_id:
353  found = cloudhook
354  break
355 
356  if found is None:
357  return {"status": HTTPStatus.OK}
358 
359  request = MockRequest(
360  content=payload["body"].encode("utf-8"),
361  headers=payload["headers"],
362  method=payload["method"],
363  query_string=payload["query"],
364  mock_source=DOMAIN,
365  )
366 
367  response = await webhook.async_handle_webhook(
368  self._hass_hass, found["webhook_id"], request
369  )
370 
371  response_dict = serialize_response(response)
372  body = response_dict.get("body")
373 
374  return {
375  "body": body,
376  "status": response_dict["status"],
377  "headers": {"Content-Type": response.content_type},
378  }
379 
380  async def async_system_message(self, payload: dict[Any, Any] | None) -> None:
381  """Handle system messages."""
382  if payload and (region := payload.get("region")):
383  self._relayer_region_relayer_region = region
384 
386  self, data: dict[str, dict[str, str | bool]]
387  ) -> None:
388  """Update local list of cloudhooks."""
389  await self._prefs_prefs.async_update(cloudhooks=data)
390 
392  self,
393  identifier: str,
394  translation_key: str,
395  *,
396  placeholders: dict[str, str] | None = None,
397  severity: Literal["error", "warning"] = "warning",
398  ) -> None:
399  """Create a repair issue."""
400  if translation_key not in VALID_REPAIR_TRANSLATION_KEYS:
401  raise ValueError(f"Invalid translation key {translation_key}")
403  hass=self._hass_hass,
404  domain=DOMAIN,
405  issue_id=identifier,
406  translation_key=translation_key,
407  translation_placeholders=placeholders,
408  severity=IssueSeverity(severity),
409  is_fixable=False,
410  )
dict[Any, Any] async_alexa_message(self, dict[Any, Any] payload)
Definition: client.py:311
dict[Any, Any] async_webhook_message(self, dict[Any, Any] payload)
Definition: client.py:346
aiohttp.web.AppRunner|None aiohttp_runner(self)
Definition: client.py:89
dict[str, dict[str, str|bool]] cloudhooks(self)
Definition: client.py:94
aiohttp.ClientSession websession(self)
Definition: client.py:84
None async_create_repair_issue(self, str identifier, str translation_key, *dict[str, str]|None placeholders=None, Literal["error", "warning"] severity="warning")
Definition: client.py:398
google_config.CloudGoogleConfig get_google_config(self)
Definition: client.py:136
dict[Any, Any] async_google_message(self, dict[Any, Any] payload)
Definition: client.py:323
asyncio.AbstractEventLoop loop(self)
Definition: client.py:79
None user_message(self, str identifier, str title, str message)
Definition: client.py:279
None dispatcher_message(self, str identifier, Any data=None)
Definition: client.py:284
None async_cloudhooks_update(self, dict[str, dict[str, str|bool]] data)
Definition: client.py:387
None __init__(self, HomeAssistant hass, CloudPreferences prefs, aiohttp.ClientSession websession, dict[str, Any] alexa_user_config, dict[str, Any] google_user_config)
Definition: client.py:54
None async_system_message(self, dict[Any, Any]|None payload)
Definition: client.py:380
dict[str, Any] async_cloud_connection_info(self, dict[str, Any] payload)
Definition: client.py:297
None async_cloud_connect_update(self, bool connect)
Definition: client.py:289
alexa_config.CloudAlexaConfig get_alexa_config(self)
Definition: client.py:113
Callable[[], None] async_register_ice_servers(HomeAssistant hass, Callable[[], Iterable[RTCIceServer]] get_ice_server_fn)
Definition: webrtc.py:402
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
dict[str, Any] serialize_response(web.Response response)
Definition: aiohttp.py:107