Merge branch 'dev' into androidtv_remote

This commit is contained in:
tronikos
2023-04-06 13:43:47 -07:00
committed by GitHub
82 changed files with 2662 additions and 977 deletions

View File

@@ -40,7 +40,9 @@ env:
# - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023)
# 10.10 is the latest short-term-support
# - 10.10.3 is the latest (as of 6 Feb 2023)
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3']"
# mysql 8.0.32 does not always behave the same as MariaDB
# and some queries that work on MariaDB do not work on MySQL
MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']"
# 12 is the oldest supported version
# - 12.14 is the latest (as of 9 Feb 2023)
# 15 is the latest version

View File

@@ -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.*

View File

@@ -217,8 +217,6 @@ build.json @home-assistant/supervisor
/tests/components/conversation/ @home-assistant/core @synesthesiam
/homeassistant/components/coolmaster/ @OnFreund
/tests/components/coolmaster/ @OnFreund
/homeassistant/components/coronavirus/ @home-assistant/core
/tests/components/coronavirus/ @home-assistant/core
/homeassistant/components/counter/ @fabaff
/tests/components/counter/ @fabaff
/homeassistant/components/cover/ @home-assistant/core

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["aioambient"],
"requirements": ["aioambient==2021.11.0"]
"requirements": ["aioambient==2022.10.0"]
}

View File

@@ -75,7 +75,7 @@ class AuthorizationServer:
token_url: str
class ApplicationCredentialsStorageCollection(collection.StorageCollection):
class ApplicationCredentialsStorageCollection(collection.DictStorageCollection):
"""Application credential collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
@@ -94,7 +94,7 @@ class ApplicationCredentialsStorageCollection(collection.StorageCollection):
return f"{info[CONF_DOMAIN]}.{info[CONF_CLIENT_ID]}"
async def _update_data(
self, data: dict[str, str], update_data: dict[str, str]
self, item: dict[str, str], update_data: dict[str, str]
) -> dict[str, str]:
"""Return a new updated data object."""
raise ValueError("Updates not supported")
@@ -144,7 +144,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
id_manager = collection.IDManager()
storage_collection = ApplicationCredentialsStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
await storage_collection.async_load()

View File

@@ -20,6 +20,11 @@ from homeassistant.components.alexa import (
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
@@ -30,16 +35,17 @@ from homeassistant.util.dt import utcnow
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
@@ -64,7 +70,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
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
@@ -115,10 +121,31 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
"""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:
# 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, {})
@@ -126,19 +153,19 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
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)
@@ -160,6 +187,15 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
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."""
@@ -233,32 +269,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
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()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs
)
return
await self.async_sync_entities()
@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()
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 = []

View File

@@ -19,6 +19,8 @@ PREF_USERNAME = "username"
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

View File

@@ -9,6 +9,10 @@ from hass_nabucasa.google_report_state import ErrorResponse
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,
@@ -22,14 +26,18 @@ from homeassistant.setup import async_setup_component
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."""
@@ -48,8 +56,6 @@ class CloudGoogleConfig(AbstractConfig):
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
@@ -89,10 +95,35 @@ class CloudGoogleConfig(AbstractConfig):
"""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, {})
@@ -109,7 +140,9 @@ class CloudGoogleConfig(AbstractConfig):
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,
@@ -123,14 +156,11 @@ class CloudGoogleConfig(AbstractConfig):
"""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)
@@ -154,6 +184,15 @@ class CloudGoogleConfig(AbstractConfig):
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."""
@@ -168,11 +207,23 @@ class CloudGoogleConfig(AbstractConfig):
"""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."""
@@ -218,14 +269,6 @@ class CloudGoogleConfig(AbstractConfig):
# 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
@@ -233,12 +276,14 @@ class CloudGoogleConfig(AbstractConfig):
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."""

View File

