Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor handling of exposed entities for cloud Alexa and Google #89877

Merged
merged 16 commits into from Apr 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Expand Up @@ -137,6 +137,7 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.homeassistant.exposed_entities
homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_hardware.*
Expand Down
76 changes: 55 additions & 21 deletions homeassistant/components/cloud/alexa_config.py
Expand Up @@ -20,6 +20,11 @@
errors as alexa_errors,
state_report as alexa_state_report,
)
from homeassistant.components.homeassistant.exposed_entities import (
async_get_assistant_settings,
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.helpers import entity_registry as er, start
Expand All @@ -30,16 +35,17 @@
from .const import (
CONF_ENTITY_CONFIG,
CONF_FILTER,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
DOMAIN as CLOUD_DOMAIN,
PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA,
PREF_SHOULD_EXPOSE,
)
from .prefs import CloudPreferences
from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences

_LOGGER = logging.getLogger(__name__)

CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"

# Time to wait when entity preferences have changed before syncing it to
# the cloud.
SYNC_DELAY = 1
Expand All @@ -64,7 +70,7 @@ def __init__(
self._cloud = cloud
self._token = None
self._token_valid = None
self._cur_entity_prefs = prefs.alexa_entity_configs
self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
self._alexa_sync_unsub: Callable[[], None] | None = None
self._endpoint = None

Expand Down Expand Up @@ -115,30 +121,51 @@ def user_identifier(self):
"""Return an identifier for the user that represents this config."""
return self._cloud_user

def _migrate_alexa_entity_settings_v1(self):
"""Migrate alexa entity settings to entity registry options."""
if not self._config[CONF_FILTER].empty_filter:
emontnemery marked this conversation as resolved.
Show resolved Hide resolved
# Don't migrate if there's a YAML config
return

entity_registry = er.async_get(self.hass)

for entity_id, entry in entity_registry.entities.items():
if CLOUD_ALEXA in entry.options:
continue
options = {"should_expose": self._should_expose_legacy(entity_id)}
entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options)

async def async_initialize(self):
"""Initialize the Alexa config."""
await super().async_initialize()

if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
if self._prefs.alexa_settings_version < 2:
self._migrate_alexa_entity_settings_v1()
await self._prefs.async_update(
alexa_settings_version=ALEXA_SETTINGS_VERSION
)

async def hass_started(hass):
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, ALEXA_DOMAIN, {})

start.async_at_start(self.hass, hass_started)

self._prefs.async_listen_updates(self._async_prefs_updated)
async_listen_entity_updates(
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
)
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entity_registry_updated,
)

