Home Assistant Unofficial Reference 2024.12.1
prefs.py
Go to the documentation of this file.
1 """Preference management for cloud."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Coroutine
6 from typing import Any
7 import uuid
8 
9 from hass_nabucasa.voice import MAP_VOICE, Gender
10 
11 from homeassistant.auth.const import GROUP_ID_ADMIN
12 from homeassistant.auth.models import User
13 from homeassistant.components import webhook
15  async_get_users as async_get_google_assistant_users,
16 )
17 from homeassistant.core import HomeAssistant, callback
18 from homeassistant.helpers.storage import Store
19 from homeassistant.helpers.typing import UNDEFINED, UndefinedType
20 from homeassistant.util.logging import async_create_catching_coro
21 
22 from .const import (
23  DEFAULT_ALEXA_REPORT_STATE,
24  DEFAULT_EXPOSED_DOMAINS,
25  DEFAULT_GOOGLE_REPORT_STATE,
26  DEFAULT_TTS_DEFAULT_VOICE,
27  DOMAIN,
28  PREF_ALEXA_DEFAULT_EXPOSE,
29  PREF_ALEXA_ENTITY_CONFIGS,
30  PREF_ALEXA_REPORT_STATE,
31  PREF_ALEXA_SETTINGS_VERSION,
32  PREF_CLOUD_USER,
33  PREF_CLOUDHOOKS,
34  PREF_ENABLE_ALEXA,
35  PREF_ENABLE_CLOUD_ICE_SERVERS,
36  PREF_ENABLE_GOOGLE,
37  PREF_ENABLE_REMOTE,
38  PREF_GOOGLE_CONNECTED,
39  PREF_GOOGLE_DEFAULT_EXPOSE,
40  PREF_GOOGLE_ENTITY_CONFIGS,
41  PREF_GOOGLE_LOCAL_WEBHOOK_ID,
42  PREF_GOOGLE_REPORT_STATE,
43  PREF_GOOGLE_SECURE_DEVICES_PIN,
44  PREF_GOOGLE_SETTINGS_VERSION,
45  PREF_INSTANCE_ID,
46  PREF_REMOTE_ALLOW_REMOTE_ENABLE,
47  PREF_REMOTE_DOMAIN,
48  PREF_TTS_DEFAULT_VOICE,
49  PREF_USERNAME,
50 )
51 
52 STORAGE_KEY = DOMAIN
53 STORAGE_VERSION = 1
54 STORAGE_VERSION_MINOR = 4
55 
56 ALEXA_SETTINGS_VERSION = 3
57 GOOGLE_SETTINGS_VERSION = 3
58 
59 
61  """Store cloud preferences."""
62 
64  self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
65  ) -> dict[str, Any]:
66  """Migrate to the new version."""
67 
68  async def google_connected() -> bool:
69  """Return True if our user is preset in the google_assistant store."""
70  # If we don't have a user, we can't be connected to Google
71  if not (cur_username := old_data.get(PREF_USERNAME)):
72  return False
73 
74  # If our user is in the Google store, we're connected
75  return cur_username in await async_get_google_assistant_users(self.hass)
76 
77  if old_major_version == 1:
78  if old_minor_version < 2:
79  old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1)
80  old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1)
81  if old_minor_version < 3:
82  # Import settings from the google_assistant store which was previously
83  # shared between the cloud integration and manually configured Google
84  # assistant.
85  # In HA Core 2024.9, remove the import and also remove the Google
86  # assistant store if it's not been migrated by manual Google assistant
87  old_data.setdefault(PREF_GOOGLE_CONNECTED, await google_connected())
88  if old_minor_version < 4:
89  # Update the default TTS voice to the new default.
90  # The default tts voice is a tuple.
91  # The first item is the language, the second item used to be gender.
92  # The new second item is the voice name.
93  default_tts_voice = old_data.get(PREF_TTS_DEFAULT_VOICE)
94  if default_tts_voice and (voice_item_two := default_tts_voice[1]) in (
95  Gender.FEMALE,
96  Gender.MALE,
97  ):
98  language: str = default_tts_voice[0]
99  if voice := MAP_VOICE.get((language, voice_item_two)):
100  old_data[PREF_TTS_DEFAULT_VOICE] = (
101  language,
102  voice,
103  )
104  else:
105  old_data[PREF_TTS_DEFAULT_VOICE] = DEFAULT_TTS_DEFAULT_VOICE
106 
107  return old_data
108 
109 
111  """Handle cloud preferences."""
112 
113  _prefs: dict[str, Any]
114 
115  def __init__(self, hass: HomeAssistant) -> None:
116  """Initialize cloud prefs."""
117  self._hass_hass = hass
119  hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
120  )
121  self._listeners: list[
122  Callable[[CloudPreferences], Coroutine[Any, Any, None]]
123  ] = []
124  self.last_updatedlast_updated: set[str] = set()
125 
126  async def async_initialize(self) -> None:
127  """Finish initializing the preferences."""
128  if (prefs := await self._store_store.async_load()) is None:
129  prefs = self._empty_config_empty_config("")
130 
131  self._prefs_prefs = prefs
132 
133  if PREF_GOOGLE_LOCAL_WEBHOOK_ID not in self._prefs_prefs:
134  await self._save_prefs_save_prefs(
135  {
136  **self._prefs_prefs,
137  PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(),
138  }
139  )
140  if PREF_INSTANCE_ID not in self._prefs_prefs:
141  await self._save_prefs_save_prefs(
142  {
143  **self._prefs_prefs,
144  PREF_INSTANCE_ID: uuid.uuid4().hex,
145  }
146  )
147 
148  @callback
150  self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]]
151  ) -> Callable[[], None]:
152  """Listen for updates to the preferences."""
153 
154  @callback
155  def unsubscribe() -> None:
156  """Remove the listener."""
157  self._listeners.remove(listener)
158 
159  self._listeners.append(listener)
160 
161  return unsubscribe
162 
163  async def async_update(
164  self,
165  *,
166  alexa_enabled: bool | UndefinedType = UNDEFINED,
167  alexa_report_state: bool | UndefinedType = UNDEFINED,
168  alexa_settings_version: int | UndefinedType = UNDEFINED,
169  cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
170  cloud_user: str | UndefinedType = UNDEFINED,
171  cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED,
172  google_connected: bool | UndefinedType = UNDEFINED,
173  google_enabled: bool | UndefinedType = UNDEFINED,
174  google_report_state: bool | UndefinedType = UNDEFINED,
175  google_secure_devices_pin: str | None | UndefinedType = UNDEFINED,
176  google_settings_version: int | UndefinedType = UNDEFINED,
177  remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
178  remote_domain: str | None | UndefinedType = UNDEFINED,
179  remote_enabled: bool | UndefinedType = UNDEFINED,
180  tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED,
181  ) -> None:
182  """Update user preferences."""
183  prefs = {**self._prefs_prefs}
184 
185  prefs.update(
186  {
187  key: value
188  for key, value in (
189  (PREF_ALEXA_REPORT_STATE, alexa_report_state),
190  (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version),
191  (PREF_CLOUD_USER, cloud_user),
192  (PREF_CLOUDHOOKS, cloudhooks),
193  (PREF_ENABLE_ALEXA, alexa_enabled),
194  (PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
195  (PREF_ENABLE_GOOGLE, google_enabled),
196  (PREF_ENABLE_REMOTE, remote_enabled),
197  (PREF_GOOGLE_CONNECTED, google_connected),
198  (PREF_GOOGLE_REPORT_STATE, google_report_state),
199  (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
200  (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version),
201  (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
202  (PREF_REMOTE_DOMAIN, remote_domain),
203  (PREF_TTS_DEFAULT_VOICE, tts_default_voice),
204  )
205  if value is not UNDEFINED
206  }
207  )
208 
209  await self._save_prefs_save_prefs(prefs)
210 
211  async def async_set_username(self, username: str | None) -> bool:
212  """Set the username that is logged in."""
213  # Logging out.
214  if username is None:
215  user = await self._load_cloud_user_load_cloud_user()
216 
217  if user is not None:
218  await self._hass_hass.auth.async_remove_user(user)
219  await self._save_prefs_save_prefs({**self._prefs_prefs, PREF_CLOUD_USER: None})
220  return False
221 
222  cur_username = self._prefs_prefs.get(PREF_USERNAME)
223 
224  if cur_username == username:
225  return False
226 
227  if cur_username is None:
228  await self._save_prefs_save_prefs({**self._prefs_prefs, PREF_USERNAME: username})
229  else:
230  await self._save_prefs_save_prefs(self._empty_config_empty_config(username))
231 
232  return True
233 
234  async def async_erase_config(self) -> None:
235  """Erase the configuration."""
236  await self._save_prefs_save_prefs(self._empty_config_empty_config(""))
237 
238  def as_dict(self) -> dict[str, Any]:
239  """Return dictionary version."""
240  return {
241  PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_exposealexa_default_expose,
242  PREF_ALEXA_REPORT_STATE: self.alexa_report_statealexa_report_state,
243  PREF_CLOUDHOOKS: self.cloudhookscloudhooks,
244  PREF_ENABLE_ALEXA: self.alexa_enabledalexa_enabled,
245  PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabledcloud_ice_servers_enabled,
246  PREF_ENABLE_GOOGLE: self.google_enabledgoogle_enabled,
247  PREF_ENABLE_REMOTE: self.remote_enabledremote_enabled,
248  PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_exposegoogle_default_expose,
249  PREF_GOOGLE_REPORT_STATE: self.google_report_stategoogle_report_state,
250  PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pingoogle_secure_devices_pin,
251  PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enableremote_allow_remote_enable,
252  PREF_TTS_DEFAULT_VOICE: self.tts_default_voicetts_default_voice,
253  }
254 
255  @property
256  def remote_allow_remote_enable(self) -> bool:
257  """Return if it's allowed to remotely activate remote."""
258  allowed: bool = self._prefs_prefs.get(PREF_REMOTE_ALLOW_REMOTE_ENABLE, True)
259  return allowed
260 
261  @property
262  def remote_enabled(self) -> bool:
263  """Return if remote is enabled on start."""
264  if not self._prefs_prefs.get(PREF_ENABLE_REMOTE, False):
265  return False
266 
267  return True
268 
269  @property
270  def remote_domain(self) -> str | None:
271  """Return remote domain."""
272  return self._prefs_prefs.get(PREF_REMOTE_DOMAIN)
273 
274  @property
275  def alexa_enabled(self) -> bool:
276  """Return if Alexa is enabled."""
277  alexa_enabled: bool = self._prefs_prefs[PREF_ENABLE_ALEXA]
278  return alexa_enabled
279 
280  @property
281  def alexa_report_state(self) -> bool:
282  """Return if Alexa report state is enabled."""
283  return self._prefs_prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) # type: ignore[no-any-return]
284 
285  @property
286  def alexa_default_expose(self) -> list[str] | None:
287  """Return array of entity domains that are exposed by default to Alexa.
288 
289  Can return None, in which case for backwards should be interpreted as allow all domains.
290  """
291  return self._prefs_prefs.get(PREF_ALEXA_DEFAULT_EXPOSE)
292 
293  @property
294  def alexa_entity_configs(self) -> dict[str, Any]:
295  """Return Alexa Entity configurations."""
296  return self._prefs_prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) # type: ignore[no-any-return]
297 
298  @property
299  def alexa_settings_version(self) -> int:
300  """Return version of Alexa settings."""
301  alexa_settings_version: int = self._prefs_prefs[PREF_ALEXA_SETTINGS_VERSION]
302  return alexa_settings_version
303 
304  @property
305  def google_enabled(self) -> bool:
306  """Return if Google is enabled."""
307  google_enabled: bool = self._prefs_prefs[PREF_ENABLE_GOOGLE]
308  return google_enabled
309 
310  @property
311  def google_connected(self) -> bool:
312  """Return if Google is connected."""
313  google_connected: bool = self._prefs_prefs[PREF_GOOGLE_CONNECTED]
314  return google_connected
315 
316  @property
317  def google_report_state(self) -> bool:
318  """Return if Google report state is enabled."""
319  return self._prefs_prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) # type: ignore[no-any-return]
320 
321  @property
322  def google_secure_devices_pin(self) -> str | None:
323  """Return if Google is allowed to unlock locks."""
324  return self._prefs_prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN)
325 
326  @property
327  def google_entity_configs(self) -> dict[str, dict[str, Any]]:
328  """Return Google Entity configurations."""
329  return self._prefs_prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) # type: ignore[no-any-return]
330 
331  @property
332  def google_settings_version(self) -> int:
333  """Return version of Google settings."""
334  google_settings_version: int = self._prefs_prefs[PREF_GOOGLE_SETTINGS_VERSION]
335  return google_settings_version
336 
337  @property
338  def google_local_webhook_id(self) -> str:
339  """Return Google webhook ID to receive local messages."""
340  google_local_webhook_id: str = self._prefs_prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID]
341  return google_local_webhook_id
342 
343  @property
344  def google_default_expose(self) -> list[str] | None:
345  """Return array of entity domains that are exposed by default to Google.
346 
347  Can return None, in which case for backwards should be interpreted as allow all domains.
348  """
349  return self._prefs_prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE)
350 
351  @property
352  def cloudhooks(self) -> dict[str, Any]:
353  """Return the published cloud webhooks."""
354  return self._prefs_prefs.get(PREF_CLOUDHOOKS, {}) # type: ignore[no-any-return]
355 
356  @property
357  def instance_id(self) -> str | None:
358  """Return the instance ID."""
359  return self._prefs_prefs.get(PREF_INSTANCE_ID)
360 
361  @property
362  def tts_default_voice(self) -> tuple[str, str]:
363  """Return the default TTS voice.
364 
365  The return value is a tuple of language and voice.
366  """
367  return self._prefs_prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
368 
369  @property
370  def cloud_ice_servers_enabled(self) -> bool:
371  """Return if cloud ICE servers are enabled."""
372  cloud_ice_servers_enabled: bool = self._prefs_prefs.get(
373  PREF_ENABLE_CLOUD_ICE_SERVERS, True
374  )
375  return cloud_ice_servers_enabled
376 
377  async def get_cloud_user(self) -> str:
378  """Return ID of Home Assistant Cloud system user."""
379  user = await self._load_cloud_user_load_cloud_user()
380 
381  if user:
382  return user.id
383 
384  user = await self._hass_hass.auth.async_create_system_user(
385  "Home Assistant Cloud", group_ids=[GROUP_ID_ADMIN], local_only=True
386  )
387  assert user is not None
388  await self.async_updateasync_update(cloud_user=user.id)
389  return user.id
390 
391  async def _load_cloud_user(self) -> User | None:
392  """Load cloud user if available."""
393  if (user_id := self._prefs_prefs.get(PREF_CLOUD_USER)) is None:
394  return None
395 
396  # Fetch the user. It can happen that the user no longer exists if
397  # an image was restored without restoring the cloud prefs.
398  return await self._hass_hass.auth.async_get_user(user_id)
399 
400  async def _save_prefs(self, prefs: dict[str, Any]) -> None:
401  """Save preferences to disk."""
402  self.last_updatedlast_updated = {
403  key for key, value in prefs.items() if value != self._prefs_prefs.get(key)
404  }
405  self._prefs_prefs = prefs
406  await self._store_store.async_save(self._prefs_prefs)
407 
408  for listener in self._listeners:
409  self._hass_hass.async_create_task(async_create_catching_coro(listener(self)))
410 
411  @callback
412  @staticmethod
413  def _empty_config(username: str) -> dict[str, Any]:
414  """Return an empty config."""
415  return {
416  PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
417  PREF_ALEXA_ENTITY_CONFIGS: {},
418  PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION,
419  PREF_CLOUD_USER: None,
420  PREF_CLOUDHOOKS: {},
421  PREF_ENABLE_ALEXA: True,
422  PREF_ENABLE_GOOGLE: True,
423  PREF_ENABLE_REMOTE: False,
424  PREF_ENABLE_CLOUD_ICE_SERVERS: True,
425  PREF_GOOGLE_CONNECTED: False,
426  PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
427  PREF_GOOGLE_ENTITY_CONFIGS: {},
428  PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION,
429  PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(),
430  PREF_INSTANCE_ID: uuid.uuid4().hex,
431  PREF_GOOGLE_SECURE_DEVICES_PIN: None,
432  PREF_REMOTE_DOMAIN: None,
433  PREF_REMOTE_ALLOW_REMOTE_ENABLE: True,
434  PREF_USERNAME: username,
435  }
dict[str, Any] _async_migrate_func(self, int old_major_version, int old_minor_version, dict[str, Any] old_data)
Definition: prefs.py:65
dict[str, Any] _empty_config(str username)
Definition: prefs.py:413
None _save_prefs(self, dict[str, Any] prefs)
Definition: prefs.py:400
bool async_set_username(self, str|None username)
Definition: prefs.py:211
dict[str, dict[str, Any]] google_entity_configs(self)
Definition: prefs.py:327
None __init__(self, HomeAssistant hass)
Definition: prefs.py:115
Callable[[], None] async_listen_updates(self, Callable[[CloudPreferences], Coroutine[Any, Any, None]] listener)
Definition: prefs.py:151
None async_update(self, *bool|UndefinedType alexa_enabled=UNDEFINED, bool|UndefinedType alexa_report_state=UNDEFINED, int|UndefinedType alexa_settings_version=UNDEFINED, bool|UndefinedType cloud_ice_servers_enabled=UNDEFINED, str|UndefinedType cloud_user=UNDEFINED, dict[str, dict[str, str|bool]]|UndefinedType cloudhooks=UNDEFINED, bool|UndefinedType google_connected=UNDEFINED, bool|UndefinedType google_enabled=UNDEFINED, bool|UndefinedType google_report_state=UNDEFINED, str|None|UndefinedType google_secure_devices_pin=UNDEFINED, int|UndefinedType google_settings_version=UNDEFINED, bool|UndefinedType remote_allow_remote_enable=UNDEFINED, str|None|UndefinedType remote_domain=UNDEFINED, bool|UndefinedType remote_enabled=UNDEFINED, tuple[str, str]|UndefinedType tts_default_voice=UNDEFINED)
Definition: prefs.py:181
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_load(HomeAssistant hass)
None async_save(self, _T data)
Definition: storage.py:424