@@ -1,5 +1,6 @@
"""The HTTP api to control the cloud integration."""
import asyncio
from collections.abc import Mapping
import dataclasses
from functools import wraps
from http import HTTPStatus
@@ -22,22 +23,24 @@ from homeassistant.components.alexa import (
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.location import async_detect_location_info
from .const import (
DOMAIN,
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_TTS_DEFAULT_VOICE,
REQUEST_TIMEOUT,
)
from .google_config import CLOUD_GOOGLE
from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info
@@ -66,11 +69,11 @@ async def async_setup(hass):
websocket_api.async_register_command(hass, websocket_remote_connect)
websocket_api.async_register_command(hass, websocket_remote_disconnect)
websocket_api.async_register_command(hass, google_assistant_get)
websocket_api.async_register_command(hass, google_assistant_list)
websocket_api.async_register_command(hass, google_assistant_update)
websocket_api.async_register_command(hass, alexa_list)
websocket_api.async_register_command(hass, alexa_update)
websocket_api.async_register_command(hass, alexa_sync)
websocket_api.async_register_command(hass, thingtalk_convert)
@@ -350,8 +353,6 @@ async def websocket_subscription(
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_REPORT_STATE): bool,
vol.Optional(PREF_ALEXA_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_DEFAULT_EXPOSE): [str],
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All(
vol.Coerce(tuple), vol.In(MAP_VOICE)
@@ -523,6 +524,54 @@ async def websocket_remote_disconnect(
connection.send_result(msg["id"], await _account_data(hass, cloud))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/google_assistant/entities/get",
"entity_id": str,
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def google_assistant_get(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get data for a single google assistant entity."""
cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
entity_registry = er.async_get(hass)
entity_id: str = msg["entity_id"]
state = hass.states.get(entity_id)
if not entity_registry.async_is_registered(entity_id) or not state:
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_FOUND,
f"{entity_id} unknown or not in the entity registry",
)
return
entity = google_helpers.GoogleEntity(hass, gconf, state)
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity.is_supported():
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_SUPPORTED,
f"{entity_id} not supported by Google assistant",
)
return
result = {
"entity_id": entity.entity_id,
"traits": [trait.name for trait in entity.traits()],
"might_2fa": entity.might_2fa_traits(),
}
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/google_assistant/entities"})
@@ -536,11 +585,14 @@ async def google_assistant_list(
"""List all google assistant entities."""
cloud = hass.data[DOMAIN]
gconf = await cloud.client.get_google_config()
entity_registry = er.async_get(hass)
entities = google_helpers.async_get_entities(hass, gconf)
result = []
for entity in entities:
if not entity_registry.async_is_registered(entity.entity_id):
continue
result.append(
{
"entity_id": entity.entity_id,
@@ -558,8 +610,7 @@ async def google_assistant_list(
{
"type": "cloud/google_assistant/entities/update",
"entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool),
vol.Optional("disable_2fa"): bool,
vol.Optional(PREF_DISABLE_2FA): bool,
}
)
@websocket_api.async_response
@@ -569,17 +620,30 @@ async def google_assistant_update(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update google assistant config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("type")
changes.pop("id")
"""Update google assistant entity config."""
entity_registry = er.async_get(hass)
entity_id: str = msg["entity_id"]
await cloud.client.prefs.async_update_google_entity_config(**changes)
if not (registry_entry := entity_registry.async_get(entity_id)):
connection.send_error(
msg["id"],
websocket_api.const.ERR_NOT_ALLOWED,
f"can't configure {entity_id}",
)
return
connection.send_result(
msg["id"], cloud.client.prefs.google_entity_configs.get(msg["entity_id"])
disable_2fa = msg[PREF_DISABLE_2FA]
assistant_options: Mapping[str, Any]
if (
assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {})
) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa:
return
assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa}
entity_registry.async_update_entity_options(
entity_id, CLOUD_GOOGLE, assistant_options
)
connection.send_result(msg["id"])
@websocket_api.require_admin
@@ -595,11 +659,14 @@ async def alexa_list(
"""List all alexa entities."""
cloud = hass.data[DOMAIN]
alexa_config = await cloud.client.get_alexa_config()
entity_registry = er.async_get(hass)
entities = alexa_entities.async_get_entities(hass, alexa_config)
result = []
for entity in entities:
if not entity_registry.async_is_registered(entity.entity_id):
continue
result.append(
{
"entity_id": entity.entity_id,
@@ -611,35 +678,6 @@ async def alexa_list(
connection.send_result(msg["id"], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
"type": "cloud/alexa/entities/update",
"entity_id": str,
vol.Optional("should_expose"): vol.Any(None, bool),
}
)
@websocket_api.async_response
@_ws_handle_cloud_errors
async def alexa_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update alexa entity config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop("type")
changes.pop("id")
await cloud.client.prefs.async_update_alexa_entity_config(**changes)
connection.send_result(
msg["id"], cloud.client.prefs.alexa_entity_configs.get(msg["entity_id"])
)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({"type": "cloud/alexa/sync"})

View File

@@ -3,7 +3,7 @@
"name": "Home Assistant Cloud",
"after_dependencies": ["google_assistant", "alexa"],
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["http", "webhook"],
"dependencies": ["homeassistant", "http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",

View File

@@ -1,6 +1,8 @@
"""Preference management for cloud."""
from __future__ import annotations
from typing import Any
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
from homeassistant.components import webhook
@@ -18,9 +20,9 @@ from .const import (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_ALEXA_REPORT_STATE,
PREF_ALEXA_SETTINGS_VERSION,
PREF_CLOUD_USER,
PREF_CLOUDHOOKS,
PREF_DISABLE_2FA,
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
PREF_ENABLE_REMOTE,
@@ -29,14 +31,33 @@ from .const import (
PREF_GOOGLE_LOCAL_WEBHOOK_ID,
PREF_GOOGLE_REPORT_STATE,
PREF_GOOGLE_SECURE_DEVICES_PIN,
PREF_GOOGLE_SETTINGS_VERSION,
PREF_REMOTE_DOMAIN,
PREF_SHOULD_EXPOSE,
PREF_TTS_DEFAULT_VOICE,
PREF_USERNAME,
)
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
ALEXA_SETTINGS_VERSION = 2
GOOGLE_SETTINGS_VERSION = 2
class CloudPreferencesStore(Store):
"""Store entity registry data."""
async def _async_migrate_func(
self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any]
) -> dict[str, Any]:
"""Migrate to the new version."""
if old_major_version == 1:
if old_minor_version < 2:
old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1)
old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1)
return old_data
class CloudPreferences:
@@ -45,7 +66,9 @@ class CloudPreferences:
def __init__(self, hass):
"""Initialize cloud prefs."""
self._hass = hass
self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
self._store = CloudPreferencesStore(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
self._prefs = None
self._listeners = []
self.last_updated: set[str] = set()
@@ -79,14 +102,12 @@ class CloudPreferences:
google_secure_devices_pin=UNDEFINED,
cloudhooks=UNDEFINED,
cloud_user=UNDEFINED,
google_entity_configs=UNDEFINED,
alexa_entity_configs=UNDEFINED,
alexa_report_state=UNDEFINED,
google_report_state=UNDEFINED,
alexa_default_expose=UNDEFINED,
google_default_expose=UNDEFINED,
tts_default_voice=UNDEFINED,
remote_domain=UNDEFINED,
alexa_settings_version=UNDEFINED,
google_settings_version=UNDEFINED,
):
"""Update user preferences."""
prefs = {**self._prefs}
@@ -98,12 +119,10 @@ class CloudPreferences:
(PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
(PREF_CLOUDHOOKS, cloudhooks),
(PREF_CLOUD_USER, cloud_user),
(PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
(PREF_ALEXA_ENTITY_CONFIGS, alexa_entity_configs),
(PREF_ALEXA_REPORT_STATE, alexa_report_state),
(PREF_GOOGLE_REPORT_STATE, google_report_state),
(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),
(PREF_REMOTE_DOMAIN, remote_domain),
):
@@ -112,53 +131,6 @@ class CloudPreferences:
await self._save_prefs(prefs)
async def async_update_google_entity_config(
self,
*,
entity_id,
disable_2fa=UNDEFINED,
should_expose=UNDEFINED,
):
"""Update config for a Google entity."""
entities = self.google_entity_configs
entity = entities.get(entity_id, {})
changes = {}
for key, value in (
(PREF_DISABLE_2FA, disable_2fa),
(PREF_SHOULD_EXPOSE, should_expose),
):
if value is not UNDEFINED:
changes[key] = value
if not changes:
return
updated_entity = {**entity, **changes}
updated_entities = {**entities, entity_id: updated_entity}
await self.async_update(google_entity_configs=updated_entities)
async def async_update_alexa_entity_config(
self, *, entity_id, should_expose=UNDEFINED
):
"""Update config for an Alexa entity."""
entities = self.alexa_entity_configs
entity = entities.get(entity_id, {})
changes = {}
for key, value in ((PREF_SHOULD_EXPOSE, should_expose),):
if value is not UNDEFINED:
changes[key] = value
if not changes:
return
updated_entity = {**entity, **changes}
updated_entities = {**entities, entity_id: updated_entity}
await self.async_update(alexa_entity_configs=updated_entities)
async def async_set_username(self, username) -> bool:
"""Set the username that is logged in."""
# Logging out.
@@ -186,14 +158,12 @@ class CloudPreferences:
"""Return dictionary version."""
return {
PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose,
PREF_ALEXA_ENTITY_CONFIGS: self.alexa_entity_configs,
PREF_ALEXA_REPORT_STATE: self.alexa_report_state,
PREF_CLOUDHOOKS: self.cloudhooks,
PREF_ENABLE_ALEXA: self.alexa_enabled,
PREF_ENABLE_GOOGLE: self.google_enabled,
PREF_ENABLE_REMOTE: self.remote_enabled,
PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose,
PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
PREF_GOOGLE_REPORT_STATE: self.google_report_state,
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
@@ -235,6 +205,11 @@ class CloudPreferences:
"""Return Alexa Entity configurations."""
return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {})
@property
def alexa_settings_version(self):
"""Return version of Alexa settings."""
return self._prefs[PREF_ALEXA_SETTINGS_VERSION]
@property
def google_enabled(self):
"""Return if Google is enabled."""
@@ -255,6 +230,11 @@ class CloudPreferences:
"""Return Google Entity configurations."""
return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
@property
def google_settings_version(self):
"""Return version of Google settings."""
return self._prefs[PREF_GOOGLE_SETTINGS_VERSION]
@property
def google_local_webhook_id(self):
"""Return Google webhook ID to receive local messages."""
@@ -319,6 +299,7 @@ class CloudPreferences:
return {
PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_ALEXA_ENTITY_CONFIGS: {},
PREF_ALEXA_SETTINGS_VERSION: ALEXA_SETTINGS_VERSION,
PREF_CLOUD_USER: None,
PREF_CLOUDHOOKS: {},
PREF_ENABLE_ALEXA: True,
@@ -326,6 +307,7 @@ class CloudPreferences:
PREF_ENABLE_REMOTE: False,
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
PREF_GOOGLE_ENTITY_CONFIGS: {},
PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION,
PREF_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(),
PREF_GOOGLE_SECURE_DEVICES_PIN: None,
PREF_REMOTE_DOMAIN: None,

View File

@@ -4,11 +4,16 @@ from hass_nabucasa import Cloud
from hass_nabucasa.voice import MAP_VOICE, AudioOutput, VoiceError
import voluptuous as vol
from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
from homeassistant.components.tts import (
ATTR_AUDIO_OUTPUT,
CONF_LANG,
PLATFORM_SCHEMA,
Provider,
)
from .const import DOMAIN
CONF_GENDER = "gender"
ATTR_GENDER = "gender"
SUPPORT_LANGUAGES = list({key[0] for key in MAP_VOICE})
@@ -18,8 +23,8 @@ def validate_lang(value):
if (lang := value.get(CONF_LANG)) is None:
return value
if (gender := value.get(CONF_GENDER)) is None:
gender = value[CONF_GENDER] = next(
if (gender := value.get(ATTR_GENDER)) is None:
gender = value[ATTR_GENDER] = next(
(chk_gender for chk_lang, chk_gender in MAP_VOICE if chk_lang == lang), None
)
@@ -33,7 +38,7 @@ PLATFORM_SCHEMA = vol.All(
PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_LANG): str,
vol.Optional(CONF_GENDER): str,
vol.Optional(ATTR_GENDER): str,
}
),
validate_lang,
@@ -49,7 +54,7 @@ async def async_get_engine(hass, config, discovery_info=None):
gender = None
else:
language = config[CONF_LANG]
gender = config[CONF_GENDER]
gender = config[ATTR_GENDER]
return CloudProvider(cloud, language, gender)
@@ -87,12 +92,15 @@ class CloudProvider(Provider):
@property
def supported_options(self):
"""Return list of supported options like voice, emotion."""
return [CONF_GENDER]
return [ATTR_GENDER, ATTR_AUDIO_OUTPUT]
@property
def default_options(self):
"""Return a dict include default options."""
return {CONF_GENDER: self._gender}
return {
ATTR_GENDER: self._gender,
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
}
async def async_get_tts_audio(self, message, language, options=None):
"""Load TTS from NabuCasa Cloud."""
@@ -101,10 +109,10 @@ class CloudProvider(Provider):
data = await self.cloud.voice.process_tts(
message,
language,
gender=options[CONF_GENDER],
output=AudioOutput.MP3,
gender=options[ATTR_GENDER],
output=options[ATTR_AUDIO_OUTPUT],
)
except VoiceError:
return (None, None)
return ("mp3", data)
return (str(options[ATTR_AUDIO_OUTPUT]), data)

View File

@@ -137,8 +137,11 @@ class CommandSensor(SensorEntity):
_LOGGER.warning("Unable to parse output as JSON: %s", value)
else:
_LOGGER.warning("Empty reply found when expecting JSON data")
if self._value_template is None:
self._attr_native_value = None
return
elif self._value_template is not None:
if self._value_template is not None:
self._attr_native_value = (
self._value_template.async_render_with_possible_json_value(
value,

View File

@@ -1,88 +0,0 @@
"""The Coronavirus integration."""
from datetime import timedelta
import logging
import async_timeout
import coronavirus
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
aiohttp_client,
entity_registry as er,
update_coordinator,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
PLATFORMS = [Platform.SENSOR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Coronavirus component."""
# Make sure coordinator is initialized.
await get_coordinator(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Coronavirus from a config entry."""
if isinstance(entry.data["country"], int):
hass.config_entries.async_update_entry(
entry, data={**entry.data, "country": entry.title}
)
@callback
def _async_migrator(entity_entry: er.RegistryEntry):
"""Migrate away from unstable ID."""
country, info_type = entity_entry.unique_id.rsplit("-", 1)
if not country.isnumeric():
return None
return {"new_unique_id": f"{entry.title}-{info_type}"}
await er.async_migrate_entries(hass, entry.entry_id, _async_migrator)
if not entry.unique_id:
hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"])
coordinator = await get_coordinator(hass)
if not coordinator.last_update_success:
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def get_coordinator(
hass: HomeAssistant,
) -> update_coordinator.DataUpdateCoordinator:
"""Get the data update coordinator."""
if DOMAIN in hass.data:
return hass.data[DOMAIN]
async def async_get_cases():
async with async_timeout.timeout(10):
return {
case.country: case
for case in await coronavirus.get_cases(
aiohttp_client.async_get_clientsession(hass)
)
}
hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator(
hass,
logging.getLogger(__name__),
name=DOMAIN,
update_method=async_get_cases,
update_interval=timedelta(hours=1),
)
await hass.data[DOMAIN].async_refresh()
return hass.data[DOMAIN]

View File

@@ -1,50 +0,0 @@
"""Config flow for Coronavirus integration."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult
from . import get_coordinator
from .const import DOMAIN, OPTION_WORLDWIDE
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Coronavirus."""
VERSION = 1
_options: dict[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if self._options is None:
coordinator = await get_coordinator(self.hass)
if not coordinator.last_update_success or coordinator.data is None:
return self.async_abort(reason="cannot_connect")
self._options = {OPTION_WORLDWIDE: "Worldwide"}
for case in sorted(
coordinator.data.values(), key=lambda case: case.country
):
self._options[case.country] = case.country
if user_input is not None:
await self.async_set_unique_id(user_input["country"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self._options[user_input["country"]], data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}),
errors=errors,
)

View File

@@ -1,6 +0,0 @@
"""Constants for the Coronavirus integration."""
from coronavirus import DEFAULT_SOURCE
DOMAIN = "coronavirus"
OPTION_WORLDWIDE = "__worldwide"
ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}"

View File

@@ -1,10 +0,0 @@
{
"domain": "coronavirus",
"name": "Coronavirus (COVID-19)",
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/coronavirus",
"iot_class": "cloud_polling",
"loggers": ["coronavirus"],
"requirements": ["coronavirus==1.1.1"]
}

View File

@@ -1,73 +0,0 @@
"""Sensor platform for the Corona virus."""
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import get_coordinator
from .const import ATTRIBUTION, OPTION_WORLDWIDE
SENSORS = {
"confirmed": "mdi:emoticon-neutral-outline",
"current": "mdi:emoticon-sad-outline",
"recovered": "mdi:emoticon-happy-outline",
"deaths": "mdi:emoticon-cry-outline",
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = await get_coordinator(hass)
async_add_entities(
CoronavirusSensor(coordinator, config_entry.data["country"], info_type)
for info_type in SENSORS
)
class CoronavirusSensor(CoordinatorEntity, SensorEntity):
"""Sensor representing corona virus data."""
_attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement = "people"
def __init__(self, coordinator, country, info_type):
"""Initialize coronavirus sensor."""
super().__init__(coordinator)
self._attr_icon = SENSORS[info_type]
self._attr_unique_id = f"{country}-{info_type}"
if country == OPTION_WORLDWIDE:
self._attr_name = f"Worldwide Coronavirus {info_type}"
else:
self._attr_name = (
f"{coordinator.data[country].country} Coronavirus {info_type}"
)
self.country = country
self.info_type = info_type
@property
def available(self) -> bool:
"""Return if sensor is available."""
return self.coordinator.last_update_success and (
self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE
)
@property
def native_value(self):
"""State of the sensor."""
if self.country == OPTION_WORLDWIDE:
sum_cases = 0
for case in self.coordinator.data.values():
if (value := getattr(case, self.info_type)) is None:
continue
sum_cases += value
return sum_cases
return getattr(self.coordinator.data[self.country], self.info_type)

View File

@@ -1,14 +0,0 @@
{
"config": {
"step": {
"user": {
"title": "Pick a country to monitor",
"data": { "country": "Country" }
}
},
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
}
}

View File

@@ -106,7 +106,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = CounterStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(
@@ -140,7 +139,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class CounterStorageCollection(collection.StorageCollection):
class CounterStorageCollection(collection.DictStorageCollection):
"""Input storage based collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS)
@@ -154,10 +153,10 @@ class CounterStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
class Counter(collection.CollectionEntity, RestoreEntity):

View File

@@ -345,10 +345,13 @@ async def async_setup_entry( # noqa: C901
disconnect_cb()
entry_data.disconnect_callbacks = []
entry_data.available = False
# Clear out the states so that we will always dispatch
# Mark state as stale so that we will always dispatch
# the next state update of that type when the device reconnects
for state_keys in entry_data.state.values():
state_keys.clear()
entry_data.stale_state = {
(type(entity_state), key)
for state_dict in entry_data.state.values()
for key, entity_state in state_dict.items()
}
if not hass.is_stopping:
# Avoid marking every esphome entity as unavailable on shutdown
# since it generates a lot of state changed events and database

View File

@@ -70,6 +70,10 @@ class RuntimeEntryData:
client: APIClient
store: Store
state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict)
# When the disconnect callback is called, we mark all states
# as stale so we will always dispatch a state update when the
# device reconnects. This is the same format as state_subscriptions.
stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set)
info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict)
# A second list of EntityInfo objects
@@ -206,9 +210,11 @@ class RuntimeEntryData:
"""Distribute an update of state information to the target."""
key = state.key
state_type = type(state)
stale_state = self.stale_state
current_state_by_type = self.state[state_type]
current_state = current_state_by_type.get(key, _SENTINEL)
if current_state == state:
subscription_key = (state_type, key)
if current_state == state and subscription_key not in stale_state:
_LOGGER.debug(
"%s: ignoring duplicate update with and key %s: %s",
self.name,
@@ -222,8 +228,8 @@ class RuntimeEntryData:
key,
state,
)
stale_state.discard(subscription_key)
current_state_by_type[key] = state
subscription_key = (state_type, key)
if subscription_key in self.state_subscriptions:
self.state_subscriptions[subscription_key]()

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20230405.0"]
"requirements": ["home-assistant-frontend==20230406.1"]
}

View File

@@ -33,10 +33,12 @@ from homeassistant.helpers.service import (
from homeassistant.helpers.template import async_load_custom_templates
from homeassistant.helpers.typing import ConfigType
from .const import DATA_EXPOSED_ENTITIES, DOMAIN
from .exposed_entities import ExposedEntities
ATTR_ENTRY_ID = "entry_id"
_LOGGER = logging.getLogger(__name__)
DOMAIN = ha.DOMAIN
SERVICE_RELOAD_CORE_CONFIG = "reload_core_config"
SERVICE_RELOAD_CONFIG_ENTRY = "reload_config_entry"
SERVICE_RELOAD_CUSTOM_TEMPLATES = "reload_custom_templates"
@@ -340,4 +342,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no
hass, ha.DOMAIN, SERVICE_RELOAD_ALL, async_handle_reload_all
)
exposed_entities = ExposedEntities(hass)
await exposed_entities.async_initialize()
hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities
return True

View File

@@ -0,0 +1,6 @@
"""Constants for the Homeassistant integration."""
import homeassistant.core as ha
DOMAIN = ha.DOMAIN
DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites"

View File

@@ -0,0 +1,351 @@
"""Control which entities are exposed to voice assistants."""
from __future__ import annotations
from collections.abc import Callable, Mapping
import dataclasses
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.storage import Store
from .const import DATA_EXPOSED_ENTITIES, DOMAIN
KNOWN_ASSISTANTS = ("cloud.alexa", "cloud.google_assistant")
STORAGE_KEY = f"{DOMAIN}.exposed_entities"
STORAGE_VERSION = 1
SAVE_DELAY = 10
DEFAULT_EXPOSED_DOMAINS = {
"climate",
"cover",
"fan",
"humidifier",
"light",
"lock",
"scene",
"script",
"switch",
"vacuum",
"water_heater",
}
DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES = {
BinarySensorDeviceClass.DOOR,
BinarySensorDeviceClass.GARAGE_DOOR,
BinarySensorDeviceClass.LOCK,
BinarySensorDeviceClass.MOTION,
BinarySensorDeviceClass.OPENING,
BinarySensorDeviceClass.PRESENCE,
BinarySensorDeviceClass.WINDOW,
}
DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES = {
SensorDeviceClass.AQI,
SensorDeviceClass.CO,
SensorDeviceClass.CO2,
SensorDeviceClass.HUMIDITY,
SensorDeviceClass.PM10,
SensorDeviceClass.PM25,
SensorDeviceClass.TEMPERATURE,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
}
@dataclasses.dataclass(frozen=True)
class AssistantPreferences:
"""Preferences for an assistant."""
expose_new: bool
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {"expose_new": self.expose_new}
class ExposedEntities:
"""Control assistant settings."""
_assistants: dict[str, AssistantPreferences]
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize."""
self._hass = hass
self._listeners: dict[str, list[Callable[[], None]]] = {}
self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store(
hass, STORAGE_VERSION, STORAGE_KEY
)
async def async_initialize(self) -> None:
"""Finish initializing."""
websocket_api.async_register_command(self._hass, ws_expose_entity)
websocket_api.async_register_command(self._hass, ws_expose_new_entities_get)
websocket_api.async_register_command(self._hass, ws_expose_new_entities_set)
await self.async_load()
@callback
def async_listen_entity_updates(
self, assistant: str, listener: Callable[[], None]
) -> None:
"""Listen for updates to entity expose settings."""
self._listeners.setdefault(assistant, []).append(listener)
@callback
def async_expose_entity(
self, assistant: str, entity_id: str, should_expose: bool
) -> None:
"""Expose an entity to an assistant.
Notify listeners if expose flag was changed.
"""
entity_registry = er.async_get(self._hass)
if not (registry_entry := entity_registry.async_get(entity_id)):
raise HomeAssistantError("Unknown entity")
assistant_options: Mapping[str, Any]
if (
assistant_options := registry_entry.options.get(assistant, {})
) and assistant_options.get("should_expose") == should_expose:
return
assistant_options = assistant_options | {"should_expose": should_expose}
entity_registry.async_update_entity_options(
entity_id, assistant, assistant_options
)
for listener in self._listeners.get(assistant, []):
listener()
@callback
def async_get_expose_new_entities(self, assistant: str) -> bool:
"""Check if new entities are exposed to an assistant."""
if prefs := self._assistants.get(assistant):
return prefs.expose_new
return False
@callback
def async_set_expose_new_entities(self, assistant: str, expose_new: bool) -> None:
"""Enable an assistant to expose new entities."""
self._assistants[assistant] = AssistantPreferences(expose_new=expose_new)
self._async_schedule_save()
@callback
def async_get_assistant_settings(
self, assistant: str
) -> dict[str, Mapping[str, Any]]:
"""Get all entity expose settings for an assistant."""
entity_registry = er.async_get(self._hass)
result: dict[str, Mapping[str, Any]] = {}
for entity_id, entry in entity_registry.entities.items():
if options := entry.options.get(assistant):
result[entity_id] = options
return result
@callback
def async_should_expose(self, assistant: str, entity_id: str) -> bool:
"""Return True if an entity should be exposed to an assistant."""
should_expose: bool
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
entity_registry = er.async_get(self._hass)
if not (registry_entry := entity_registry.async_get(entity_id)):
# Entities which are not in the entity registry are not exposed
return False
if assistant in registry_entry.options:
if "should_expose" in registry_entry.options[assistant]:
should_expose = registry_entry.options[assistant]["should_expose"]
return should_expose
if (prefs := self._assistants.get(assistant)) and prefs.expose_new:
should_expose = self._is_default_exposed(entity_id, registry_entry)
else:
should_expose = False
assistant_options: Mapping[str, Any] = registry_entry.options.get(assistant, {})
assistant_options = assistant_options | {"should_expose": should_expose}
entity_registry.async_update_entity_options(
entity_id, assistant, assistant_options
)
return should_expose
def _is_default_exposed(
self, entity_id: str, registry_entry: er.RegistryEntry
) -> bool:
"""Return True if an entity is exposed by default."""
if (
registry_entry.entity_category is not None
or registry_entry.hidden_by is not None
):
return False
domain = split_entity_id(entity_id)[0]
if domain in DEFAULT_EXPOSED_DOMAINS:
return True
device_class = get_device_class(self._hass, entity_id)
if (
domain == "binary_sensor"
and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
):
return True
if domain == "sensor" and device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES:
return True
return False
async def async_load(self) -> None:
"""Load from the store."""
data = await self._store.async_load()
assistants: dict[str, AssistantPreferences] = {}
if data:
for domain, preferences in data["assistants"].items():
assistants[domain] = AssistantPreferences(**preferences)
self._assistants = assistants
@callback
def _async_schedule_save(self) -> None:
"""Schedule saving the preferences."""
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
@callback
def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]:
"""Return data to store in a file."""
data = {}
data["assistants"] = {
domain: preferences.to_json()
for domain, preferences in self._assistants.items()
}
return data
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "homeassistant/expose_entity",
vol.Required("assistants"): [vol.In(KNOWN_ASSISTANTS)],
vol.Required("entity_ids"): [str],
vol.Required("should_expose"): bool,
}
)
def ws_expose_entity(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Expose an entity to an assistant."""
entity_registry = er.async_get(hass)
entity_ids: str = msg["entity_ids"]
if blocked := next(
(
entity_id
for entity_id in entity_ids
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES
),
None,
):
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_ALLOWED, f"can't expose '{blocked}'"
)
return
if unknown := next(
(
entity_id
for entity_id in entity_ids
if entity_id not in entity_registry.entities
),
None,
):
connection.send_error(
msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'"
)
return
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
for entity_id in entity_ids:
for assistant in msg["assistants"]:
exposed_entities.async_expose_entity(
assistant, entity_id, msg["should_expose"]
)
connection.send_result(msg["id"])
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "homeassistant/expose_new_entities/get",
vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS),
}
)
def ws_expose_new_entities_get(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Check if new entities are exposed to an assistant."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
expose_new = exposed_entities.async_get_expose_new_entities(msg["assistant"])
connection.send_result(msg["id"], {"expose_new": expose_new})
@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "homeassistant/expose_new_entities/set",
vol.Required("assistant"): vol.In(KNOWN_ASSISTANTS),
vol.Required("expose_new"): bool,
}
)
def ws_expose_new_entities_set(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Expose new entities to an assistatant."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities(msg["assistant"], msg["expose_new"])
connection.send_result(msg["id"])
@callback
def async_listen_entity_updates(
hass: HomeAssistant, assistant: str, listener: Callable[[], None]
) -> None:
"""Listen for updates to entity expose settings."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_listen_entity_updates(assistant, listener)
@callback
def async_get_assistant_settings(
hass: HomeAssistant, assistant: str
) -> dict[str, Mapping[str, Any]]:
"""Get all entity expose settings for an assistant."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
return exposed_entities.async_get_assistant_settings(assistant)
@callback
def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool:
"""Return True if an entity should be exposed to an assistant."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
return exposed_entities.async_should_expose(assistant, entity_id)

View File

@@ -57,7 +57,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class ImageStorageCollection(collection.StorageCollection):
class ImageStorageCollection(collection.DictStorageCollection):
"""Image collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
@@ -67,7 +67,6 @@ class ImageStorageCollection(collection.StorageCollection):
"""Initialize media storage collection."""
super().__init__(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
)
self.async_add_listener(self._change_listener)
self.image_dir = image_dir
@@ -126,11 +125,11 @@ class ImageStorageCollection(collection.StorageCollection):
async def _update_data(
self,
data: dict[str, Any],
item: dict[str, Any],
update_data: dict[str, Any],
) -> dict[str, Any]:
"""Return a new updated data object."""
return {**data, **self.UPDATE_SCHEMA(update_data)}
return {**item, **self.UPDATE_SCHEMA(update_data)}
async def _change_listener(
self,

View File

@@ -65,7 +65,7 @@ STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
class InputBooleanStorageCollection(collection.StorageCollection):
class InputBooleanStorageCollection(collection.DictStorageCollection):
"""Input boolean collection stored in storage."""
CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS)
@@ -79,10 +79,10 @@ class InputBooleanStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
@bind_hass
@@ -110,7 +110,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = InputBooleanStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(

View File

@@ -56,7 +56,7 @@ STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
class InputButtonStorageCollection(collection.StorageCollection):
class InputButtonStorageCollection(collection.DictStorageCollection):
"""Input button collection stored in storage."""
CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS)
@@ -70,10 +70,10 @@ class InputButtonStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return cast(str, info[CONF_NAME])
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = InputButtonStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(

View File

@@ -148,7 +148,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = DateTimeStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(
@@ -204,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class DateTimeStorageCollection(collection.StorageCollection):
class DateTimeStorageCollection(collection.DictStorageCollection):
"""Input storage based collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, has_date_or_time))
@@ -218,10 +217,10 @@ class DateTimeStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
class InputDatetime(collection.CollectionEntity, RestoreEntity):

View File

@@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = NumberStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(
@@ -171,7 +170,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class NumberStorageCollection(collection.StorageCollection):
class NumberStorageCollection(collection.DictStorageCollection):
"""Input storage based collection."""
SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_number))
@@ -185,7 +184,7 @@ class NumberStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _async_load_data(self) -> dict | None:
async def _async_load_data(self) -> collection.SerializedStorageCollection | None:
"""Load the data.
A past bug caused frontend to add initial value to all input numbers.
@@ -201,10 +200,10 @@ class NumberStorageCollection(collection.StorageCollection):
return data
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
class InputNumber(collection.CollectionEntity, RestoreEntity):

View File

@@ -156,7 +156,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
InputSelectStore(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(
@@ -232,7 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class InputSelectStorageCollection(collection.StorageCollection):
class InputSelectStorageCollection(collection.DictStorageCollection):
"""Input storage based collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_select))
@@ -247,11 +246,11 @@ class InputSelectStorageCollection(collection.StorageCollection):
return cast(str, info[CONF_NAME])
async def _update_data(
self, data: dict[str, Any], update_data: dict[str, Any]
self, item: dict[str, Any], update_data: dict[str, Any]
) -> dict[str, Any]:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity):

View File

@@ -125,7 +125,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = InputTextStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(
@@ -165,7 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class InputTextStorageCollection(collection.StorageCollection):
class InputTextStorageCollection(collection.DictStorageCollection):
"""Input storage based collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_text))
@@ -179,10 +178,10 @@ class InputTextStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return {CONF_ID: data[CONF_ID]} | update_data
return {CONF_ID: item[CONF_ID]} | update_data
class InputText(collection.CollectionEntity, RestoreEntity):

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime, timedelta
import logging
from typing import Any
@@ -186,14 +186,23 @@ def _parse_event(event: dict[str, Any]) -> Event:
def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
start: datetime | date
end: datetime | date
if isinstance(event.start, datetime) and isinstance(event.end, datetime):
start = dt_util.as_local(event.start)
end = dt_util.as_local(event.end)
if (end - start) <= timedelta(seconds=0):
end = start + timedelta(minutes=30)
else:
start = event.start
end = event.end
if (end - start) <= timedelta(days=0):
end = start + timedelta(days=1)
return CalendarEvent(
summary=event.summary,
start=dt_util.as_local(event.start)
if isinstance(event.start, datetime)
else event.start,
end=dt_util.as_local(event.end)
if isinstance(event.end, datetime)
else event.end,
start=start,
end=end,
description=event.description,
uid=event.uid,
rrule=event.rrule.as_rrule_str() if event.rrule else None,

View File

@@ -6,7 +6,6 @@ import logging
import os
from pathlib import Path
import time
from typing import cast
import voluptuous as vol
@@ -218,7 +217,7 @@ def _config_info(mode, config):
}
class DashboardsCollection(collection.StorageCollection):
class DashboardsCollection(collection.DictStorageCollection):
"""Collection of dashboards."""
CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS)
@@ -228,13 +227,12 @@ class DashboardsCollection(collection.StorageCollection):
"""Initialize the dashboards collection."""
super().__init__(
storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY),
_LOGGER,
)
async def _async_load_data(self) -> dict | None:
async def _async_load_data(self) -> collection.SerializedStorageCollection | None:
"""Load the data."""
if (data := await self.store.async_load()) is None:
return cast(dict | None, data)
return data
updated = False
@@ -246,7 +244,7 @@ class DashboardsCollection(collection.StorageCollection):
if updated:
await self.store.async_save(data)
return cast(dict | None, data)
return data
async def _process_create_data(self, data: dict) -> dict:
"""Validate the config is valid."""
@@ -263,10 +261,10 @@ class DashboardsCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_URL_PATH]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.UPDATE_SCHEMA(update_data)
updated = {**data, **update_data}
updated = {**item, **update_data}
if CONF_ICON in updated and updated[CONF_ICON] is None:
updated.pop(CONF_ICON)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from typing import cast
from typing import Any
import uuid
import voluptuous as vol
@@ -45,7 +45,7 @@ class ResourceYAMLCollection:
return self.data
class ResourceStorageCollection(collection.StorageCollection):
class ResourceStorageCollection(collection.DictStorageCollection):
"""Collection to store resources."""
loaded = False
@@ -56,7 +56,6 @@ class ResourceStorageCollection(collection.StorageCollection):
"""Initialize the storage collection."""
super().__init__(
storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY),
_LOGGER,
)
self.ll_config = ll_config
@@ -68,10 +67,10 @@ class ResourceStorageCollection(collection.StorageCollection):
return {"resources": len(self.async_items() or [])}
async def _async_load_data(self) -> dict | None:
async def _async_load_data(self) -> collection.SerializedStorageCollection | None:
"""Load the data."""
if (data := await self.store.async_load()) is not None:
return cast(dict | None, data)
if (store_data := await self.store.async_load()) is not None:
return store_data
# Import it from config.
try:
@@ -83,20 +82,20 @@ class ResourceStorageCollection(collection.StorageCollection):
return None
# Remove it from config and save both resources + config
data = conf[CONF_RESOURCES]
resources: list[dict[str, Any]] = conf[CONF_RESOURCES]
try:
vol.Schema([RESOURCE_SCHEMA])(data)
vol.Schema([RESOURCE_SCHEMA])(resources)
except vol.Invalid as err:
_LOGGER.warning("Resource import failed. Data invalid: %s", err)
return None
conf.pop(CONF_RESOURCES)
for item in data:
for item in resources:
item[CONF_ID] = uuid.uuid4().hex
data = {"items": data}
data: collection.SerializedStorageCollection = {"items": resources}
await self.store.async_save(data)
await self.ll_config.async_save(conf)
@@ -114,7 +113,7 @@ class ResourceStorageCollection(collection.StorageCollection):
"""Return unique ID."""
return uuid.uuid4().hex
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
if not self.loaded:
await self.async_load()
@@ -124,4 +123,4 @@ class ResourceStorageCollection(collection.StorageCollection):
if CONF_RESOURCE_TYPE_WS in update_data:
update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS)
return {**data, **update_data}
return {**item, **update_data}