def should_expose(self, entity_id):
def _should_expose_legacy(self, entity_id):
"""If an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False

if not self._config[CONF_FILTER].empty_filter:
return self._config[CONF_FILTER](entity_id)

entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
Expand All @@ -160,6 +187,15 @@ def should_expose(self, entity_id):

return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose

def should_expose(self, entity_id):
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
return self._config[CONF_FILTER](entity_id)

return async_should_expose(self.hass, CLOUD_ALEXA, entity_id)

@callback
def async_invalidate_access_token(self):
"""Invalidate access token."""
Expand Down Expand Up @@ -233,32 +269,30 @@ async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:
if not any(
key in updated_prefs
for key in (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE,
PREF_ENABLE_ALEXA,
)
):
return

# If we update just entity preferences, delay updating
# as we might update more
if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}:
if self._alexa_sync_unsub:
self._alexa_sync_unsub()
await self.async_sync_entities()

self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs
)
return
@callback
def _async_exposed_entities_updated(self) -> None:
"""Handle updated preferences."""
# Delay updating as we might update more
if self._alexa_sync_unsub:
self._alexa_sync_unsub()

await self.async_sync_entities()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs
)

async def _sync_prefs(self, _now):
"""Sync the updated preferences to Alexa."""
self._alexa_sync_unsub = None
old_prefs = self._cur_entity_prefs
new_prefs = self._prefs.alexa_entity_configs
new_prefs = async_get_assistant_settings(self.hass, CLOUD_ALEXA)

seen = set()
to_update = []
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/cloud/const.py
Expand Up @@ -19,6 +19,8 @@
PREF_REMOTE_DOMAIN = "remote_domain"
PREF_ALEXA_DEFAULT_EXPOSE = "alexa_default_expose"
PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose"
PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version"
PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female")
DEFAULT_DISABLE_2FA = False
Expand Down
89 changes: 67 additions & 22 deletions homeassistant/components/cloud/google_config.py
Expand Up @@ -9,6 +9,10 @@

from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
from homeassistant.components.google_assistant.helpers import AbstractConfig
from homeassistant.components.homeassistant.exposed_entities import (
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import (
CoreState,
Expand All @@ -22,14 +26,18 @@

from .const import (
CONF_ENTITY_CONFIG,
CONF_FILTER,
DEFAULT_DISABLE_2FA,
DOMAIN as CLOUD_DOMAIN,
PREF_DISABLE_2FA,
PREF_SHOULD_EXPOSE,
)
from .prefs import CloudPreferences
from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences

_LOGGER = logging.getLogger(__name__)

CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"


class CloudGoogleConfig(AbstractConfig):
"""HA Cloud Configuration for Google Assistant."""
Expand All @@ -48,8 +56,6 @@ def __init__(
self._user = cloud_user
self._prefs = prefs
self._cloud = cloud
self._cur_entity_prefs = self._prefs.google_entity_configs
self._cur_default_expose = self._prefs.google_default_expose
self._sync_entities_lock = asyncio.Lock()

@property
Expand Down Expand Up @@ -89,10 +95,35 @@ def cloud_user(self):
"""Return Cloud User account."""
return self._user

def _migrate_google_entity_settings_v1(self):
"""Migrate Google entity settings to entity registry options."""
if not self._config[CONF_FILTER].empty_filter:
# Don't migrate if there's a YAML config
return

entity_registry = er.async_get(self.hass)

for entity_id, entry in entity_registry.entities.items():
if CLOUD_GOOGLE in entry.options:
continue
options = {"should_expose": self._should_expose_legacy(entity_id)}
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
options[PREF_DISABLE_2FA] = _2fa_disabled
entity_registry.async_update_entity_options(
entity_id, CLOUD_GOOGLE, options
)

async def async_initialize(self):
"""Perform async initialization of config."""
await super().async_initialize()

if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
if self._prefs.google_settings_version < 2:
self._migrate_google_entity_settings_v1()
await self._prefs.async_update(
google_settings_version=GOOGLE_SETTINGS_VERSION
)

async def hass_started(hass):
if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, GOOGLE_DOMAIN, {})
Expand All @@ -109,7 +140,9 @@ async def hass_started(hass):
await self.async_disconnect_agent_user(agent_user_id)

self._prefs.async_listen_updates(self._async_prefs_updated)

async_listen_entity_updates(
self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated
)
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entity_registry_updated,
Expand All @@ -123,14 +156,11 @@ def should_expose(self, state):
"""If a state object should be exposed."""
return self._should_expose_entity_id(state.entity_id)

def _should_expose_entity_id(self, entity_id):
def _should_expose_legacy(self, entity_id):
"""If an entity ID should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False

if not self._config["filter"].empty_filter:
return self._config["filter"](entity_id)

entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(entity_id, {})
entity_expose = entity_config.get(PREF_SHOULD_EXPOSE)
Expand All @@ -154,6 +184,15 @@ def _should_expose_entity_id(self, entity_id):

return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose

def _should_expose_entity_id(self, entity_id):
"""If an entity should be exposed."""
if not self._config[CONF_FILTER].empty_filter:
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
return self._config[CONF_FILTER](entity_id)

return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id)

@property
def agent_user_id(self):
"""Return Agent User Id to use for query responses."""
Expand All @@ -168,11 +207,23 @@ def get_agent_user_id(self, context):
"""Get agent user ID making request."""
return self.agent_user_id

def should_2fa(self, state):
def _2fa_disabled_legacy(self, entity_id):
"""If an entity should be checked for 2FA."""
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(state.entity_id, {})
return not entity_config.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
entity_config = entity_configs.get(entity_id, {})
return entity_config.get(PREF_DISABLE_2FA)

def should_2fa(self, state):
"""If an entity should be checked for 2FA."""
entity_registry = er.async_get(self.hass)

registry_entry = entity_registry.async_get(state.entity_id)
if not registry_entry:
# Handle the entity has been removed
return False

assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {})
return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)

async def async_report_state(self, message, agent_user_id: str):
"""Send a state report to Google."""
Expand Down Expand Up @@ -218,27 +269,21 @@ async def _async_prefs_updated(self, prefs):
# So when we change it, we need to sync all entities.
sync_entities = True

# If entity prefs are the same or we have filter in config.yaml,
# don't sync.
elif (
self._cur_entity_prefs is not prefs.google_entity_configs
or self._cur_default_expose is not prefs.google_default_expose
) and self._config["filter"].empty_filter:
self.async_schedule_google_sync_all()

if self.enabled and not self.is_local_sdk_active:
self.async_enable_local_sdk()
sync_entities = True
elif not self.enabled and self.is_local_sdk_active:
self.async_disable_local_sdk()
sync_entities = True

self._cur_entity_prefs = prefs.google_entity_configs
self._cur_default_expose = prefs.google_default_expose

if sync_entities and self.hass.is_running:
await self.async_sync_entities_all()

@callback
def _async_exposed_entities_updated(self) -> None:
"""Handle updated preferences."""
self.async_schedule_google_sync_all()

@callback
def _handle_entity_registry_updated(self, event: Event) -> None:
"""Handle when entity registry updated."""
Expand Down