View File

@@ -31,7 +31,7 @@ from homeassistant.helpers import (
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.network import get_url
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -152,9 +152,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
webhook_url = webhook.async_generate_path(entry.entry_id)
hass_url = get_url(
hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False
)
try:
hass_url = get_url(
hass,
allow_cloud=False,
allow_external=False,
allow_ip=True,
require_ssl=False,
)
except NoURLAvailableError:
webhook.async_unregister(hass, entry.entry_id)
raise ConfigEntryNotReady(
f"Error registering URL for webhook {entry.entry_id}: "
"HomeAssistant URL is not available"
) from None
url = f"{hass_url}{webhook_url}"
if hass_url.startswith("https"):

View File

@@ -188,7 +188,7 @@ class PersonStore(Store):
return {"items": old_data["persons"]}
class PersonStorageCollection(collection.StorageCollection):
class PersonStorageCollection(collection.DictStorageCollection):
"""Person collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
@@ -197,15 +197,14 @@ class PersonStorageCollection(collection.StorageCollection):
def __init__(
self,
store: Store,
logger: logging.Logger,
id_manager: collection.IDManager,
yaml_collection: collection.YamlCollection,
) -> None:
"""Initialize a person storage collection."""
super().__init__(store, logger, id_manager)
super().__init__(store, id_manager)
self.yaml_collection = yaml_collection
async def _async_load_data(self) -> dict | None:
async def _async_load_data(self) -> collection.SerializedStorageCollection | None:
"""Load the data.
A past bug caused onboarding to create invalid person objects.
@@ -271,16 +270,16 @@ class PersonStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.UPDATE_SCHEMA(update_data)
user_id = update_data.get(CONF_USER_ID)
if user_id is not None and user_id != data.get(CONF_USER_ID):
if user_id is not None and user_id != item.get(CONF_USER_ID):
await self._validate_user_id(user_id)
return {**data, **update_data}
return {**item, **update_data}
async def _validate_user_id(self, user_id):
"""Validate the used user_id."""
@@ -337,7 +336,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
storage_collection = PersonStorageCollection(
PersonStore(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
yaml_collection,
)

View File

@@ -87,7 +87,7 @@ BINARY_SENSORS = (
icon="mdi:bell-ring-outline",
icon_off="mdi:doorbell",
value=lambda api, ch: api.visitor_detected(ch),
supported=lambda api, ch: api.is_doorbell_enabled(ch),
supported=lambda api, ch: api.is_doorbell(ch),
),
)

View File

@@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.5.9"]
"requirements": ["reolink-aio==0.5.10"]
}

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, time, timedelta
import itertools
import logging
from typing import Any, Literal
import voluptuous as vol
@@ -21,8 +20,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers.collection import (
CollectionEntity,
DictStorageCollection,
IDManager,
StorageCollection,
SerializedStorageCollection,
StorageCollectionWebsocket,
YamlCollection,
sync_entity_lifecycle,
@@ -173,7 +173,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
version=STORAGE_VERSION,
minor_version=STORAGE_VERSION_MINOR,
),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
sync_entity_lifecycle(hass, DOMAIN, DOMAIN, component, storage_collection, Schedule)
@@ -210,7 +209,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class ScheduleStorageCollection(StorageCollection):
class ScheduleStorageCollection(DictStorageCollection):
"""Schedules stored in storage."""
SCHEMA = vol.Schema(BASE_SCHEMA | STORAGE_SCHEDULE_SCHEMA)
@@ -226,12 +225,12 @@ class ScheduleStorageCollection(StorageCollection):
name: str = info[CONF_NAME]
return name
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
self.SCHEMA(update_data)
return data | update_data
return item | update_data
async def _async_load_data(self) -> dict | None:
async def _async_load_data(self) -> SerializedStorageCollection | None:
"""Load the data."""
if data := await super()._async_load_data():
data["items"] = [STORAGE_SCHEMA(item) for item in data["items"]]

View File

@@ -59,7 +59,7 @@ class TagIDManager(collection.IDManager):
return suggestion
class TagStorageCollection(collection.StorageCollection):
class TagStorageCollection(collection.DictStorageCollection):
"""Tag collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
@@ -80,9 +80,9 @@ class TagStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[TAG_ID]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
data = {**data, **self.UPDATE_SCHEMA(update_data)}
data = {**item, **self.UPDATE_SCHEMA(update_data)}
# make last_scanned JSON serializeable
if LAST_SCANNED in update_data:
data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
@@ -95,7 +95,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
id_manager = TagIDManager()
hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
await storage_collection.async_load()

View File

@@ -119,7 +119,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = TimerStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(
@@ -163,7 +162,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
class TimerStorageCollection(collection.StorageCollection):
class TimerStorageCollection(collection.DictStorageCollection):
"""Timer storage based collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS)
@@ -180,9 +179,9 @@ class TimerStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
data = {CONF_ID: data[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data)
data = {CONF_ID: item[CONF_ID]} | self.CREATE_UPDATE_SCHEMA(update_data)
# make duration JSON serializeable
if CONF_DURATION in update_data:
data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION])

View File

@@ -59,6 +59,7 @@ ATTR_LANGUAGE = "language"
ATTR_MESSAGE = "message"
ATTR_OPTIONS = "options"
ATTR_PLATFORM = "platform"
ATTR_AUDIO_OUTPUT = "audio_output"
BASE_URL_KEY = "tts_base_url"
@@ -134,6 +135,7 @@ class TTSCache(TypedDict):
filename: str
voice: bytes
pending: asyncio.Task | None
@callback
@@ -495,8 +497,11 @@ class SpeechManager:
)
extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:]
data = self.mem_cache[cache_key]["voice"]
return extension, data
cached = self.mem_cache[cache_key]
if pending := cached.get("pending"):
await pending
cached = self.mem_cache[cache_key]
return extension, cached["voice"]
@callback
def _generate_cache_key(
@@ -527,30 +532,62 @@ class SpeechManager:
This method is a coroutine.
"""
provider = self.providers[engine]
extension, data = await provider.async_get_tts_audio(message, language, options)
if data is None or extension is None:
raise HomeAssistantError(f"No TTS from {engine} for '{message}'")
if options is not None and ATTR_AUDIO_OUTPUT in options:
expected_extension = options[ATTR_AUDIO_OUTPUT]
else:
expected_extension = None
# Create file infos
filename = f"{cache_key}.{extension}".lower()
# Validate filename
if not _RE_VOICE_FILE.match(filename):
raise HomeAssistantError(
f"TTS filename '{filename}' from {engine} is invalid!"
async def get_tts_data() -> str:
"""Handle data available."""
extension, data = await provider.async_get_tts_audio(
message, language, options
)
# Save to memory
if extension == "mp3":
data = self.write_tags(filename, data, provider, message, language, options)
self._async_store_to_memcache(cache_key, filename, data)
if data is None or extension is None:
raise HomeAssistantError(f"No TTS from {engine} for '{message}'")
if cache:
self.hass.async_create_task(
self._async_save_tts_audio(cache_key, filename, data)
)
# Create file infos
filename = f"{cache_key}.{extension}".lower()
# Validate filename
if not _RE_VOICE_FILE.match(filename):
raise HomeAssistantError(
f"TTS filename '{filename}' from {engine} is invalid!"
)
# Save to memory
if extension == "mp3":
data = self.write_tags(
filename, data, provider, message, language, options
)
self._async_store_to_memcache(cache_key, filename, data)
if cache:
self.hass.async_create_task(
self._async_save_tts_audio(cache_key, filename, data)
)
return filename
audio_task = self.hass.async_create_task(get_tts_data())
if expected_extension is None:
return await audio_task
def handle_error(_future: asyncio.Future) -> None:
"""Handle error."""
if audio_task.exception():
self.mem_cache.pop(cache_key, None)
audio_task.add_done_callback(handle_error)
filename = f"{cache_key}.{expected_extension}".lower()
self.mem_cache[cache_key] = {
"filename": filename,
"voice": b"",
"pending": audio_task,
}
return filename
async def _async_save_tts_audio(
@@ -601,7 +638,11 @@ class SpeechManager:
self, cache_key: str, filename: str, data: bytes
) -> None:
"""Store data to memcache and set timer to remove it."""
self.mem_cache[cache_key] = {"filename": filename, "voice": data}
self.mem_cache[cache_key] = {
"filename": filename,
"voice": data,
"pending": None,
}
@callback
def async_remove_from_mem() -> None:
@@ -628,7 +669,11 @@ class SpeechManager:
await self._async_file_to_mem(cache_key)
content, _ = mimetypes.guess_type(filename)
return content, self.mem_cache[cache_key]["voice"]
cached = self.mem_cache[cache_key]
if pending := cached.get("pending"):
await pending
cached = self.mem_cache[cache_key]
return content, cached["voice"]
@staticmethod
def write_tags(

View File

@@ -17,6 +17,7 @@ from .pipeline import (
PipelineRun,
PipelineStage,
async_get_pipeline,
async_setup_pipeline_store,
)
from .websocket_api import async_register_websocket_api
@@ -31,7 +32,7 @@ __all__ = (
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Voice Assistant integration."""
hass.data[DOMAIN] = {}
await async_setup_pipeline_store(hass)
async_register_websocket_api(hass)
return True
@@ -61,7 +62,7 @@ async def async_pipeline_from_audio_stream(
if context is None:
context = Context()
pipeline = async_get_pipeline(
pipeline = await async_get_pipeline(
hass,
pipeline_id=pipeline_id,
language=language,

View File

@@ -1,3 +1,2 @@
"""Constants for the Voice Assistant integration."""
DOMAIN = "voice_assistant"
DEFAULT_PIPELINE = "default"

View File

@@ -7,13 +7,20 @@ from dataclasses import asdict, dataclass, field
import logging
from typing import Any
import voluptuous as vol
from homeassistant.backports.enum import StrEnum
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.tts.media_source import (
generate_media_source_id as tts_generate_media_source_id,
)
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.util.dt import utcnow
from homeassistant.helpers.collection import (
StorageCollection,
StorageCollectionWebsocket,
)
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util, ulid as ulid_util
from .const import DOMAIN
from .error import (
@@ -25,23 +32,39 @@ from .error import (
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = f"{DOMAIN}.pipelines"
STORAGE_VERSION = 1
@callback
def async_get_pipeline(
STORAGE_FIELDS = {
vol.Required("conversation_engine"): str,
vol.Required("language"): str,
vol.Required("name"): str,
vol.Required("stt_engine"): str,
vol.Required("tts_engine"): str,
}
SAVE_DELAY = 10
async def async_get_pipeline(
hass: HomeAssistant, pipeline_id: str | None = None, language: str | None = None
) -> Pipeline | None:
"""Get a pipeline by id or create one for a language."""
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN]
if pipeline_id is not None:
return hass.data[DOMAIN].get(pipeline_id)
return pipeline_store.data.get(pipeline_id)
# Construct a pipeline for the required/configured language
language = language or hass.config.language
return Pipeline(
name=language,
language=language,
stt_engine=None, # first engine
conversation_engine=None, # first agent
tts_engine=None, # first engine
return await pipeline_store.async_create_item(
{
"name": language,
"language": language,
"stt_engine": None, # first engine
"conversation_engine": None, # first agent
"tts_engine": None, # first engine
}
)
@@ -65,7 +88,7 @@ class PipelineEvent:
type: PipelineEventType
data: dict[str, Any] | None = None
timestamp: str = field(default_factory=lambda: utcnow().isoformat())
timestamp: str = field(default_factory=lambda: dt_util.utcnow().isoformat())
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation of the event."""
@@ -79,16 +102,29 @@ class PipelineEvent:
PipelineEventCallback = Callable[[PipelineEvent], None]
@dataclass
@dataclass(frozen=True)
class Pipeline:
"""A voice assistant pipeline."""
name: str
language: str | None
stt_engine: str | None
conversation_engine: str | None
language: str | None
name: str
stt_engine: str | None
tts_engine: str | None
id: str = field(default_factory=ulid_util.ulid)
def to_json(self) -> dict[str, Any]:
"""Return a JSON serializable representation for storage."""
return {
"conversation_engine": self.conversation_engine,
"id": self.id,
"language": self.language,
"name": self.name,
"stt_engine": self.stt_engine,
"tts_engine": self.tts_engine,
}
class PipelineStage(StrEnum):
"""Stages of a pipeline."""
@@ -478,3 +514,47 @@ class PipelineInput:
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
class PipelineStorageCollection(StorageCollection[Pipeline]):
"""Pipeline storage collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(STORAGE_FIELDS)
async def _process_create_data(self, data: dict) -> dict:
"""Validate the config is valid."""
# We don't need to validate, the WS API has already validated
return data
@callback
def _get_suggested_id(self, info: dict) -> str:
"""Suggest an ID based on the config."""
return ulid_util.ulid()
async def _update_data(self, item: Pipeline, update_data: dict) -> Pipeline:
"""Return a new updated item."""
return Pipeline(id=item.id, **update_data)
def _create_item(self, item_id: str, data: dict) -> Pipeline:
"""Create an item from validated config."""
return Pipeline(id=item_id, **data)
def _deserialize_item(self, data: dict) -> Pipeline:
"""Create an item from its serialized representation."""
return Pipeline(**data)
def _serialize_item(self, item_id: str, item: Pipeline) -> dict:
"""Return the serialized representation of an item."""
return item.to_json()
async def async_setup_pipeline_store(hass):
"""Set up the pipeline storage collection."""
pipeline_store = PipelineStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY)
)
await pipeline_store.async_load()
StorageCollectionWebsocket(
pipeline_store, f"{DOMAIN}/pipeline", "pipeline", STORAGE_FIELDS, STORAGE_FIELDS
).async_setup(hass)
hass.data[DOMAIN] = pipeline_store

View File

@@ -61,7 +61,7 @@ async def websocket_run(
language = "en-US"
pipeline_id = msg.get("pipeline")
pipeline = async_get_pipeline(
pipeline = await async_get_pipeline(
hass,
pipeline_id=pipeline_id,
language=language,

View File

@@ -163,7 +163,7 @@ def in_zone(zone: State, latitude: float, longitude: float, radius: float = 0) -
return zone_dist - radius < cast(float, zone.attributes[ATTR_RADIUS])
class ZoneStorageCollection(collection.StorageCollection):
class ZoneStorageCollection(collection.DictStorageCollection):
"""Zone collection stored in storage."""
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
@@ -178,10 +178,10 @@ class ZoneStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return cast(str, info[CONF_NAME])
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
update_data = self.UPDATE_SCHEMA(update_data)
return {**data, **update_data}
return {**item, **update_data}
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -198,7 +198,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
storage_collection = ZoneStorageCollection(
storage.Store(hass, STORAGE_VERSION, STORAGE_KEY),
logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.sync_entity_lifecycle(

View File

@@ -80,7 +80,6 @@ FLOWS = {
"coinbase",
"control4",
"coolmaster",
"coronavirus",
"cpuspeed",
"crownstone",
"daikin",

View File

@@ -881,12 +881,6 @@
"config_flow": true,
"iot_class": "local_polling"
},
"coronavirus": {
"name": "Coronavirus (COVID-19)",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"cozytouch": {
"name": "Atlantic Cozytouch",
"integration_type": "virtual",

View File

@@ -216,7 +216,7 @@ class AreaRegistry:
if not new_values:
return old
new = self.areas[area_id] = attr.evolve(old, **new_values)
new = self.areas[area_id] = attr.evolve(old, **new_values) # type: ignore[arg-type]
if normalized_name is not None:
self._normalized_name_area_idx[
normalized_name

View File

@@ -8,7 +8,7 @@ from dataclasses import dataclass
from itertools import groupby
import logging
from operator import attrgetter
from typing import Any, cast
from typing import Any, Generic, TypedDict, TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -32,6 +32,8 @@ CHANGE_ADDED = "added"
CHANGE_UPDATED = "updated"
CHANGE_REMOVED = "removed"
_T = TypeVar("_T")
@dataclass
class CollectionChangeSet:
@@ -121,23 +123,20 @@ class CollectionEntity(Entity):
"""Handle updated configuration."""
class ObservableCollection(ABC):
class ObservableCollection(ABC, Generic[_T]):
"""Base collection type that can be observed."""
def __init__(
self, logger: logging.Logger, id_manager: IDManager | None = None
) -> None:
def __init__(self, id_manager: IDManager | None) -> None:
"""Initialize the base collection."""
self.logger = logger
self.id_manager = id_manager or IDManager()
self.data: dict[str, dict] = {}
self.data: dict[str, _T] = {}
self.listeners: list[ChangeListener] = []
self.change_set_listeners: list[ChangeSetListener] = []
self.id_manager.add_collection(self.data)
@callback
def async_items(self) -> list[dict]:
def async_items(self) -> list[_T]:
"""Return list of items in collection."""
return list(self.data.values())
@@ -172,9 +171,18 @@ class ObservableCollection(ABC):
)
class YamlCollection(ObservableCollection):
class YamlCollection(ObservableCollection[dict]):
"""Offer a collection based on static data."""
def __init__(
self,
logger: logging.Logger,
id_manager: IDManager | None = None,
) -> None:
"""Initialize the storage collection."""
super().__init__(id_manager)
self.logger = logger
@staticmethod
def create_entity(
entity_class: type[CollectionEntity], config: ConfigType
@@ -212,17 +220,22 @@ class YamlCollection(ObservableCollection):
await self.notify_changes(change_sets)
class StorageCollection(ObservableCollection, ABC):
class SerializedStorageCollection(TypedDict):
"""Serialized storage collection."""
items: list[dict[str, Any]]
class StorageCollection(ObservableCollection[_T], ABC):
"""Offer a CRUD interface on top of JSON storage."""
def __init__(
self,
store: Store,
logger: logging.Logger,
store: Store[SerializedStorageCollection],
id_manager: IDManager | None = None,
) -> None:
"""Initialize the storage collection."""
super().__init__(logger, id_manager)
super().__init__(id_manager)
self.store = store
@staticmethod
@@ -237,9 +250,9 @@ class StorageCollection(ObservableCollection, ABC):
"""Home Assistant object."""
return self.store.hass
async def _async_load_data(self) -> dict | None:
async def _async_load_data(self) -> SerializedStorageCollection | None:
"""Load the data."""
return cast(dict | None, await self.store.async_load())
return await self.store.async_load()
async def async_load(self) -> None:
"""Load the storage Manager."""
@@ -249,7 +262,7 @@ class StorageCollection(ObservableCollection, ABC):
raw_storage = {"items": []}
for item in raw_storage["items"]:
self.data[item[CONF_ID]] = item
self.data[item[CONF_ID]] = self._deserialize_item(item)
await self.notify_changes(
[
@@ -268,21 +281,35 @@ class StorageCollection(ObservableCollection, ABC):
"""Suggest an ID based on the config."""
@abstractmethod
async def _update_data(self, data: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
async def _update_data(self, item: _T, update_data: dict) -> _T:
"""Return a new updated item."""
async def async_create_item(self, data: dict) -> dict:
@abstractmethod
def _create_item(self, item_id: str, data: dict) -> _T:
"""Create an item from validated config."""
@abstractmethod
def _deserialize_item(self, data: dict) -> _T:
"""Create an item from its serialized representation."""
@abstractmethod
def _serialize_item(self, item_id: str, item: _T) -> dict:
"""Return the serialized representation of an item.
The serialized representation must include the item_id in the "id" key.
"""
async def async_create_item(self, data: dict) -> _T:
"""Create a new item."""
item = await self._process_create_data(data)
item[CONF_ID] = self.id_manager.generate_id(self._get_suggested_id(item))
self.data[item[CONF_ID]] = item
validated_data = await self._process_create_data(data)
item_id = self.id_manager.generate_id(self._get_suggested_id(validated_data))
item = self._create_item(item_id, validated_data)
self.data[item_id] = item
self._async_schedule_save()
await self.notify_changes(
[CollectionChangeSet(CHANGE_ADDED, item[CONF_ID], item)]
)
await self.notify_changes([CollectionChangeSet(CHANGE_ADDED, item_id, item)])
return item
async def async_update_item(self, item_id: str, updates: dict) -> dict:
async def async_update_item(self, item_id: str, updates: dict) -> _T:
"""Update item."""
if item_id not in self.data:
raise ItemNotFound(item_id)
@@ -315,13 +342,34 @@ class StorageCollection(ObservableCollection, ABC):
@callback
def _async_schedule_save(self) -> None:
"""Schedule saving the area registry."""
"""Schedule saving the collection."""
self.store.async_delay_save(self._data_to_save, SAVE_DELAY)
@callback
def _data_to_save(self) -> dict:
"""Return data of area registry to store in a file."""
return {"items": list(self.data.values())}
def _data_to_save(self) -> SerializedStorageCollection:
"""Return JSON-compatible date for storing to file."""
return {
"items": [
self._serialize_item(item_id, item)
for item_id, item in self.data.items()
]
}
class DictStorageCollection(StorageCollection[dict]):
"""A specialized StorageCollection where the items are untyped dicts."""
def _create_item(self, item_id: str, data: dict) -> dict:
"""Create an item from its validated, serialized representation."""
return {CONF_ID: item_id} | data
def _deserialize_item(self, data: dict) -> dict:
"""Create an item from its validated, serialized representation."""
return data
def _serialize_item(self, item_id: str, item: dict) -> dict:
"""Return the serialized representation of an item."""
return item
class IDLessCollection(YamlCollection):

View File

@@ -25,7 +25,7 @@ ha-av==10.0.0
hass-nabucasa==0.63.1
hassil==1.0.6
home-assistant-bluetooth==1.9.3
home-assistant-frontend==20230405.0
home-assistant-frontend==20230406.1
home-assistant-intents==2023.3.29
httpx==0.23.3
ifaddr==0.1.7

View File

@@ -1132,6 +1132,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.homeassistant.exposed_entities]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.homeassistant.triggers.event]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@@ -119,7 +119,7 @@ aioairq==0.2.4
aioairzone==0.5.2
# homeassistant.components.ambient_station
aioambient==2021.11.0
aioambient==2022.10.0
# homeassistant.components.aseko_pool_live
aioaseko==0.0.2
@@ -544,9 +544,6 @@ connect-box==0.2.8
# homeassistant.components.xiaomi_miio
construct==2.10.56
# homeassistant.components.coronavirus
coronavirus==1.1.1
# homeassistant.components.utility_meter
croniter==1.0.6
@@ -910,7 +907,7 @@ hole==0.8.0
holidays==0.21.13
# homeassistant.components.frontend
home-assistant-frontend==20230405.0
home-assistant-frontend==20230406.1
# homeassistant.components.conversation
home-assistant-intents==2023.3.29
@@ -2234,7 +2231,7 @@ regenmaschine==2022.11.0
renault-api==0.1.12
# homeassistant.components.reolink
reolink-aio==0.5.9
reolink-aio==0.5.10
# homeassistant.components.python_script
restrictedpython==6.0

View File

@@ -12,7 +12,7 @@ codecov==2.1.12
coverage==7.2.1
freezegun==1.2.2
mock-open==1.4.0
mypy==1.1.1
mypy==1.2.0
pre-commit==3.1.0
pydantic==1.10.7
pylint==2.17.0

View File

@@ -109,7 +109,7 @@ aioairq==0.2.4
aioairzone==0.5.2
# homeassistant.components.ambient_station
aioambient==2021.11.0
aioambient==2022.10.0
# homeassistant.components.aseko_pool_live
aioaseko==0.0.2
@@ -430,9 +430,6 @@ colorthief==0.2.1
# homeassistant.components.xiaomi_miio
construct==2.10.56
# homeassistant.components.coronavirus
coronavirus==1.1.1
# homeassistant.components.utility_meter
croniter==1.0.6
@@ -696,7 +693,7 @@ hole==0.8.0
holidays==0.21.13
# homeassistant.components.frontend
home-assistant-frontend==20230405.0
home-assistant-frontend==20230406.1
# homeassistant.components.conversation
home-assistant-intents==2023.3.29
@@ -1600,7 +1597,7 @@ regenmaschine==2022.11.0
renault-api==0.1.12
# homeassistant.components.reolink
reolink-aio==0.5.9
reolink-aio==0.5.10
# homeassistant.components.python_script
restrictedpython==6.0

View File

@@ -3,7 +3,7 @@
from unittest.mock import AsyncMock, patch
from homeassistant.components import cloud
from homeassistant.components.cloud import const
from homeassistant.components.cloud import const, prefs as cloud_prefs
from homeassistant.setup import async_setup_component
@@ -18,9 +18,11 @@ async def mock_cloud(hass, config=None):
def mock_cloud_prefs(hass, prefs={}):
"""Fixture for cloud component."""
prefs_to_set = {
const.PREF_ALEXA_SETTINGS_VERSION: cloud_prefs.ALEXA_SETTINGS_VERSION,
const.PREF_ENABLE_ALEXA: True,
const.PREF_ENABLE_GOOGLE: True,
const.PREF_GOOGLE_SECURE_DEVICES_PIN: None,
const.PREF_GOOGLE_SETTINGS_VERSION: cloud_prefs.GOOGLE_SETTINGS_VERSION,
}
prefs_to_set.update(prefs)
hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set

View File

@@ -6,10 +6,22 @@ import pytest
from homeassistant.components.alexa import errors
from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config
from homeassistant.components.cloud.const import (
PREF_ALEXA_DEFAULT_EXPOSE,
PREF_ALEXA_ENTITY_CONFIGS,
PREF_SHOULD_EXPOSE,
)
from homeassistant.components.cloud.prefs import CloudPreferences
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.setup import async_setup_component
from tests.common import async_fire_time_changed
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -21,10 +33,23 @@ def cloud_stub():
return Mock(is_logged_in=True, subscription_expired=False)
def expose_new(hass, expose_new):
"""Enable exposing new entities to Alexa."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities("cloud.alexa", expose_new)
def expose_entity(hass, entity_id, should_expose):
"""Expose an entity to Alexa."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_expose_entity("cloud.alexa", entity_id, should_expose)
async def test_alexa_config_expose_entity_prefs(
hass: HomeAssistant, cloud_prefs, cloud_stub, entity_registry: er.EntityRegistry
) -> None:
"""Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
entity_entry1 = entity_registry.async_get_or_create(
"light",
"test",
@@ -53,54 +78,62 @@ async def test_alexa_config_expose_entity_prefs(
suggested_object_id="hidden_user_light",
hidden_by=er.RegistryEntryHider.USER,
)
entity_entry5 = entity_registry.async_get_or_create(
"light",
"test",
"light_basement_id",
suggested_object_id="basement",
)
entity_entry6 = entity_registry.async_get_or_create(
"light",
"test",
"light_entrance_id",
suggested_object_id="entrance",
)
entity_conf = {"should_expose": False}
await cloud_prefs.async_update(
alexa_entity_configs={"light.kitchen": entity_conf},
alexa_default_expose=["light"],
alexa_enabled=True,
alexa_report_state=False,
)
expose_new(hass, True)
expose_entity(hass, entity_entry5.entity_id, False)
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
# can't expose an entity which is not in the entity registry
with pytest.raises(HomeAssistantError):
expose_entity(hass, "light.kitchen", True)
assert not conf.should_expose("light.kitchen")
assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
entity_conf["should_expose"] = True
assert conf.should_expose("light.kitchen")
# categorized and hidden entities should not be exposed
assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
# this has been hidden
assert not conf.should_expose(entity_entry5.entity_id)
# exposed by default
assert conf.should_expose(entity_entry6.entity_id)
entity_conf["should_expose"] = None
assert conf.should_expose("light.kitchen")
# categorized and hidden entities should not be exposed
assert not conf.should_expose(entity_entry1.entity_id)
assert not conf.should_expose(entity_entry2.entity_id)
assert not conf.should_expose(entity_entry3.entity_id)
assert not conf.should_expose(entity_entry4.entity_id)
expose_entity(hass, entity_entry5.entity_id, True)
assert conf.should_expose(entity_entry5.entity_id)
expose_entity(hass, entity_entry5.entity_id, None)
assert not conf.should_expose(entity_entry5.entity_id)
assert "alexa" not in hass.config.components
await cloud_prefs.async_update(
alexa_default_expose=["sensor"],
)
await hass.async_block_till_done()
assert "alexa" in hass.config.components
assert not conf.should_expose("light.kitchen")
assert not conf.should_expose(entity_entry5.entity_id)
async def test_alexa_config_report_state(
hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
await cloud_prefs.async_update(
alexa_report_state=False,
)
@@ -134,6 +167,8 @@ async def test_alexa_config_invalidate_token(
hass: HomeAssistant, cloud_prefs, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test Alexa config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
aioclient_mock.post(
"https://example/access_token",
json={
@@ -181,10 +216,18 @@ async def test_alexa_config_fail_refresh_token(
hass: HomeAssistant,
cloud_prefs,
aioclient_mock: AiohttpClientMocker,
entity_registry: er.EntityRegistry,
reject_reason,
expected_exception,
) -> None:
"""Test Alexa config failing to refresh token."""
assert await async_setup_component(hass, "homeassistant", {})
# Enable exposing new entities to Alexa
expose_new(hass, True)
# Register a fan entity
entity_entry = entity_registry.async_get_or_create(
"fan", "test", "unique", suggested_object_id="test_fan"
)
aioclient_mock.post(
"https://example/access_token",
@@ -216,7 +259,7 @@ async def test_alexa_config_fail_refresh_token(
assert conf.should_report_state is False
assert conf.is_reporting_states is False
hass.states.async_set("fan.test_fan", "off")
hass.states.async_set(entity_entry.entity_id, "off")
# Enable state reporting
await cloud_prefs.async_update(alexa_report_state=True)
@@ -227,7 +270,7 @@ async def test_alexa_config_fail_refresh_token(
assert conf.is_reporting_states is True
# Change states to trigger event listener
hass.states.async_set("fan.test_fan", "on")
hass.states.async_set(entity_entry.entity_id, "on")
await hass.async_block_till_done()
# Invalidate the token and try to fetch another
@@ -240,7 +283,7 @@ async def test_alexa_config_fail_refresh_token(
)
# Change states to trigger event listener
hass.states.async_set("fan.test_fan", "off")
hass.states.async_set(entity_entry.entity_id, "off")
await hass.async_block_till_done()
# Check state reporting is still wanted in cloud prefs, but disabled for Alexa
@@ -292,16 +335,30 @@ def patch_sync_helper():
async def test_alexa_update_expose_trigger_sync(
hass: HomeAssistant, cloud_prefs, cloud_stub
hass: HomeAssistant, entity_registry: er.EntityRegistry, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config responds to updating exposed entities."""
hass.states.async_set("binary_sensor.door", "on")
assert await async_setup_component(hass, "homeassistant", {})
# Enable exposing new entities to Alexa
expose_new(hass, True)
# Register entities
binary_sensor_entry = entity_registry.async_get_or_create(
"binary_sensor", "test", "unique", suggested_object_id="door"
)
sensor_entry = entity_registry.async_get_or_create(
"sensor", "test", "unique", suggested_object_id="temp"
)
light_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
hass.states.async_set(binary_sensor_entry.entity_id, "on")
hass.states.async_set(
"sensor.temp",
sensor_entry.entity_id,
"23",
{"device_class": "temperature", "unit_of_measurement": "°C"},
)
hass.states.async_set("light.kitchen", "off")
hass.states.async_set(light_entry.entity_id, "off")
await cloud_prefs.async_update(
alexa_enabled=True,
@@ -313,34 +370,26 @@ async def test_alexa_update_expose_trigger_sync(
await conf.async_initialize()
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config(
entity_id="light.kitchen", should_expose=True
)
expose_entity(hass, light_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, fire_all=True)
await hass.async_block_till_done()
assert conf._alexa_sync_unsub is None
assert to_update == ["light.kitchen"]
assert to_update == [light_entry.entity_id]
assert to_remove == []
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update_alexa_entity_config(
entity_id="light.kitchen", should_expose=False
)
await cloud_prefs.async_update_alexa_entity_config(
entity_id="binary_sensor.door", should_expose=True
)
await cloud_prefs.async_update_alexa_entity_config(
entity_id="sensor.temp", should_expose=True
)
expose_entity(hass, light_entry.entity_id, False)
expose_entity(hass, binary_sensor_entry.entity_id, True)
expose_entity(hass, sensor_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, fire_all=True)
await hass.async_block_till_done()
assert conf._alexa_sync_unsub is None
assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"]
assert to_remove == ["light.kitchen"]
assert sorted(to_update) == [binary_sensor_entry.entity_id, sensor_entry.entity_id]
assert to_remove == [light_entry.entity_id]
with patch_sync_helper() as (to_update, to_remove):
await cloud_prefs.async_update(
@@ -350,56 +399,65 @@ async def test_alexa_update_expose_trigger_sync(
assert conf._alexa_sync_unsub is None
assert to_update == []
assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"]
assert to_remove == [
binary_sensor_entry.entity_id,
sensor_entry.entity_id,
light_entry.entity_id,
]
async def test_alexa_entity_registry_sync(
hass: HomeAssistant, mock_cloud_login, cloud_prefs
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_cloud_login,
cloud_prefs,
) -> None:
"""Test Alexa config responds to entity registry."""
# Enable exposing new entities to Alexa
expose_new(hass, True)
await alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
).async_initialize()
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
await hass.async_block_till_done()
assert to_update == ["light.kitchen"]
assert to_update == [entry.entity_id]
assert to_remove == []
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"},
{"action": "remove", "entity_id": entry.entity_id},
)
await hass.async_block_till_done()
assert to_update == []
assert to_remove == ["light.kitchen"]
assert to_remove == [entry.entity_id]
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{
"action": "update",
"entity_id": "light.kitchen",
"entity_id": entry.entity_id,
"changes": ["entity_id"],
"old_entity_id": "light.living_room",
},
)
await hass.async_block_till_done()
assert to_update == ["light.kitchen"]
assert to_update == [entry.entity_id]
assert to_remove == ["light.living_room"]
with patch_sync_helper() as (to_update, to_remove):
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]},
{"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]},
)
await hass.async_block_till_done()
@@ -411,6 +469,7 @@ async def test_alexa_update_report_state(
hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config responds to reporting state."""
assert await async_setup_component(hass, "homeassistant", {})
await cloud_prefs.async_update(
alexa_report_state=False,
)
@@ -450,6 +509,7 @@ async def test_alexa_handle_logout(
hass: HomeAssistant, cloud_prefs, cloud_stub
) -> None:
"""Test Alexa config responds to logging out."""
assert await async_setup_component(hass, "homeassistant", {})
aconf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
@@ -475,3 +535,118 @@ async def test_alexa_handle_logout(
await hass.async_block_till_done()
assert len(mock_enable.return_value.mock_calls) == 1
async def test_alexa_config_migrate_expose_entity_prefs(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
cloud_stub,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Alexa entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_exposed = entity_registry.async_get_or_create(
"light",
"test",
"light_exposed",
suggested_object_id="exposed",
)
entity_migrated = entity_registry.async_get_or_create(
"light",
"test",
"light_migrated",
suggested_object_id="migrated",
)
entity_config = entity_registry.async_get_or_create(
"light",
"test",
"light_config",
suggested_object_id="config",
entity_category=EntityCategory.CONFIG,
)
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
entity_blocked = entity_registry.async_get_or_create(
"group",
"test",
"group_all_locks",
suggested_object_id="all_locks",
)
assert entity_blocked.entity_id == "group.all_locks"
await cloud_prefs.async_update(
alexa_enabled=True,
alexa_report_state=False,
alexa_settings_version=1,
)
expose_entity(hass, entity_migrated.entity_id, False)
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
entity_exposed = entity_registry.async_get(entity_exposed.entity_id)
assert entity_exposed.options == {"cloud.alexa": {"should_expose": True}}
entity_migrated = entity_registry.async_get(entity_migrated.entity_id)
assert entity_migrated.options == {"cloud.alexa": {"should_expose": False}}
entity_config = entity_registry.async_get(entity_config.entity_id)
assert entity_config.options == {"cloud.alexa": {"should_expose": False}}
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}
entity_blocked = entity_registry.async_get(entity_blocked.entity_id)
assert entity_blocked.options == {"cloud.alexa": {"should_expose": False}}
async def test_alexa_config_migrate_expose_entity_prefs_default_none(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
cloud_stub,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Alexa entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
await cloud_prefs.async_update(
alexa_enabled=True,
alexa_report_state=False,
alexa_settings_version=1,
)
cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = None
conf = alexa_config.CloudAlexaConfig(
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
)
await conf.async_initialize()
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}

View File

@@ -13,8 +13,13 @@ from homeassistant.components.cloud.const import (
PREF_ENABLE_ALEXA,
PREF_ENABLE_GOOGLE,
)
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import CONTENT_TYPE_JSON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -245,14 +250,25 @@ async def test_google_config_expose_entity(
hass: HomeAssistant, mock_cloud_setup, mock_cloud_login
) -> None:
"""Test Google config exposing entity method uses latest config."""
entity_registry = er.async_get(hass)
# Enable exposing new entities to Google
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities("cloud.google_assistant", True)
# Register a light entity
entity_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
cloud_client = hass.data[DOMAIN].client
state = State("light.kitchen", "on")
state = State(entity_entry.entity_id, "on")
gconf = await cloud_client.get_google_config()
assert gconf.should_expose(state)
await cloud_client.prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=False
exposed_entities.async_expose_entity(
"cloud.google_assistant", entity_entry.entity_id, False
)
assert not gconf.should_expose(state)
@@ -262,14 +278,21 @@ async def test_google_config_should_2fa(
hass: HomeAssistant, mock_cloud_setup, mock_cloud_login
) -> None:
"""Test Google config disabling 2FA method uses latest config."""
entity_registry = er.async_get(hass)
# Register a light entity
entity_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
cloud_client = hass.data[DOMAIN].client
gconf = await cloud_client.get_google_config()
state = State("light.kitchen", "on")
state = State(entity_entry.entity_id, "on")
assert gconf.should_2fa(state)
await cloud_client.prefs.async_update_google_entity_config(
entity_id="light.kitchen", disable_2fa=True
entity_registry.async_update_entity_options(
entity_entry.entity_id, "cloud.google_assistant", {"disable_2fa": True}
)
assert not gconf.should_2fa(state)

View File

@@ -6,11 +6,24 @@ from freezegun import freeze_time
import pytest
from homeassistant.components.cloud import GACTIONS_SCHEMA
from homeassistant.components.cloud.const import (
PREF_DISABLE_2FA,
PREF_GOOGLE_DEFAULT_EXPOSE,
PREF_GOOGLE_ENTITY_CONFIGS,
PREF_SHOULD_EXPOSE,
)
from homeassistant.components.cloud.google_config import CloudGoogleConfig
from homeassistant.components.cloud.prefs import CloudPreferences
from homeassistant.components.google_assistant import helpers as ga_helpers
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EntityCategory
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from tests.common import async_fire_time_changed
@@ -28,10 +41,26 @@ def mock_conf(hass, cloud_prefs):
)
def expose_new(hass, expose_new):
"""Enable exposing new entities to Google."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_set_expose_new_entities("cloud.google_assistant", expose_new)
def expose_entity(hass, entity_id, should_expose):
"""Expose an entity to Google."""
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
exposed_entities.async_expose_entity(
"cloud.google_assistant", entity_id, should_expose
)
async def test_google_update_report_state(
mock_conf, hass: HomeAssistant, cloud_prefs
) -> None:
"""Test Google config responds to updating preference."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
@@ -51,6 +80,8 @@ async def test_google_update_report_state_subscription_expired(
mock_conf, hass: HomeAssistant, cloud_prefs
) -> None:
"""Test Google config not reporting state when subscription has expired."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
@@ -68,6 +99,8 @@ async def test_google_update_report_state_subscription_expired(
async def test_sync_entities(mock_conf, hass: HomeAssistant, cloud_prefs) -> None:
"""Test sync devices."""
assert await async_setup_component(hass, "homeassistant", {})
await mock_conf.async_initialize()
await mock_conf.async_connect_agent_user("mock-user-id")
@@ -88,6 +121,22 @@ async def test_google_update_expose_trigger_sync(
hass: HomeAssistant, cloud_prefs
) -> None:
"""Test Google config responds to updating exposed entities."""
assert await async_setup_component(hass, "homeassistant", {})
entity_registry = er.async_get(hass)
# Enable exposing new entities to Google
expose_new(hass, True)
# Register entities
binary_sensor_entry = entity_registry.async_get_or_create(
"binary_sensor", "test", "unique", suggested_object_id="door"
)
sensor_entry = entity_registry.async_get_or_create(
"sensor", "test", "unique", suggested_object_id="temp"
)
light_entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
with freeze_time(utcnow()):
config = CloudGoogleConfig(
hass,
@@ -102,9 +151,7 @@ async def test_google_update_expose_trigger_sync(
with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
ga_helpers, "SYNC_DELAY", 0
):
await cloud_prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=True
)
expose_entity(hass, light_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done()
@@ -114,15 +161,9 @@ async def test_google_update_expose_trigger_sync(
with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
ga_helpers, "SYNC_DELAY", 0
):
await cloud_prefs.async_update_google_entity_config(
entity_id="light.kitchen", should_expose=False
)
await cloud_prefs.async_update_google_entity_config(
entity_id="binary_sensor.door", should_expose=True
)
await cloud_prefs.async_update_google_entity_config(
entity_id="sensor.temp", should_expose=True
)
expose_entity(hass, light_entry.entity_id, False)
expose_entity(hass, binary_sensor_entry.entity_id, True)
expose_entity(hass, sensor_entry.entity_id, True)
await hass.async_block_till_done()
async_fire_time_changed(hass, utcnow())
await hass.async_block_till_done()
@@ -134,6 +175,11 @@ async def test_google_entity_registry_sync(
hass: HomeAssistant, mock_cloud_login, cloud_prefs
) -> None:
"""Test Google config responds to entity registry."""
entity_registry = er.async_get(hass)
# Enable exposing new entities to Google
expose_new(hass, True)
config = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
)
@@ -146,9 +192,8 @@ async def test_google_entity_registry_sync(
ga_helpers, "SYNC_DELAY", 0
):
# Created entity
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
await hass.async_block_till_done()
@@ -157,7 +202,7 @@ async def test_google_entity_registry_sync(
# Removed entity
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "remove", "entity_id": "light.kitchen"},
{"action": "remove", "entity_id": entry.entity_id},
)
await hass.async_block_till_done()
@@ -168,7 +213,7 @@ async def test_google_entity_registry_sync(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{
"action": "update",
"entity_id": "light.kitchen",
"entity_id": entry.entity_id,
"changes": ["entity_id"],
},
)
@@ -179,7 +224,7 @@ async def test_google_entity_registry_sync(
# Entity registry updated with non-relevant changes
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "update", "entity_id": "light.kitchen", "changes": ["icon"]},
{"action": "update", "entity_id": entry.entity_id, "changes": ["icon"]},
)
await hass.async_block_till_done()
@@ -189,7 +234,7 @@ async def test_google_entity_registry_sync(
hass.state = CoreState.starting
hass.bus.async_fire(
er.EVENT_ENTITY_REGISTRY_UPDATED,
{"action": "create", "entity_id": "light.kitchen"},
{"action": "create", "entity_id": entry.entity_id},
)
await hass.async_block_till_done()
@@ -204,6 +249,10 @@ async def test_google_device_registry_sync(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, hass.data["cloud"]
)
ent_reg = er.async_get(hass)
# Enable exposing new entities to Google
expose_new(hass, True)
entity_entry = ent_reg.async_get_or_create("light", "hue", "1234", device_id="1234")
entity_entry = ent_reg.async_update_entity(entity_entry.entity_id, area_id="ABCD")
@@ -293,6 +342,7 @@ async def test_google_config_expose_entity_prefs(
hass: HomeAssistant, mock_conf, cloud_prefs, entity_registry: er.EntityRegistry
) -> None:
"""Test Google config should expose using prefs."""
assert await async_setup_component(hass, "homeassistant", {})
entity_entry1 = entity_registry.async_get_or_create(
"light",
"test",
@@ -321,45 +371,49 @@ async def test_google_config_expose_entity_prefs(
suggested_object_id="hidden_user_light",
hidden_by=er.RegistryEntryHider.USER,
)
entity_conf = {"should_expose": False}
await cloud_prefs.async_update(
google_entity_configs={"light.kitchen": entity_conf},
google_default_expose=["light"],
entity_entry5 = entity_registry.async_get_or_create(
"light",
"test",
"light_basement_id",
suggested_object_id="basement",
)
entity_entry6 = entity_registry.async_get_or_create(
"light",
"test",
"light_entrance_id",
suggested_object_id="entrance",
)
expose_new(hass, True)
expose_entity(hass, entity_entry5.entity_id, False)
state = State("light.kitchen", "on")
state_config = State(entity_entry1.entity_id, "on")
state_diagnostic = State(entity_entry2.entity_id, "on")
state_hidden_integration = State(entity_entry3.entity_id, "on")
state_hidden_user = State(entity_entry4.entity_id, "on")
state_not_exposed = State(entity_entry5.entity_id, "on")
state_exposed_default = State(entity_entry6.entity_id, "on")
# can't expose an entity which is not in the entity registry
with pytest.raises(HomeAssistantError):
expose_entity(hass, "light.kitchen", True)
assert not mock_conf.should_expose(state)
assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic)
assert not mock_conf.should_expose(state_hidden_integration)
assert not mock_conf.should_expose(state_hidden_user)
entity_conf["should_expose"] = True
assert mock_conf.should_expose(state)
# categorized and hidden entities should not be exposed
assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic)
assert not mock_conf.should_expose(state_hidden_integration)
assert not mock_conf.should_expose(state_hidden_user)
# this has been hidden
assert not mock_conf.should_expose(state_not_exposed)
# exposed by default
assert mock_conf.should_expose(state_exposed_default)
entity_conf["should_expose"] = None
assert mock_conf.should_expose(state)
# categorized and hidden entities should not be exposed
assert not mock_conf.should_expose(state_config)
assert not mock_conf.should_expose(state_diagnostic)
assert not mock_conf.should_expose(state_hidden_integration)
assert not mock_conf.should_expose(state_hidden_user)
expose_entity(hass, entity_entry5.entity_id, True)
assert mock_conf.should_expose(state_not_exposed)
await cloud_prefs.async_update(
google_default_expose=["sensor"],
)
assert not mock_conf.should_expose(state)
expose_entity(hass, entity_entry5.entity_id, None)
assert not mock_conf.should_expose(state_not_exposed)
def test_enabled_requires_valid_sub(
@@ -379,6 +433,7 @@ def test_enabled_requires_valid_sub(
async def test_setup_integration(hass: HomeAssistant, mock_conf, cloud_prefs) -> None:
"""Test that we set up the integration if used."""
assert await async_setup_component(hass, "homeassistant", {})
mock_conf._cloud.subscription_expired = False
assert "google_assistant" not in hass.config.components
@@ -423,3 +478,136 @@ async def test_google_handle_logout(
await hass.async_block_till_done()
assert len(mock_enable.return_value.mock_calls) == 1
async def test_google_config_migrate_expose_entity_prefs(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Google entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_exposed = entity_registry.async_get_or_create(
"light",
"test",
"light_exposed",
suggested_object_id="exposed",
)
entity_no_2fa_exposed = entity_registry.async_get_or_create(
"light",
"test",
"light_no_2fa_exposed",
suggested_object_id="no_2fa_exposed",
)
entity_migrated = entity_registry.async_get_or_create(
"light",
"test",
"light_migrated",
suggested_object_id="migrated",
)
entity_config = entity_registry.async_get_or_create(
"light",
"test",
"light_config",
suggested_object_id="config",
entity_category=EntityCategory.CONFIG,
)
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
entity_blocked = entity_registry.async_get_or_create(
"group",
"test",
"group_all_locks",
suggested_object_id="all_locks",
)
assert entity_blocked.entity_id == "group.all_locks"
await cloud_prefs.async_update(
google_enabled=True,
google_report_state=False,
google_settings_version=1,
)
expose_entity(hass, entity_migrated.entity_id, False)
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_no_2fa_exposed.entity_id] = {
PREF_SHOULD_EXPOSE: True,
PREF_DISABLE_2FA: True,
}
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = {
PREF_SHOULD_EXPOSE: True
}
conf = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
)
await conf.async_initialize()
entity_exposed = entity_registry.async_get(entity_exposed.entity_id)
assert entity_exposed.options == {"cloud.google_assistant": {"should_expose": True}}
entity_migrated = entity_registry.async_get(entity_migrated.entity_id)
assert entity_migrated.options == {
"cloud.google_assistant": {"should_expose": False}
}
entity_no_2fa_exposed = entity_registry.async_get(entity_no_2fa_exposed.entity_id)
assert entity_no_2fa_exposed.options == {
"cloud.google_assistant": {"disable_2fa": True, "should_expose": True}
}
entity_config = entity_registry.async_get(entity_config.entity_id)
assert entity_config.options == {"cloud.google_assistant": {"should_expose": False}}
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}
entity_blocked = entity_registry.async_get(entity_blocked.entity_id)
assert entity_blocked.options == {
"cloud.google_assistant": {"should_expose": False}
}
async def test_google_config_migrate_expose_entity_prefs_default_none(
hass: HomeAssistant,
cloud_prefs: CloudPreferences,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migrating Google entity config."""
assert await async_setup_component(hass, "homeassistant", {})
entity_default = entity_registry.async_get_or_create(
"light",
"test",
"light_default",
suggested_object_id="default",
)
await cloud_prefs.async_update(
google_enabled=True,
google_report_state=False,
google_settings_version=1,
)
cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = None
conf = CloudGoogleConfig(
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
)
await conf.async_initialize()
entity_default = entity_registry.async_get(entity_default.entity_id)
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}

View File

@@ -15,6 +15,7 @@ from homeassistant.components.alexa.entities import LightCapabilities
from homeassistant.components.cloud.const import DOMAIN
from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.util.location import LocationInfo
from . import mock_cloud, mock_cloud_prefs
@@ -399,11 +400,9 @@ async def test_websocket_status(
"alexa_enabled": True,
"cloudhooks": {},
"google_enabled": True,
"google_entity_configs": {},
"google_secure_devices_pin": None,
"google_default_expose": None,
"alexa_default_expose": None,
"alexa_entity_configs": {},
"alexa_report_state": True,
"google_report_state": True,
"remote_enabled": False,
@@ -520,8 +519,6 @@ async def test_websocket_update_preferences(
"alexa_enabled": False,
"google_enabled": False,
"google_secure_devices_pin": "1234",
"google_default_expose": ["light", "switch"],
"alexa_default_expose": ["sensor", "media_player"],
"tts_default_voice": ["en-GB", "male"],
}
)
@@ -531,8 +528,6 @@ async def test_websocket_update_preferences(
assert not setup_api.google_enabled
assert not setup_api.alexa_enabled
assert setup_api.google_secure_devices_pin == "1234"
assert setup_api.google_default_expose == ["light", "switch"]
assert setup_api.alexa_default_expose == ["sensor", "media_player"]
assert setup_api.tts_default_voice == ("en-GB", "male")
@@ -683,7 +678,11 @@ async def test_enabling_remote(
async def test_list_google_entities(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can list Google entities."""
client = await hass_ws_client(hass)
@@ -699,9 +698,25 @@ async def test_list_google_entities(
"homeassistant.components.google_assistant.helpers.async_get_entities",
return_value=[entity, entity2],
):
await client.send_json({"id": 5, "type": "cloud/google_assistant/entities"})
await client.send_json_auto_id({"type": "cloud/google_assistant/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 0
# Add the entities to the entity registry
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
entity_registry.async_get_or_create(
"cover", "test", "unique", suggested_object_id="garage"
)
with patch(
"homeassistant.components.google_assistant.helpers.async_get_entities",
return_value=[entity, entity2],
):
await client.send_json_auto_id({"type": "cloud/google_assistant/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 2
assert response["result"][0] == {
@@ -716,49 +731,118 @@ async def test_list_google_entities(
}
async def test_get_google_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can get a Google entity."""
client = await hass_ws_client(hass)
# Test getting an unknown entity
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "light.kitchen unknown or not in the entity registry",
}
# Test getting a blocked entity
entity_registry.async_get_or_create(
"group", "test", "unique", suggested_object_id="all_locks"
)
hass.states.async_set("group.all_locks", "bla")
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"}
)
response = await client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_supported",
"message": "group.all_locks not supported by Google assistant",
}
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
entity_registry.async_get_or_create(
"cover", "test", "unique", suggested_object_id="garage"
)
hass.states.async_set("light.kitchen", "on")
hass.states.async_set("cover.garage", "open", {"device_class": "garage"})
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"entity_id": "light.kitchen",
"might_2fa": False,
"traits": ["action.devices.traits.OnOff"],
}
await client.send_json_auto_id(
{"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"}
)
response = await client.receive_json()
assert response["success"]
assert response["result"] == {
"entity_id": "cover.garage",
"might_2fa": True,
"traits": ["action.devices.traits.OpenClose"],
}
async def test_update_google_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can update config of a Google entity."""
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "cloud/google_assistant/entities/update",
"entity_id": "light.kitchen",
"should_expose": False,
"disable_2fa": False,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs["light.kitchen"] == {
"should_expose": False,
"disable_2fa": False,
}
await client.send_json(
await client.send_json_auto_id(
{
"id": 6,
"type": "cloud/google_assistant/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
"type": "homeassistant/expose_entity",
"assistants": ["cloud.google_assistant"],
"entity_ids": [entry.entity_id],
"should_expose": False,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.google_entity_configs["light.kitchen"] == {
"should_expose": None,
"disable_2fa": False,
}
assert entity_registry.async_get(entry.entity_id).options[
"cloud.google_assistant"
] == {"disable_2fa": False, "should_expose": False}
async def test_list_alexa_entities(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can list Alexa entities."""
client = await hass_ws_client(hass)
@@ -769,9 +853,22 @@ async def test_list_alexa_entities(
"homeassistant.components.alexa.entities.async_get_entities",
return_value=[entity],
):
await client.send_json({"id": 5, "type": "cloud/alexa/entities"})
await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 0
# Add the entity to the entity registry
entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
with patch(
"homeassistant.components.alexa.entities.async_get_entities",
return_value=[entity],
):
await client.send_json_auto_id({"type": "cloud/alexa/entities"})
response = await client.receive_json()
assert response["success"]
assert len(response["result"]) == 1
assert response["result"][0] == {
@@ -782,37 +879,31 @@ async def test_list_alexa_entities(
async def test_update_alexa_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
setup_api,
mock_cloud_login,
) -> None:
"""Test that we can update config of an Alexa entity."""
entry = entity_registry.async_get_or_create(
"light", "test", "unique", suggested_object_id="kitchen"
)
client = await hass_ws_client(hass)
await client.send_json(
await client.send_json_auto_id(
{
"id": 5,
"type": "cloud/alexa/entities/update",
"entity_id": "light.kitchen",
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa"],
"entity_ids": [entry.entity_id],
"should_expose": False,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False}
await client.send_json(
{
"id": 6,
"type": "cloud/alexa/entities/update",
"entity_id": "light.kitchen",
"should_expose": None,
}
)
response = await client.receive_json()
assert response["success"]
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None}
assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == {
"should_expose": False
}
async def test_sync_alexa_entities_timeout(

View File

@@ -58,17 +58,17 @@ async def test_prefs_default_voice(
)
assert provider_pref.default_language == "en-US"
assert provider_pref.default_options == {"gender": "female"}
assert provider_pref.default_options == {"gender": "female", "audio_output": "mp3"}
assert provider_conf.default_language == "fr-FR"
assert provider_conf.default_options == {"gender": "female"}
assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"}
await cloud_prefs.async_update(tts_default_voice=("nl-NL", "male"))
await hass.async_block_till_done()
assert provider_pref.default_language == "nl-NL"
assert provider_pref.default_options == {"gender": "male"}
assert provider_pref.default_options == {"gender": "male", "audio_output": "mp3"}
assert provider_conf.default_language == "fr-FR"
assert provider_conf.default_options == {"gender": "female"}
assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"}
async def test_provider_properties(cloud_with_prefs) -> None:
@@ -76,7 +76,7 @@ async def test_provider_properties(cloud_with_prefs) -> None:
provider = await tts.async_get_engine(
Mock(data={const.DOMAIN: cloud_with_prefs}), None, {}
)
assert provider.supported_options == ["gender"]
assert provider.supported_options == ["gender", "audio_output"]
assert "nl-NL" in provider.supported_languages
@@ -85,5 +85,5 @@ async def test_get_tts_audio(cloud_with_prefs) -> None:
provider = await tts.async_get_engine(
Mock(data={const.DOMAIN: cloud_with_prefs}), None, {}
)
assert provider.supported_options == ["gender"]
assert provider.supported_options == ["gender", "audio_output"]
assert "nl-NL" in provider.supported_languages

View File

@@ -169,6 +169,28 @@ async def test_update_with_json_attrs(hass: HomeAssistant) -> None:
)
entity_state = hass.states.get("sensor.test")
assert entity_state
assert entity_state.state == "unknown"
assert entity_state.attributes["key"] == "some_json_value"
assert entity_state.attributes["another_key"] == "another_json_value"
assert entity_state.attributes["key_three"] == "value_three"
async def test_update_with_json_attrs_and_value_template(hass: HomeAssistant) -> None:
"""Test json_attributes can be used together with value_template."""
await setup_test_entities(
hass,
{
"command": (
'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": '
'\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'
),
"json_attributes": ["key", "another_key", "key_three"],
"value_template": '{{ value_json["key"] }}',
},
)
entity_state = hass.states.get("sensor.test")
assert entity_state
assert entity_state.state == "some_json_value"
assert entity_state.attributes["key"] == "some_json_value"
assert entity_state.attributes["another_key"] == "another_json_value"
assert entity_state.attributes["key_three"] == "value_three"

View File

@@ -1 +0,0 @@
"""Tests for the Coronavirus integration."""

View File

@@ -1,34 +0,0 @@
"""Test helpers."""
from collections.abc import Generator
from unittest.mock import AsyncMock, Mock, patch
import pytest
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.coronavirus.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(autouse=True)
def mock_cases():
"""Mock coronavirus cases."""
with patch(
"coronavirus.get_cases",
return_value=[
Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1),
Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0),
Mock(
country="Sweden",
confirmed=None,
recovered=None,
deaths=None,
current=None,
),
],
) as mock_get_cases:
yield mock_get_cases

View File

@@ -1,53 +0,0 @@
"""Test the Coronavirus config flow."""
from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientError
import pytest
from homeassistant import config_entries
from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE
from homeassistant.core import HomeAssistant
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"country": OPTION_WORLDWIDE},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "Worldwide"
assert result2["result"].unique_id == OPTION_WORLDWIDE
assert result2["data"] == {
"country": OPTION_WORLDWIDE,
}
await hass.async_block_till_done()
mock_setup_entry.assert_called_once()
@patch(
"coronavirus.get_cases",
side_effect=ClientError,
)
async def test_abort_on_connection_error(
mock_get_cases: MagicMock, hass: HomeAssistant
) -> None:
"""Test we abort on connection error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert "type" in result
assert result["type"] == "abort"
assert "reason" in result
assert result["reason"] == "cannot_connect"

View File

@@ -1,72 +0,0 @@
"""Test init of Coronavirus integration."""
from unittest.mock import MagicMock, patch
from aiohttp import ClientError
from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, mock_registry
async def test_migration(hass: HomeAssistant) -> None:
"""Test that we can migrate coronavirus to stable unique ID."""
nl_entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34})
nl_entry.add_to_hass(hass)
worldwide_entry = MockConfigEntry(
domain=DOMAIN, title="Worldwide", data={"country": OPTION_WORLDWIDE}
)
worldwide_entry.add_to_hass(hass)
mock_registry(
hass,
{
"sensor.netherlands_confirmed": er.RegistryEntry(
entity_id="sensor.netherlands_confirmed",
unique_id="34-confirmed",
platform="coronavirus",
config_entry_id=nl_entry.entry_id,
),
"sensor.worldwide_confirmed": er.RegistryEntry(
entity_id="sensor.worldwide_confirmed",
unique_id="__worldwide-confirmed",
platform="coronavirus",
config_entry_id=worldwide_entry.entry_id,
),
},
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ent_reg = er.async_get(hass)
sensor_nl = ent_reg.async_get("sensor.netherlands_confirmed")
assert sensor_nl.unique_id == "Netherlands-confirmed"
sensor_worldwide = ent_reg.async_get("sensor.worldwide_confirmed")
assert sensor_worldwide.unique_id == "__worldwide-confirmed"
assert hass.states.get("sensor.netherlands_confirmed").state == "10"
assert hass.states.get("sensor.worldwide_confirmed").state == "11"
assert nl_entry.unique_id == "Netherlands"
assert worldwide_entry.unique_id == OPTION_WORLDWIDE
@patch(
"coronavirus.get_cases",
side_effect=ClientError,
)
async def test_config_entry_not_ready(
mock_get_cases: MagicMock, hass: HomeAssistant
) -> None:
"""Test the configuration entry not ready."""
entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34})
entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.SETUP_RETRY

View File

@@ -0,0 +1,348 @@
"""Test Home Assistant exposed entities helper."""
import pytest
from homeassistant.components.homeassistant.exposed_entities import (
DATA_EXPOSED_ENTITIES,
ExposedEntities,
async_get_assistant_settings,
async_listen_entity_updates,
async_should_expose,
)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import flush_store
from tests.typing import WebSocketGenerator
async def test_load_preferences(hass: HomeAssistant) -> None:
"""Make sure that we can load/save data correctly."""
assert await async_setup_component(hass, "homeassistant", {})
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
assert exposed_entities._assistants == {}
exposed_entities.async_set_expose_new_entities("test1", True)
exposed_entities.async_set_expose_new_entities("test2", False)
assert list(exposed_entities._assistants) == ["test1", "test2"]
exposed_entities2 = ExposedEntities(hass)
await flush_store(exposed_entities._store)
await exposed_entities2.async_load()
assert exposed_entities._assistants == exposed_entities2._assistants
async def test_expose_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test expose entity."""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
entry1 = entity_registry.async_get_or_create("test", "test", "unique1")
entry2 = entity_registry.async_get_or_create("test", "test", "unique2")
# Set options
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa"],
"entity_ids": [entry1.entity_id],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
entry1 = entity_registry.async_get(entry1.entity_id)
assert entry1.options == {"cloud.alexa": {"should_expose": True}}
entry2 = entity_registry.async_get(entry2.entity_id)
assert entry2.options == {}
# Update options
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa", "cloud.google_assistant"],
"entity_ids": [entry1.entity_id, entry2.entity_id],
"should_expose": False,
}
)
response = await ws_client.receive_json()
assert response["success"]
entry1 = entity_registry.async_get(entry1.entity_id)
assert entry1.options == {
"cloud.alexa": {"should_expose": False},
"cloud.google_assistant": {"should_expose": False},
}
entry2 = entity_registry.async_get(entry2.entity_id)
assert entry2.options == {
"cloud.alexa": {"should_expose": False},
"cloud.google_assistant": {"should_expose": False},
}
async def test_expose_entity_unknown(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test behavior when exposing an unknown entity."""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
# Set options
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa"],
"entity_ids": ["test.test"],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_found",
"message": "can't expose 'test.test'",
}
with pytest.raises(HomeAssistantError):
exposed_entities.async_expose_entity("cloud.alexa", "test.test", True)
async def test_expose_entity_blocked(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test behavior when exposing a blocked entity."""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
# Set options
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa"],
"entity_ids": ["group.all_locks"],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"] == {
"code": "not_allowed",
"message": "can't expose 'group.all_locks'",
}
@pytest.mark.parametrize("expose_new", [True, False])
async def test_expose_new_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
expose_new,
) -> None:
"""Test expose entity."""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
entry1 = entity_registry.async_get_or_create("climate", "test", "unique1")
entry2 = entity_registry.async_get_or_create("climate", "test", "unique2")
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_new_entities/get",
"assistant": "cloud.alexa",
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {"expose_new": False}
# Check if exposed - should be False
assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False
# Expose new entities to Alexa
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_new_entities/set",
"assistant": "cloud.alexa",
"expose_new": expose_new,
}
)
response = await ws_client.receive_json()
assert response["success"]
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_new_entities/get",
"assistant": "cloud.alexa",
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {"expose_new": expose_new}
# Check again if exposed - should still be False
assert async_should_expose(hass, "cloud.alexa", entry1.entity_id) is False
# Check if exposed - should be True
assert async_should_expose(hass, "cloud.alexa", entry2.entity_id) == expose_new
async def test_listen_updates(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test listen to updates."""
calls = []
def listener():
calls.append(None)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
async_listen_entity_updates(hass, "cloud.alexa", listener)
entry = entity_registry.async_get_or_create("climate", "test", "unique1")
# Call for another assistant - listener not called
exposed_entities.async_expose_entity(
"cloud.google_assistant", entry.entity_id, True
)
assert len(calls) == 0
# Call for our assistant - listener called
exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True)
assert len(calls) == 1
# Settings not changed - listener not called
exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True)
assert len(calls) == 1
# Settings changed - listener called
exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, False)
assert len(calls) == 2
async def test_get_assistant_settings(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test get assistant settings."""
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
entry = entity_registry.async_get_or_create("climate", "test", "unique1")
assert async_get_assistant_settings(hass, "cloud.alexa") == {}
exposed_entities.async_expose_entity("cloud.alexa", entry.entity_id, True)
assert async_get_assistant_settings(hass, "cloud.alexa") == {
"climate.test_unique1": {"should_expose": True}
}
assert async_get_assistant_settings(hass, "cloud.google_assistant") == {}
async def test_should_expose(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test expose entity."""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
# Expose new entities to Alexa
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_new_entities/set",
"assistant": "cloud.alexa",
"expose_new": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
# Unknown entity is not exposed
assert async_should_expose(hass, "test.test", "test.test") is False
# Blocked entity is not exposed
entry_blocked = entity_registry.async_get_or_create(
"group", "test", "unique", suggested_object_id="all_locks"
)
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", entry_blocked.entity_id) is False
# Lock is exposed
lock1 = entity_registry.async_get_or_create("lock", "test", "unique1")
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", lock1.entity_id) is True
# Hidden entity is not exposed
lock2 = entity_registry.async_get_or_create(
"lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER
)
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", lock2.entity_id) is False
# Entity with category is not exposed
lock3 = entity_registry.async_get_or_create(
"lock", "test", "unique3", entity_category=EntityCategory.CONFIG
)
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", lock3.entity_id) is False
# Binary sensor without device class is not exposed
binarysensor1 = entity_registry.async_get_or_create(
"binary_sensor", "test", "unique1"
)
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", binarysensor1.entity_id) is False
# Binary sensor with certain device class is exposed
binarysensor2 = entity_registry.async_get_or_create(
"binary_sensor",
"test",
"unique2",
original_device_class="door",
)
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", binarysensor2.entity_id) is True
# Sensor without device class is not exposed
sensor1 = entity_registry.async_get_or_create("sensor", "test", "unique1")
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", sensor1.entity_id) is False
# Sensor with certain device class is exposed
sensor2 = entity_registry.async_get_or_create(
"sensor",
"test",
"unique2",
original_device_class="temperature",
)
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
assert async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True

View File

@@ -26,10 +26,10 @@ TEST_ENTITY = "calendar.light_schedule"
class FakeStore(LocalCalendarStore):
"""Mock storage implementation."""
def __init__(self, hass: HomeAssistant, path: Path) -> None:
def __init__(self, hass: HomeAssistant, path: Path, ics_content: str) -> None:
"""Initialize FakeStore."""
super().__init__(hass, path)
self._content = ""
self._content = ics_content
def _load(self) -> str:
"""Read from calendar storage."""
@@ -40,15 +40,21 @@ class FakeStore(LocalCalendarStore):
self._content = ics_content
@pytest.fixture(name="ics_content", autouse=True)
def mock_ics_content() -> str:
"""Fixture to allow tests to set initial ics content for the calendar store."""
return ""
@pytest.fixture(name="store", autouse=True)
def mock_store() -> Generator[None, None, None]:
def mock_store(ics_content: str) -> Generator[None, None, None]:
"""Test cleanup, remove any media storage persisted during the test."""
stores: dict[Path, FakeStore] = {}
def new_store(hass: HomeAssistant, path: Path) -> FakeStore:
if path not in stores:
stores[path] = FakeStore(hass, path)
stores[path] = FakeStore(hass, path, ics_content)
return stores[path]
with patch(

View File

@@ -1,6 +1,7 @@
"""Tests for calendar platform of local calendar."""
import datetime
import textwrap
import pytest
@@ -940,3 +941,91 @@ async def test_create_event_service(
"location": "Test Location",
}
]
@pytest.mark.parametrize(
"ics_content",
[
textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART:19970714
DTEND:19970714
END:VEVENT
END:VCALENDAR
"""
),
textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART:19970714
DTEND:19970710
END:VEVENT
END:VCALENDAR
"""
),
],
ids=["no_duration", "negative"],
)
async def test_invalid_all_day_event(
ws_client: ClientFixture,
setup_integration: None,
get_events: GetEventsFn,
) -> None:
"""Test all day events with invalid durations, which are coerced to be valid."""
events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z")
assert list(map(event_fields, events)) == [
{
"summary": "Bastille Day Party",
"start": {"date": "1997-07-14"},
"end": {"date": "1997-07-15"},
}
]
@pytest.mark.parametrize(
"ics_content",
[
textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART:19970714T110000
DTEND:19970714T110000
END:VEVENT
END:VCALENDAR
"""
),
textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART:19970714T110000
DTEND:19970710T100000
END:VEVENT
END:VCALENDAR
"""
),
],
ids=["no_duration", "negative"],
)
async def test_invalid_event_duration(
ws_client: ClientFixture,
setup_integration: None,
get_events: GetEventsFn,
) -> None:
"""Test events with invalid durations, which are coerced to be valid."""
events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z")
assert list(map(event_fields, events)) == [
{
"summary": "Bastille Day Party",
"start": {"dateTime": "1997-07-14T11:00:00-06:00"},
"end": {"dateTime": "1997-07-14T11:30:00-06:00"},
}
]

View File

@@ -1,6 +1,8 @@
"""The tests for the mailbox component."""
from datetime import datetime
from hashlib import sha1
from http import HTTPStatus
from typing import Any
from aiohttp.test_utils import TestClient
import pytest
@@ -8,20 +10,111 @@ import pytest
from homeassistant.bootstrap import async_setup_component
import homeassistant.components.mailbox as mailbox
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from tests.common import assert_setup_component
from tests.common import MockModule, mock_integration, mock_platform
from tests.typing import ClientSessionGenerator
MAILBOX_NAME = "TestMailbox"
MEDIA_DATA = b"3f67c4ea33b37d1710f"
MESSAGE_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
def _create_message(idx: int) -> dict[str, Any]:
"""Create a sample message."""
msgtime = dt_util.as_timestamp(datetime(2010, 12, idx + 1, 13, 17, 00))
msgtxt = f"Message {idx + 1}. {MESSAGE_TEXT}"
msgsha = sha1(msgtxt.encode("utf-8")).hexdigest()
return {
"info": {
"origtime": int(msgtime),
"callerid": "John Doe <212-555-1212>",
"duration": "10",
},
"text": msgtxt,
"sha": msgsha,
}
class TestMailbox(mailbox.Mailbox):
"""Test Mailbox, with 10 sample messages."""
def __init__(self, hass: HomeAssistant, name: str) -> None:
"""Initialize Test mailbox."""
super().__init__(hass, name)
self._messages: dict[str, dict[str, Any]] = {}
for idx in range(0, 10):
msg = _create_message(idx)
msgsha = msg["sha"]
self._messages[msgsha] = msg
@property
def media_type(self) -> str:
"""Return the supported media type."""
return mailbox.CONTENT_TYPE_MPEG
@property
def can_delete(self) -> bool:
"""Return if messages can be deleted."""
return True
@property
def has_media(self) -> bool:
"""Return if messages have attached media files."""
return True
async def async_get_media(self, msgid: str) -> bytes:
"""Return the media blob for the msgid."""
if msgid not in self._messages:
raise mailbox.StreamError("Message not found")
return MEDIA_DATA
async def async_get_messages(self) -> list[dict[str, Any]]:
"""Return a list of the current messages."""
return sorted(
self._messages.values(),
key=lambda item: item["info"]["origtime"], # type: ignore[no-any-return]
reverse=True,
)
async def async_delete(self, msgid: str) -> bool:
"""Delete the specified messages."""
if msgid in self._messages:
del self._messages[msgid]
self.async_update()
return True
class MockMailbox:
"""A mock mailbox platform."""
async def async_get_handler(
self,
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> mailbox.Mailbox:
"""Set up the Test mailbox."""
return TestMailbox(hass, MAILBOX_NAME)
@pytest.fixture
def mock_mailbox(hass: HomeAssistant) -> None:
"""Mock mailbox."""
mock_integration(hass, MockModule(domain="test"))
mock_platform(hass, "test.mailbox", MockMailbox())
@pytest.fixture
async def mock_http_client(
hass: HomeAssistant, hass_client: ClientSessionGenerator
hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_mailbox: None
) -> TestClient:
"""Start the Home Assistant HTTP component."""
config = {mailbox.DOMAIN: {"platform": "demo"}}
with assert_setup_component(1, mailbox.DOMAIN):
await async_setup_component(hass, mailbox.DOMAIN, config)
await hass.async_block_till_done()
assert await async_setup_component(
hass, mailbox.DOMAIN, {mailbox.DOMAIN: {"platform": "test"}}
)
return await hass_client()
@@ -33,12 +126,12 @@ async def test_get_platforms_from_mailbox(mock_http_client: TestClient) -> None:
assert req.status == HTTPStatus.OK
result = await req.json()
assert len(result) == 1
assert result[0].get("name") == "DemoMailbox"
assert result[0].get("name") == "TestMailbox"
async def test_get_messages_from_mailbox(mock_http_client: TestClient) -> None:
"""Get messages from mailbox."""
url = "/api/mailbox/messages/DemoMailbox"
url = "/api/mailbox/messages/TestMailbox"
req = await mock_http_client.get(url)
assert req.status == HTTPStatus.OK
@@ -48,11 +141,11 @@ async def test_get_messages_from_mailbox(mock_http_client: TestClient) -> None:
async def test_get_media_from_mailbox(mock_http_client: TestClient) -> None:
"""Get audio from mailbox."""
mp3sha = "3f67c4ea33b37d1710f772a26dd3fb43bb159d50"
mp3sha = "7cad61312c7b66f619295be2da8c7ac73b4968f1"
msgtxt = "Message 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
msgsha = sha1(msgtxt.encode("utf-8")).hexdigest()
url = f"/api/mailbox/media/DemoMailbox/{msgsha}"
url = f"/api/mailbox/media/TestMailbox/{msgsha}"
req = await mock_http_client.get(url)
assert req.status == HTTPStatus.OK
data = await req.read()
@@ -67,11 +160,11 @@ async def test_delete_from_mailbox(mock_http_client: TestClient) -> None:
msgsha2 = sha1(msgtxt2.encode("utf-8")).hexdigest()
for msg in [msgsha1, msgsha2]:
url = f"/api/mailbox/delete/DemoMailbox/{msg}"
url = f"/api/mailbox/delete/TestMailbox/{msg}"
req = await mock_http_client.delete(url)
assert req.status == HTTPStatus.OK
url = "/api/mailbox/messages/DemoMailbox"
url = "/api/mailbox/messages/TestMailbox"
req = await mock_http_client.get(url)
assert req.status == HTTPStatus.OK
result = await req.json()
@@ -98,7 +191,7 @@ async def test_get_media_from_invalid_mailbox(mock_http_client: TestClient) -> N
async def test_get_media_from_invalid_msgid(mock_http_client: TestClient) -> None:
"""Get messages from mailbox."""
msgsha = "0000000000000000000000000000000000000000"
url = f"/api/mailbox/media/DemoMailbox/{msgsha}"
url = f"/api/mailbox/media/TestMailbox/{msgsha}"
req = await mock_http_client.get(url)
assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR

View File

@@ -35,7 +35,6 @@ def storage_collection(hass):
id_manager = collection.IDManager()
return person.PersonStorageCollection(
person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY),
logging.getLogger(f"{person.__name__}.storage_collection"),
id_manager,
collection.YamlCollection(
logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager

View File

@@ -1,4 +1,5 @@
"""The tests for the TTS component."""
import asyncio
from http import HTTPStatus
from typing import Any
from unittest.mock import patch
@@ -996,3 +997,73 @@ async def test_support_options(hass: HomeAssistant, setup_tts) -> None:
await tts.async_support_options(hass, "test", "en", {"invalid_option": "yo"})
is False
)
async def test_fetching_in_async(hass: HomeAssistant, hass_client) -> None:
"""Test async fetching of data."""
tts_audio = asyncio.Future()
class ProviderWithAsyncFetching(MockProvider):
"""Provider that supports audio output option."""
@property
def supported_options(self) -> list[str]:
"""Return list of supported options like voice, emotions."""
return [tts.ATTR_AUDIO_OUTPUT]
@property
def default_options(self) -> dict[str, str]:
"""Return a dict including the default options."""
return {tts.ATTR_AUDIO_OUTPUT: "mp3"}
async def async_get_tts_audio(
self, message: str, language: str, options: dict[str, Any] | None = None
) -> tts.TtsAudioType:
return ("mp3", await tts_audio)
mock_integration(hass, MockModule(domain="test"))
mock_platform(hass, "test.tts", MockTTS(ProviderWithAsyncFetching))
assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}})
# Test async_get_media_source_audio
media_source_id = tts.generate_media_source_id(
hass, "test message", "test", "en", None, None
)
task = hass.async_create_task(
tts.async_get_media_source_audio(hass, media_source_id)
)
task2 = hass.async_create_task(
tts.async_get_media_source_audio(hass, media_source_id)
)
url = await get_media_source_url(hass, media_source_id)
client = await hass_client()
client_get_task = hass.async_create_task(client.get(url))
# Make sure that tasks are waiting for our future to resolve
done, pending = await asyncio.wait((task, task2, client_get_task), timeout=0.1)
assert len(done) == 0
assert len(pending) == 3
tts_audio.set_result(b"test")
assert await task == ("mp3", b"test")
assert await task2 == ("mp3", b"test")
req = await client_get_task
assert req.status == HTTPStatus.OK
assert await req.read() == b"test"
# Test error is not cached
media_source_id = tts.generate_media_source_id(
hass, "test message 2", "test", "en", None, None
)
tts_audio = asyncio.Future()
tts_audio.set_exception(HomeAssistantError("test error"))
with pytest.raises(HomeAssistantError):
assert await tts.async_get_media_source_audio(hass, media_source_id)
tts_audio = asyncio.Future()
tts_audio.set_result(b"test 2")
await tts.async_get_media_source_audio(hass, media_source_id) == ("mp3", b"test 2")

View File

@@ -209,6 +209,29 @@
}),
'unit_of_measurement': None,
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fan_air_quality',
'icon': None,
'name': None,
'original_device_class': None,
'original_icon': None,
'original_name': 'Fan Air Quality',
'state': dict({
'attributes': dict({
'friendly_name': 'Fan Air Quality',
}),
'entity_id': 'sensor.fan_air_quality',
'last_changed': str,
'last_updated': str,
'state': 'unavailable',
}),
'unit_of_measurement': None,
}),
dict({
'device_class': None,
'disabled': False,
@@ -234,29 +257,6 @@
}),
'unit_of_measurement': '%',
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.fan_air_quality',
'icon': None,
'name': None,
'original_device_class': None,
'original_icon': None,
'original_name': 'Fan Air Quality',
'state': dict({
'attributes': dict({
'friendly_name': 'Fan Air Quality',
}),
'entity_id': 'sensor.fan_air_quality',
'last_changed': str,
'last_updated': str,
'state': 'unavailable',
}),
'unit_of_measurement': None,
}),
]),
'name': 'Fan',
'name_by_user': None,

View File

@@ -85,6 +85,9 @@ async def test_async_get_device_diagnostics__single_fan(
diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device)
assert isinstance(diag, dict)
diag["home_assistant"]["entities"] = sorted(
diag["home_assistant"]["entities"], key=lambda ent: ent["entity_id"]
)
assert diag == snapshot(
matcher=path_type(
{

View File

@@ -117,7 +117,7 @@ async def mock_stt_provider(hass) -> MockSttProvider:
return MockSttProvider(hass, _TRANSCRIPT)
@pytest.fixture(autouse=True)
@pytest.fixture
async def init_components(
hass: HomeAssistant,
mock_stt_provider: MockSttProvider,

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
async def test_pipeline_from_audio_stream(
hass: HomeAssistant, mock_stt_provider, snapshot: SnapshotAssertion
hass: HomeAssistant, mock_stt_provider, init_components, snapshot: SnapshotAssertion
) -> None:
"""Test creating a pipeline from an audio stream."""

View File

@@ -0,0 +1,104 @@
"""Websocket tests for Voice Assistant integration."""
from typing import Any
from homeassistant.components.voice_assistant.const import DOMAIN
from homeassistant.components.voice_assistant.pipeline import (
STORAGE_KEY,
STORAGE_VERSION,
PipelineStorageCollection,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.setup import async_setup_component
from tests.common import flush_store
async def test_load_datasets(hass: HomeAssistant, init_components) -> None:
"""Make sure that we can load/save data correctly."""
pipelines = [
{
"conversation_engine": "conversation_engine_1",
"language": "language_1",
"name": "name_1",
"stt_engine": "stt_engine_1",
"tts_engine": "tts_engine_1",
},
{
"conversation_engine": "conversation_engine_2",
"language": "language_2",
"name": "name_2",
"stt_engine": "stt_engine_2",
"tts_engine": "tts_engine_2",
},
{
"conversation_engine": "conversation_engine_3",
"language": "language_3",
"name": "name_3",
"stt_engine": "stt_engine_3",
"tts_engine": "tts_engine_3",
},
]
pipeline_ids = []
store1: PipelineStorageCollection = hass.data[DOMAIN]
for pipeline in pipelines:
pipeline_ids.append((await store1.async_create_item(pipeline)).id)
assert len(store1.data) == 3
await store1.async_delete_item(pipeline_ids[1])
assert len(store1.data) == 2
store2 = PipelineStorageCollection(Store(hass, STORAGE_VERSION, STORAGE_KEY))
await flush_store(store1.store)
await store2.async_load()
assert len(store2.data) == 2
assert store1.data is not store2.data
assert store1.data == store2.data
async def test_loading_datasets_from_storage(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test loading stored datasets on start."""
hass_storage[STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"key": "voice_assistant.pipelines",
"data": {
"items": [
{
"conversation_engine": "conversation_engine_1",
"id": "01GX8ZWBAQYWNB1XV3EXEZ75DY",
"language": "language_1",
"name": "name_1",
"stt_engine": "stt_engine_1",
"tts_engine": "tts_engine_1",
},
{
"conversation_engine": "conversation_engine_2",
"id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX",
"language": "language_2",
"name": "name_2",
"stt_engine": "stt_engine_2",
"tts_engine": "tts_engine_2",
},
{
"conversation_engine": "conversation_engine_3",
"id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J",
"language": "language_3",
"name": "name_3",
"stt_engine": "stt_engine_3",
"tts_engine": "tts_engine_3",
},
]
},
}
assert await async_setup_component(hass, "voice_assistant", {})
store: PipelineStorageCollection = hass.data[DOMAIN]
assert len(store.data) == 3

View File

@@ -1,9 +1,14 @@
"""Websocket tests for Voice Assistant integration."""
import asyncio
from unittest.mock import MagicMock, patch
from unittest.mock import ANY, MagicMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.voice_assistant.const import DOMAIN
from homeassistant.components.voice_assistant.pipeline import (
Pipeline,
PipelineStorageCollection,
)
from homeassistant.core import HomeAssistant
from tests.typing import WebSocketGenerator
@@ -12,6 +17,7 @@ from tests.typing import WebSocketGenerator
async def test_text_only_pipeline(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test events from a pipeline run with text input (no STT/TTS)."""
@@ -51,7 +57,10 @@ async def test_text_only_pipeline(
async def test_audio_pipeline(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test events from a pipeline run with audio input/output."""
client = await hass_ws_client(hass)
@@ -271,6 +280,7 @@ async def test_audio_pipeline_timeout(
async def test_stt_provider_missing(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test events from a pipeline run with a non-existent STT provider."""
@@ -297,6 +307,7 @@ async def test_stt_provider_missing(
async def test_stt_stream_failed(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test events from a pipeline run with a non-existent STT provider."""
@@ -398,3 +409,205 @@ async def test_invalid_stage_order(
# result
msg = await client.receive_json()
assert not msg["success"]
async def test_add_pipeline(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components
) -> None:
"""Test we can add a pipeline."""
client = await hass_ws_client(hass)
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN]
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/create",
"conversation_engine": "test_conversation_engine",
"language": "test_language",
"name": "test_name",
"stt_engine": "test_stt_engine",
"tts_engine": "test_tts_engine",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"conversation_engine": "test_conversation_engine",
"id": ANY,
"language": "test_language",
"name": "test_name",
"stt_engine": "test_stt_engine",
"tts_engine": "test_tts_engine",
}
assert len(pipeline_store.data) == 1
pipeline = pipeline_store.data[msg["result"]["id"]]
assert pipeline == Pipeline(
conversation_engine="test_conversation_engine",
id=msg["result"]["id"],
language="test_language",
name="test_name",
stt_engine="test_stt_engine",
tts_engine="test_tts_engine",
)
async def test_delete_pipeline(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components
) -> None:
"""Test we can delete a pipeline."""
client = await hass_ws_client(hass)
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN]
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/create",
"conversation_engine": "test_conversation_engine",
"language": "test_language",
"name": "test_name",
"stt_engine": "test_stt_engine",
"tts_engine": "test_tts_engine",
}
)
msg = await client.receive_json()
assert msg["success"]
assert len(pipeline_store.data) == 1
pipeline_id = msg["result"]["id"]
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/delete",
"pipeline_id": pipeline_id,
}
)
msg = await client.receive_json()
assert msg["success"]
assert len(pipeline_store.data) == 0
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/delete",
"pipeline_id": pipeline_id,
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"] == {
"code": "not_found",
"message": f"Unable to find pipeline_id {pipeline_id}",
}
async def test_list_pipelines(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components
) -> None:
"""Test we can list pipelines."""
client = await hass_ws_client(hass)
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN]
await client.send_json_auto_id({"type": "voice_assistant/pipeline/list"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == []
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/create",
"conversation_engine": "test_conversation_engine",
"language": "test_language",
"name": "test_name",
"stt_engine": "test_stt_engine",
"tts_engine": "test_tts_engine",
}
)
msg = await client.receive_json()
assert msg["success"]
assert len(pipeline_store.data) == 1
await client.send_json_auto_id({"type": "voice_assistant/pipeline/list"})
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == [
{
"conversation_engine": "test_conversation_engine",
"id": ANY,
"language": "test_language",
"name": "test_name",
"stt_engine": "test_stt_engine",
"tts_engine": "test_tts_engine",
}
]
async def test_update_pipeline(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components
) -> None:
"""Test we can list pipelines."""
client = await hass_ws_client(hass)
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN]
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/update",
"conversation_engine": "new_conversation_engine",
"language": "new_language",
"name": "new_name",
"pipeline_id": "no_such_pipeline",
"stt_engine": "new_stt_engine",
"tts_engine": "new_tts_engine",
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"] == {
"code": "not_found",
"message": "Unable to find pipeline_id no_such_pipeline",
}
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/create",
"conversation_engine": "test_conversation_engine",
"language": "test_language",
"name": "test_name",
"stt_engine": "test_stt_engine",
"tts_engine": "test_tts_engine",
}
)
msg = await client.receive_json()
assert msg["success"]
pipeline_id = msg["result"]["id"]
assert len(pipeline_store.data) == 1
await client.send_json_auto_id(
{
"type": "voice_assistant/pipeline/update",
"conversation_engine": "new_conversation_engine",
"language": "new_language",
"name": "new_name",
"pipeline_id": pipeline_id,
"stt_engine": "new_stt_engine",
"tts_engine": "new_tts_engine",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] == {
"conversation_engine": "new_conversation_engine",
"language": "new_language",
"name": "new_name",
"id": pipeline_id,
"stt_engine": "new_stt_engine",
"tts_engine": "new_tts_engine",
}
assert len(pipeline_store.data) == 1
pipeline = pipeline_store.data[pipeline_id]
assert pipeline == Pipeline(
conversation_engine="new_conversation_engine",
id=pipeline_id,
language="new_language",
name="new_name",
stt_engine="new_stt_engine",
tts_engine="new_tts_engine",
)

View File

@@ -82,7 +82,7 @@ class MockObservableCollection(collection.ObservableCollection):
return entity_class.from_storage(config)
class MockStorageCollection(collection.StorageCollection):
class MockStorageCollection(collection.DictStorageCollection):
"""Mock storage collection."""
async def _process_create_data(self, data: dict) -> dict:
@@ -96,9 +96,9 @@ class MockStorageCollection(collection.StorageCollection):
"""Suggest an ID based on the config."""
return info["name"]
async def _update_data(self, data: dict, update_data: dict) -> dict:
async def _update_data(self, item: dict, update_data: dict) -> dict:
"""Return a new updated data object."""
return {**data, **update_data}
return {**item, **update_data}
def test_id_manager() -> None:
@@ -116,7 +116,7 @@ def test_id_manager() -> None:
async def test_observable_collection() -> None:
"""Test observerable collection."""
coll = collection.ObservableCollection(_LOGGER)
coll = collection.ObservableCollection(None)
assert coll.async_items() == []
coll.data["bla"] = 1
assert coll.async_items() == [1]
@@ -202,7 +202,7 @@ async def test_storage_collection(hass: HomeAssistant) -> None:
}
)
id_manager = collection.IDManager()
coll = MockStorageCollection(store, _LOGGER, id_manager)
coll = MockStorageCollection(store, id_manager)
changes = track_changes(coll)
await coll.async_load()
@@ -257,7 +257,7 @@ async def test_attach_entity_component_collection(hass: HomeAssistant) -> None:
"""Test attaching collection to entity component."""
ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass)
await ent_comp.async_setup({})
coll = MockObservableCollection(_LOGGER)
coll = MockObservableCollection(None)
collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, MockEntity)
await coll.notify_changes(
@@ -297,7 +297,7 @@ async def test_entity_component_collection_abort(hass: HomeAssistant) -> None:
"""Test aborted entity adding is handled."""
ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass)
await ent_comp.async_setup({})
coll = MockObservableCollection(_LOGGER)
coll = MockObservableCollection(None)
async_update_config_calls = []
async_remove_calls = []
@@ -364,7 +364,7 @@ async def test_entity_component_collection_entity_removed(hass: HomeAssistant) -
"""Test entity removal is handled."""
ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass)
await ent_comp.async_setup({})
coll = MockObservableCollection(_LOGGER)
coll = MockObservableCollection(None)
async_update_config_calls = []
async_remove_calls = []
@@ -434,7 +434,7 @@ async def test_storage_collection_websocket(
) -> None:
"""Test exposing a storage collection via websockets."""
store = storage.Store(hass, 1, "test-data")
coll = MockStorageCollection(store, _LOGGER)
coll = MockStorageCollection(store)
changes = track_changes(coll)
collection.StorageCollectionWebsocket(
coll,