forked from home-assistant/core
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bce18bf61a | |||
| eda0731e60 | |||
| 238c87055f | |||
| 4b4464a3de | |||
| a07fbdd61c | |||
| 3126ebe9d6 | |||
| 89aec9d356 | |||
| 0cfa566ff6 | |||
| fffece95f5 | |||
| c61e29709c | |||
| 458fe17a48 | |||
| 15fdefd23b | |||
| 576f9600b5 | |||
| 7a62574360 | |||
| 0251d677d8 | |||
| 2cd9b94ecb | |||
| 3cd2ab2319 | |||
| 4f0d403393 | |||
| b558cf8b59 | |||
| 820c7b77ce | |||
| 9d0fc916fc | |||
| 387f07a97f | |||
| 44968cfc7c | |||
| c6751bed86 | |||
| b87e3860d9 | |||
| 8ef6bd85f5 | |||
| ad4fed4f60 | |||
| 1050895657 | |||
| c31d657206 | |||
| 88343bed77 | |||
| 51a10a84da | |||
| 5f3bbf2804 | |||
| b8eebf085c | |||
| cdfd53e1cc | |||
| ca147dd97e | |||
| 5b1278d885 | |||
| 0db28dcf4d | |||
| 7c651665c5 | |||
| 2f3964e3ce | |||
| eef95fa0d4 | |||
| 43a1eb043b | |||
| 6b77775ed5 | |||
| 7077d23127 | |||
| c7eac0ebbb | |||
| 7f13033f69 | |||
| eba201e71b | |||
| 1e9d777201 | |||
| 030b7f8a37 | |||
| 8cbc69fc92 | |||
| 2a5f5ea039 | |||
| 0ba662e7bc | |||
| 05530d656a | |||
| 2b2be6a333 | |||
| 5bd54490ea | |||
| 00a28caa6d | |||
| c4aa6ba262 | |||
| 7a90db903b | |||
| fe279c8593 | |||
| ddf5a9fbcc | |||
| 093d5d6176 |
@@ -60,6 +60,7 @@ class AlexaConfig(AbstractConfig):
|
||||
"""Return an identifier for the user that represents this config."""
|
||||
return ""
|
||||
|
||||
@core.callback
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if not self._config[CONF_FILTER].empty_filter:
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from atenpdu import AtenPE, AtenPEError
|
||||
from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
from yalexs.util import get_latest_activity
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
@@ -169,12 +170,11 @@ class ActivityStream(AugustSubscriberMixin):
|
||||
device_id = activity.device_id
|
||||
activity_type = activity.activity_type
|
||||
device_activities = self._latest_activities.setdefault(device_id, {})
|
||||
lastest_activity = device_activities.get(activity_type)
|
||||
|
||||
# Ignore activities that are older than the latest one
|
||||
# Ignore activities that are older than the latest one unless it is a non
|
||||
# locking or unlocking activity with the exact same start time.
|
||||
if (
|
||||
lastest_activity
|
||||
and lastest_activity.activity_start_time >= activity.activity_start_time
|
||||
get_latest_activity(activity, device_activities.get(activity_type))
|
||||
!= activity
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.activity import SOURCE_PUBNUB, ActivityType
|
||||
from yalexs.lock import LockStatus
|
||||
from yalexs.util import update_lock_detail_from_activity
|
||||
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
|
||||
|
||||
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -90,17 +90,26 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
|
||||
@callback
|
||||
def _update_from_data(self):
|
||||
"""Get the latest state of the sensor and update activity."""
|
||||
lock_activity = self._data.activity_stream.get_latest_device_activity(
|
||||
self._device_id,
|
||||
{ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
|
||||
activity_stream = self._data.activity_stream
|
||||
device_id = self._device_id
|
||||
if lock_activity := activity_stream.get_latest_device_activity(
|
||||
device_id,
|
||||
{ActivityType.LOCK_OPERATION},
|
||||
):
|
||||
self._attr_changed_by = lock_activity.operated_by
|
||||
|
||||
lock_activity_without_operator = activity_stream.get_latest_device_activity(
|
||||
device_id,
|
||||
{ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
|
||||
)
|
||||
|
||||
if lock_activity is not None:
|
||||
self._attr_changed_by = lock_activity.operated_by
|
||||
update_lock_detail_from_activity(self._detail, lock_activity)
|
||||
# If the source is pubnub the lock must be online since its a live update
|
||||
if lock_activity.source == SOURCE_PUBNUB:
|
||||
if latest_activity := get_latest_activity(
|
||||
lock_activity_without_operator, lock_activity
|
||||
):
|
||||
if latest_activity.source == SOURCE_PUBNUB:
|
||||
# If the source is pubnub the lock must be online since its a live update
|
||||
self._detail.set_online(True)
|
||||
update_lock_detail_from_activity(self._detail, latest_activity)
|
||||
|
||||
bridge_activity = self._data.activity_stream.get_latest_device_activity(
|
||||
self._device_id, {ActivityType.BRIDGE_OPERATION}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.3.2", "yalexs-ble==2.1.16"]
|
||||
"requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.16"]
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.20.1",
|
||||
"bleak==0.20.2",
|
||||
"bleak-retry-connector==3.0.2",
|
||||
"bluetooth-adapters==0.15.3",
|
||||
"bluetooth-auto-recovery==1.0.3",
|
||||
"bluetooth-auto-recovery==1.1.2",
|
||||
"bluetooth-data-tools==0.4.0",
|
||||
"dbus-fast==1.85.0"
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pybravia"],
|
||||
"requirements": ["pybravia==0.3.2"],
|
||||
"requirements": ["pybravia==0.3.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-sony-com:service:ScalarWebAPI:1",
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.alexa import (
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
@@ -29,6 +30,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
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, start
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
@@ -104,7 +106,11 @@ def entity_supported(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
if domain in SUPPORTED_DOMAINS:
|
||||
return True
|
||||
|
||||
device_class = get_device_class(hass, entity_id)
|
||||
try:
|
||||
device_class = get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
# The entity no longer exists
|
||||
return False
|
||||
if (
|
||||
domain == "binary_sensor"
|
||||
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
|
||||
@@ -193,35 +199,44 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
# 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)
|
||||
for state in self.hass.states.async_all():
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_ALEXA,
|
||||
state.entity_id,
|
||||
self._should_expose_legacy(state.entity_id),
|
||||
)
|
||||
for entity_id in self._prefs.alexa_entity_configs:
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_ALEXA,
|
||||
entity_id,
|
||||
self._should_expose_legacy(entity_id),
|
||||
)
|
||||
|
||||
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 on_hass_started(hass):
|
||||
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_listen_entity_updates(
|
||||
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
|
||||
)
|
||||
|
||||
async def hass_started(hass):
|
||||
async def on_hass_start(hass):
|
||||
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
|
||||
await async_setup_component(self.hass, ALEXA_DOMAIN, {})
|
||||
|
||||
start.async_at_start(self.hass, hass_started)
|
||||
start.async_at_start(self.hass, on_hass_start)
|
||||
start.async_at_started(self.hass, on_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,
|
||||
@@ -257,6 +272,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
and entity_supported(self.hass, entity_id)
|
||||
)
|
||||
|
||||
@callback
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if not self._config[CONF_FILTER].empty_filter:
|
||||
|
||||
@@ -11,7 +11,10 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
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_expose_entity,
|
||||
async_get_entity_settings,
|
||||
async_listen_entity_updates,
|
||||
async_set_assistant_option,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
@@ -23,6 +26,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
split_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er, start
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -171,34 +175,59 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
# 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
|
||||
for state in self.hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
self._should_expose_legacy(entity_id),
|
||||
)
|
||||
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
|
||||
async_set_assistant_option(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
PREF_DISABLE_2FA,
|
||||
_2fa_disabled,
|
||||
)
|
||||
for entity_id in self._prefs.google_entity_configs:
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
self._should_expose_legacy(entity_id),
|
||||
)
|
||||
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
|
||||
async_set_assistant_option(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
PREF_DISABLE_2FA,
|
||||
_2fa_disabled,
|
||||
)
|
||||
|
||||
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 on_hass_started(hass: HomeAssistant) -> None:
|
||||
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_listen_entity_updates(
|
||||
self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated
|
||||
)
|
||||
|
||||
async def hass_started(hass):
|
||||
async def on_hass_start(hass: HomeAssistant) -> None:
|
||||
if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components:
|
||||
await async_setup_component(self.hass, GOOGLE_DOMAIN, {})
|
||||
|
||||
start.async_at_start(self.hass, hass_started)
|
||||
start.async_at_start(self.hass, on_hass_start)
|
||||
start.async_at_started(self.hass, on_hass_started)
|
||||
|
||||
# Remove any stored user agent id that is not ours
|
||||
remove_agent_user_ids = []
|
||||
@@ -210,9 +239,6 @@ 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,
|
||||
@@ -289,14 +315,13 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
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:
|
||||
try:
|
||||
settings = async_get_entity_settings(self.hass, state.entity_id)
|
||||
except HomeAssistantError:
|
||||
# Handle the entity has been removed
|
||||
return False
|
||||
|
||||
assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {})
|
||||
assistant_options = settings.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):
|
||||
@@ -382,7 +407,7 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
self.async_schedule_google_sync_all()
|
||||
|
||||
@callback
|
||||
def _handle_device_registry_updated(self, event: Event) -> None:
|
||||
async def _handle_device_registry_updated(self, event: Event) -> None:
|
||||
"""Handle when device registry updated."""
|
||||
if (
|
||||
not self.enabled
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The HTTP api to control the cloud integration."""
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
import dataclasses
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
@@ -21,11 +22,12 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
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.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.location import async_detect_location_info
|
||||
|
||||
@@ -566,15 +568,14 @@ async def google_assistant_get(
|
||||
"""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:
|
||||
if not state:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_FOUND,
|
||||
f"{entity_id} unknown or not in the entity registry",
|
||||
f"{entity_id} unknown",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -587,10 +588,16 @@ async def google_assistant_get(
|
||||
)
|
||||
return
|
||||
|
||||
assistant_options: Mapping[str, Any] = {}
|
||||
with suppress(HomeAssistantError, KeyError):
|
||||
settings = exposed_entities.async_get_entity_settings(hass, entity_id)
|
||||
assistant_options = settings[CLOUD_GOOGLE]
|
||||
|
||||
result = {
|
||||
"entity_id": entity.entity_id,
|
||||
"traits": [trait.name for trait in entity.traits()],
|
||||
"might_2fa": entity.might_2fa_traits(),
|
||||
PREF_DISABLE_2FA: assistant_options.get(PREF_DISABLE_2FA),
|
||||
}
|
||||
|
||||
connection.send_result(msg["id"], result)
|
||||
@@ -609,14 +616,11 @@ 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,
|
||||
@@ -645,27 +649,19 @@ async def google_assistant_update(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update google assistant entity config."""
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
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
|
||||
assistant_options: Mapping[str, Any] = {}
|
||||
with suppress(HomeAssistantError, KeyError):
|
||||
settings = exposed_entities.async_get_entity_settings(hass, entity_id)
|
||||
assistant_options = settings[CLOUD_GOOGLE]
|
||||
|
||||
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:
|
||||
if 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
|
||||
exposed_entities.async_set_assistant_option(
|
||||
hass, CLOUD_GOOGLE, entity_id, PREF_DISABLE_2FA, disable_2fa
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -686,17 +682,8 @@ async def alexa_get(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Get data for a single alexa entity."""
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not entity_registry.async_is_registered(entity_id):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_FOUND,
|
||||
f"{entity_id} not in the entity registry",
|
||||
)
|
||||
return
|
||||
|
||||
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES or not entity_supported_by_alexa(
|
||||
hass, entity_id
|
||||
):
|
||||
@@ -723,14 +710,11 @@ 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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Const for conversation integration."""
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
HOME_ASSISTANT_AGENT = "homeassistant"
|
||||
|
||||
@@ -21,19 +21,21 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
intent,
|
||||
start,
|
||||
template,
|
||||
translation,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
@@ -81,16 +83,24 @@ def async_setup(hass: core.HomeAssistant) -> None:
|
||||
async_should_expose(hass, DOMAIN, entity_id)
|
||||
|
||||
@core.callback
|
||||
def async_handle_entity_registry_changed(event: core.Event) -> None:
|
||||
"""Set expose flag on newly created entities."""
|
||||
if event.data["action"] == "create":
|
||||
async_should_expose(hass, DOMAIN, event.data["entity_id"])
|
||||
def async_entity_state_listener(
|
||||
changed_entity: str,
|
||||
old_state: core.State | None,
|
||||
new_state: core.State | None,
|
||||
):
|
||||
"""Set expose flag on new entities."""
|
||||
if old_state is not None or new_state is None:
|
||||
return
|
||||
async_should_expose(hass, DOMAIN, changed_entity)
|
||||
|
||||
hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
async_handle_entity_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
@core.callback
|
||||
def async_hass_started(hass: core.HomeAssistant) -> None:
|
||||
"""Set expose flag on all entities."""
|
||||
for state in hass.states.async_all():
|
||||
async_should_expose(hass, DOMAIN, state.entity_id)
|
||||
async_track_state_change(hass, MATCH_ALL, async_entity_state_listener)
|
||||
|
||||
start.async_at_started(hass, async_hass_started)
|
||||
|
||||
|
||||
class DefaultAgent(AbstractConversationAgent):
|
||||
@@ -130,6 +140,11 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
self._async_handle_entity_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
core.EVENT_STATE_CHANGED,
|
||||
self._async_handle_state_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
async_listen_entity_updates(
|
||||
self.hass, DOMAIN, self._async_exposed_entities_updated
|
||||
)
|
||||
@@ -186,6 +201,7 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
user_input.text,
|
||||
user_input.context,
|
||||
language,
|
||||
assistant=DOMAIN,
|
||||
)
|
||||
except intent.IntentHandleError:
|
||||
_LOGGER.exception("Intent handling error")
|
||||
@@ -475,12 +491,19 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
@core.callback
|
||||
def _async_handle_entity_registry_changed(self, event: core.Event) -> None:
|
||||
"""Clear names list cache when an entity registry entry has changed."""
|
||||
if event.data["action"] == "update" and not any(
|
||||
if event.data["action"] != "update" or not any(
|
||||
field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
|
||||
):
|
||||
return
|
||||
self._slot_lists = None
|
||||
|
||||
@core.callback
|
||||
def _async_handle_state_changed(self, event: core.Event) -> None:
|
||||
"""Clear names list cache when a state is added or removed from the state machine."""
|
||||
if event.data.get("old_state") and event.data.get("new_state"):
|
||||
return
|
||||
self._slot_lists = None
|
||||
|
||||
@core.callback
|
||||
def _async_exposed_entities_updated(self) -> None:
|
||||
"""Handle updated preferences."""
|
||||
@@ -493,30 +516,38 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
|
||||
area_ids_with_entities: set[str] = set()
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entities = [
|
||||
entity
|
||||
for entity in entity_registry.entities.values()
|
||||
if async_should_expose(self.hass, DOMAIN, entity.entity_id)
|
||||
states = [
|
||||
state
|
||||
for state in self.hass.states.async_all()
|
||||
if async_should_expose(self.hass, DOMAIN, state.entity_id)
|
||||
]
|
||||
devices = dr.async_get(self.hass)
|
||||
|
||||
# Gather exposed entity names
|
||||
entity_names = []
|
||||
for entity in entities:
|
||||
for state in states:
|
||||
# Checked against "requires_context" and "excludes_context" in hassil
|
||||
context = {"domain": entity.domain}
|
||||
if entity.device_class:
|
||||
context[ATTR_DEVICE_CLASS] = entity.device_class
|
||||
context = {"domain": state.domain}
|
||||
if state.attributes:
|
||||
# Include some attributes
|
||||
for attr in DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
if attr not in state.attributes:
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
||||
entity = entity_registry.async_get(state.entity_id)
|
||||
|
||||
if not entity:
|
||||
# Default name
|
||||
entity_names.append((state.name, state.name, context))
|
||||
continue
|
||||
|
||||
if entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
entity_names.append((alias, alias, context))
|
||||
|
||||
# Default name
|
||||
name = entity.async_friendly_name(self.hass) or entity.entity_id.replace(
|
||||
"_", " "
|
||||
)
|
||||
entity_names.append((name, name, context))
|
||||
entity_names.append((state.name, state.name, context))
|
||||
|
||||
if entity.area_id:
|
||||
# Expose area too
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/eddystone_temperature",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["beacontools"],
|
||||
"requirements": ["beacontools[scan]==1.2.3", "construct==2.10.56"]
|
||||
"requirements": ["beacontools[scan]==2.1.0", "construct==2.10.56"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20230428.0"]
|
||||
"requirements": ["home-assistant-frontend==20230503.2"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
from itertools import chain
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -77,16 +78,41 @@ class AssistantPreferences:
|
||||
return {"expose_new": self.expose_new}
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ExposedEntity:
|
||||
"""An exposed entity without a unique_id."""
|
||||
|
||||
assistants: dict[str, dict[str, Any]]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
"""Return a JSON serializable representation for storage."""
|
||||
return {
|
||||
"assistants": self.assistants,
|
||||
}
|
||||
|
||||
|
||||
class SerializedExposedEntities(TypedDict):
|
||||
"""Serialized exposed entities storage storage collection."""
|
||||
|
||||
assistants: dict[str, dict[str, Any]]
|
||||
exposed_entities: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
class ExposedEntities:
|
||||
"""Control assistant settings."""
|
||||
"""Control assistant settings.
|
||||
|
||||
Settings for entities without a unique_id are stored in the store.
|
||||
Settings for entities with a unique_id are stored in the entity registry.
|
||||
"""
|
||||
|
||||
_assistants: dict[str, AssistantPreferences]
|
||||
entities: dict[str, ExposedEntity]
|
||||
|
||||
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(
|
||||
self._store: Store[SerializedExposedEntities] = Store(
|
||||
hass, STORAGE_VERSION, STORAGE_KEY
|
||||
)
|
||||
|
||||
@@ -95,7 +121,8 @@ class ExposedEntities:
|
||||
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()
|
||||
websocket_api.async_register_command(self._hass, ws_list_exposed_entities)
|
||||
await self._async_load_data()
|
||||
|
||||
@callback
|
||||
def async_listen_entity_updates(
|
||||
@@ -105,30 +132,57 @@ class ExposedEntities:
|
||||
self._listeners.setdefault(assistant, []).append(listener)
|
||||
|
||||
@callback
|
||||
def async_expose_entity(
|
||||
self, assistant: str, entity_id: str, should_expose: bool
|
||||
def async_set_assistant_option(
|
||||
self, assistant: str, entity_id: str, key: str, value: Any
|
||||
) -> None:
|
||||
"""Expose an entity to an assistant.
|
||||
"""Set an option for 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")
|
||||
return self._async_set_legacy_assistant_option(
|
||||
assistant, entity_id, key, value
|
||||
)
|
||||
|
||||
assistant_options: Mapping[str, Any]
|
||||
if (
|
||||
assistant_options := registry_entry.options.get(assistant, {})
|
||||
) and assistant_options.get("should_expose") == should_expose:
|
||||
) and assistant_options.get(key) == value:
|
||||
return
|
||||
|
||||
assistant_options = assistant_options | {"should_expose": should_expose}
|
||||
assistant_options = assistant_options | {key: value}
|
||||
entity_registry.async_update_entity_options(
|
||||
entity_id, assistant, assistant_options
|
||||
)
|
||||
for listener in self._listeners.get(assistant, []):
|
||||
listener()
|
||||
|
||||
def _async_set_legacy_assistant_option(
|
||||
self, assistant: str, entity_id: str, key: str, value: Any
|
||||
) -> None:
|
||||
"""Set an option for an assistant.
|
||||
|
||||
Notify listeners if expose flag was changed.
|
||||
"""
|
||||
if (
|
||||
(exposed_entity := self.entities.get(entity_id))
|
||||
and (assistant_options := exposed_entity.assistants.get(assistant, {}))
|
||||
and assistant_options.get(key) == value
|
||||
):
|
||||
return
|
||||
|
||||
if exposed_entity:
|
||||
new_exposed_entity = self._update_exposed_entity(
|
||||
assistant, entity_id, key, value
|
||||
)
|
||||
else:
|
||||
new_exposed_entity = self._new_exposed_entity(assistant, key, value)
|
||||
self.entities[entity_id] = new_exposed_entity
|
||||
self._async_schedule_save()
|
||||
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."""
|
||||
@@ -150,6 +204,11 @@ class ExposedEntities:
|
||||
entity_registry = er.async_get(self._hass)
|
||||
result: dict[str, Mapping[str, Any]] = {}
|
||||
|
||||
options: Mapping | None
|
||||
for entity_id, exposed_entity in self.entities.items():
|
||||
if options := exposed_entity.assistants.get(assistant):
|
||||
result[entity_id] = options
|
||||
|
||||
for entity_id, entry in entity_registry.entities.items():
|
||||
if options := entry.options.get(assistant):
|
||||
result[entity_id] = options
|
||||
@@ -162,11 +221,16 @@ class ExposedEntities:
|
||||
entity_registry = er.async_get(self._hass)
|
||||
result: dict[str, Mapping[str, Any]] = {}
|
||||
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
assistant_settings: Mapping
|
||||
if registry_entry := entity_registry.async_get(entity_id):
|
||||
assistant_settings = registry_entry.options
|
||||
elif exposed_entity := self.entities.get(entity_id):
|
||||
assistant_settings = exposed_entity.assistants
|
||||
else:
|
||||
raise HomeAssistantError("Unknown entity")
|
||||
|
||||
for assistant in KNOWN_ASSISTANTS:
|
||||
if options := registry_entry.options.get(assistant):
|
||||
if options := assistant_settings.get(assistant):
|
||||
result[assistant] = options
|
||||
|
||||
return result
|
||||
@@ -181,9 +245,7 @@ class ExposedEntities:
|
||||
|
||||
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
|
||||
|
||||
return self._async_should_expose_legacy_entity(assistant, entity_id)
|
||||
if assistant in registry_entry.options:
|
||||
if "should_expose" in registry_entry.options[assistant]:
|
||||
should_expose = registry_entry.options[assistant]["should_expose"]
|
||||
@@ -202,11 +264,42 @@ class ExposedEntities:
|
||||
|
||||
return should_expose
|
||||
|
||||
def _async_should_expose_legacy_entity(
|
||||
self, assistant: str, entity_id: str
|
||||
) -> bool:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
should_expose: bool
|
||||
|
||||
if (
|
||||
exposed_entity := self.entities.get(entity_id)
|
||||
) and assistant in exposed_entity.assistants:
|
||||
if "should_expose" in exposed_entity.assistants[assistant]:
|
||||
should_expose = exposed_entity.assistants[assistant]["should_expose"]
|
||||
return should_expose
|
||||
|
||||
if self.async_get_expose_new_entities(assistant):
|
||||
should_expose = self._is_default_exposed(entity_id, None)
|
||||
else:
|
||||
should_expose = False
|
||||
|
||||
if exposed_entity:
|
||||
new_exposed_entity = self._update_exposed_entity(
|
||||
assistant, entity_id, "should_expose", should_expose
|
||||
)
|
||||
else:
|
||||
new_exposed_entity = self._new_exposed_entity(
|
||||
assistant, "should_expose", should_expose
|
||||
)
|
||||
self.entities[entity_id] = new_exposed_entity
|
||||
self._async_schedule_save()
|
||||
|
||||
return should_expose
|
||||
|
||||
def _is_default_exposed(
|
||||
self, entity_id: str, registry_entry: er.RegistryEntry
|
||||
self, entity_id: str, registry_entry: er.RegistryEntry | None
|
||||
) -> bool:
|
||||
"""Return True if an entity is exposed by default."""
|
||||
if (
|
||||
if registry_entry and (
|
||||
registry_entry.entity_category is not None
|
||||
or registry_entry.hidden_by is not None
|
||||
):
|
||||
@@ -216,7 +309,11 @@ class ExposedEntities:
|
||||
if domain in DEFAULT_EXPOSED_DOMAINS:
|
||||
return True
|
||||
|
||||
device_class = get_device_class(self._hass, entity_id)
|
||||
try:
|
||||
device_class = get_device_class(self._hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
# The entity no longer exists
|
||||
return False
|
||||
if (
|
||||
domain == "binary_sensor"
|
||||
and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
|
||||
@@ -228,17 +325,43 @@ class ExposedEntities:
|
||||
|
||||
return False
|
||||
|
||||
async def async_load(self) -> None:
|
||||
def _update_exposed_entity(
|
||||
self, assistant: str, entity_id: str, key: str, value: Any
|
||||
) -> ExposedEntity:
|
||||
"""Update an exposed entity."""
|
||||
entity = self.entities[entity_id]
|
||||
assistants = dict(entity.assistants)
|
||||
old_settings = assistants.get(assistant, {})
|
||||
assistants[assistant] = old_settings | {key: value}
|
||||
return ExposedEntity(assistants)
|
||||
|
||||
def _new_exposed_entity(
|
||||
self, assistant: str, key: str, value: Any
|
||||
) -> ExposedEntity:
|
||||
"""Create a new exposed entity."""
|
||||
return ExposedEntity(
|
||||
assistants={assistant: {key: value}},
|
||||
)
|
||||
|
||||
async def _async_load_data(self) -> SerializedExposedEntities | None:
|
||||
"""Load from the store."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
assistants: dict[str, AssistantPreferences] = {}
|
||||
exposed_entities: dict[str, ExposedEntity] = {}
|
||||
|
||||
if data:
|
||||
for domain, preferences in data["assistants"].items():
|
||||
assistants[domain] = AssistantPreferences(**preferences)
|
||||
|
||||
if data and "exposed_entities" in data:
|
||||
for entity_id, preferences in data["exposed_entities"].items():
|
||||
exposed_entities[entity_id] = ExposedEntity(**preferences)
|
||||
|
||||
self._assistants = assistants
|
||||
self.entities = exposed_entities
|
||||
|
||||
return data
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
@@ -246,17 +369,19 @@ class ExposedEntities:
|
||||
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()
|
||||
def _data_to_save(self) -> SerializedExposedEntities:
|
||||
"""Return JSON-compatible date for storing to file."""
|
||||
return {
|
||||
"assistants": {
|
||||
domain: preferences.to_json()
|
||||
for domain, preferences in self._assistants.items()
|
||||
},
|
||||
"exposed_entities": {
|
||||
entity_id: entity.to_json()
|
||||
for entity_id, entity in self.entities.items()
|
||||
},
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@@ -272,7 +397,6 @@ 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(
|
||||
@@ -288,28 +412,37 @@ def ws_expose_entity(
|
||||
)
|
||||
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"]
|
||||
)
|
||||
async_expose_entity(hass, 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_entity/list",
|
||||
}
|
||||
)
|
||||
def ws_list_exposed_entities(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Expose an entity to an assistant."""
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
|
||||
result[entity_id] = {}
|
||||
entity_settings = async_get_entity_settings(hass, entity_id)
|
||||
for assistant, settings in entity_settings.items():
|
||||
if "should_expose" not in settings:
|
||||
continue
|
||||
result[entity_id][assistant] = settings["should_expose"]
|
||||
connection.send_result(msg["id"], {"exposed_entities": result})
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
@@ -380,8 +513,9 @@ def async_expose_entity(
|
||||
should_expose: bool,
|
||||
) -> None:
|
||||
"""Get assistant expose settings for an entity."""
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
exposed_entities.async_expose_entity(assistant, entity_id, should_expose)
|
||||
async_set_assistant_option(
|
||||
hass, assistant, entity_id, "should_expose", should_expose
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -389,3 +523,15 @@ def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) ->
|
||||
"""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)
|
||||
|
||||
|
||||
@callback
|
||||
def async_set_assistant_option(
|
||||
hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any
|
||||
) -> None:
|
||||
"""Set an option for an assistant.
|
||||
|
||||
Notify listeners if expose flag was changed.
|
||||
"""
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
exposed_entities.async_set_assistant_option(assistant, entity_id, option, value)
|
||||
|
||||
@@ -140,16 +140,18 @@ class GetStateIntentHandler(intent.IntentHandler):
|
||||
area=area,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s",
|
||||
"Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s",
|
||||
len(states),
|
||||
name,
|
||||
area,
|
||||
domains,
|
||||
device_classes,
|
||||
intent_obj.assistant,
|
||||
)
|
||||
|
||||
# Create response
|
||||
|
||||
@@ -205,13 +205,20 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT
|
||||
)
|
||||
|
||||
def get_number_of_zones(self) -> int:
|
||||
"""Return the number of zones.
|
||||
|
||||
If the number of zones is not yet populated, return 0
|
||||
"""
|
||||
return len(self.device.color_zones) if self.device.color_zones else 0
|
||||
|
||||
@callback
|
||||
def _async_build_color_zones_update_requests(self) -> list[Callable]:
|
||||
"""Build a color zones update request."""
|
||||
device = self.device
|
||||
return [
|
||||
partial(device.get_color_zones, start_index=zone)
|
||||
for zone in range(0, len(device.color_zones), 8)
|
||||
for zone in range(0, self.get_number_of_zones(), 8)
|
||||
]
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
@@ -224,7 +231,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
):
|
||||
await self._async_populate_device_info()
|
||||
|
||||
num_zones = len(device.color_zones) if device.color_zones is not None else 0
|
||||
num_zones = self.get_number_of_zones()
|
||||
features = lifx_features(self.device)
|
||||
is_extended_multizone = features["extended_multizone"]
|
||||
is_legacy_multizone = not is_extended_multizone and features["multizone"]
|
||||
@@ -256,7 +263,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
if is_extended_multizone or is_legacy_multizone:
|
||||
self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
|
||||
if is_legacy_multizone and num_zones != len(device.color_zones):
|
||||
if is_legacy_multizone and num_zones != self.get_number_of_zones():
|
||||
# The number of zones has changed so we need
|
||||
# to update the zones again. This happens rarely.
|
||||
await self.async_get_color_zones()
|
||||
|
||||
@@ -382,7 +382,7 @@ class LIFXMultiZone(LIFXColor):
|
||||
"""Send a color change to the bulb."""
|
||||
bulb = self.bulb
|
||||
color_zones = bulb.color_zones
|
||||
num_zones = len(color_zones)
|
||||
num_zones = self.coordinator.get_number_of_zones()
|
||||
|
||||
# Zone brightness is not reported when powered off
|
||||
if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None:
|
||||
|
||||
@@ -129,7 +129,7 @@ class LocalCalendarEntity(CalendarEntity):
|
||||
recurrence_range=range_value,
|
||||
)
|
||||
except EventStoreError as err:
|
||||
raise HomeAssistantError("Error while deleting event: {err}") from err
|
||||
raise HomeAssistantError(f"Error while deleting event: {err}") from err
|
||||
await self._async_store()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
@@ -153,7 +153,7 @@ class LocalCalendarEntity(CalendarEntity):
|
||||
recurrence_range=range_value,
|
||||
)
|
||||
except EventStoreError as err:
|
||||
raise HomeAssistantError("Error while updating event: {err}") from err
|
||||
raise HomeAssistantError(f"Error while updating event: {err}") from err
|
||||
await self._async_store()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nina",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynina"],
|
||||
"requirements": ["pynina==0.2.0"]
|
||||
"requirements": ["pynina==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aionotion"],
|
||||
"requirements": ["aionotion==2023.04.2"]
|
||||
"requirements": ["aionotion==2023.05.0"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""The ONVIF integration."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from httpx import RequestError
|
||||
@@ -57,6 +58,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not setup camera {device.device.host}:{device.device.port}: {err}"
|
||||
) from err
|
||||
except asyncio.CancelledError as err:
|
||||
# After https://github.com/agronholm/anyio/issues/374 is resolved
|
||||
# this may be able to be removed
|
||||
await device.device.close()
|
||||
raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err
|
||||
|
||||
if not device.available:
|
||||
raise ConfigEntryNotReady()
|
||||
|
||||
@@ -6,12 +6,13 @@ from contextlib import suppress
|
||||
import datetime as dt
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from httpx import RequestError
|
||||
import onvif
|
||||
from onvif import ONVIFCamera
|
||||
from onvif.exceptions import ONVIFError
|
||||
from zeep.exceptions import Fault, XMLParseError
|
||||
from zeep.exceptions import Fault, TransportError, XMLParseError, XMLSyntaxError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -55,6 +56,7 @@ class ONVIFDevice:
|
||||
|
||||
self.info: DeviceInfo = DeviceInfo()
|
||||
self.capabilities: Capabilities = Capabilities()
|
||||
self.onvif_capabilities: dict[str, Any] | None = None
|
||||
self.profiles: list[Profile] = []
|
||||
self.max_resolution: int = 0
|
||||
self.platforms: list[Platform] = []
|
||||
@@ -98,6 +100,11 @@ class ONVIFDevice:
|
||||
|
||||
# Get all device info
|
||||
await self.device.update_xaddrs()
|
||||
LOGGER.debug("%s: xaddrs = %s", self.name, self.device.xaddrs)
|
||||
|
||||
# Get device capabilities
|
||||
self.onvif_capabilities = await self.device.get_capabilities()
|
||||
|
||||
await self.async_check_date_and_time()
|
||||
|
||||
# Create event manager
|
||||
@@ -106,9 +113,20 @@ class ONVIFDevice:
|
||||
|
||||
# Fetch basic device info and capabilities
|
||||
self.info = await self.async_get_device_info()
|
||||
LOGGER.debug("Camera %s info = %s", self.name, self.info)
|
||||
LOGGER.debug("%s: camera info = %s", self.name, self.info)
|
||||
|
||||
#
|
||||
# We need to check capabilities before profiles, because we need the data
|
||||
# from capabilities to determine profiles correctly.
|
||||
#
|
||||
# We no longer initialize events in capabilities to avoid the problem
|
||||
# where cameras become slow to respond for a bit after starting events, and
|
||||
# instead we start events last and than update capabilities.
|
||||
#
|
||||
LOGGER.debug("%s: fetching initial capabilities", self.name)
|
||||
self.capabilities = await self.async_get_capabilities()
|
||||
LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities)
|
||||
|
||||
LOGGER.debug("%s: fetching profiles", self.name)
|
||||
self.profiles = await self.async_get_profiles()
|
||||
LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles)
|
||||
|
||||
@@ -117,6 +135,7 @@ class ONVIFDevice:
|
||||
raise ONVIFError("No camera profiles found")
|
||||
|
||||
if self.capabilities.ptz:
|
||||
LOGGER.debug("%s: creating PTZ service", self.name)
|
||||
self.device.create_ptz_service()
|
||||
|
||||
# Determine max resolution from profiles
|
||||
@@ -126,6 +145,12 @@ class ONVIFDevice:
|
||||
if profile.video.encoding == "H264"
|
||||
)
|
||||
|
||||
# Start events last since some cameras become slow to respond
|
||||
# for a bit after starting events
|
||||
LOGGER.debug("%s: starting events", self.name)
|
||||
self.capabilities.events = await self.async_start_events()
|
||||
LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities)
|
||||
|
||||
async def async_stop(self, event=None):
|
||||
"""Shut it all down."""
|
||||
if self.events:
|
||||
@@ -178,81 +203,104 @@ class ONVIFDevice:
|
||||
"""Warns if device and system date not synced."""
|
||||
LOGGER.debug("%s: Setting up the ONVIF device management service", self.name)
|
||||
device_mgmt = self.device.create_devicemgmt_service()
|
||||
system_date = dt_util.utcnow()
|
||||
|
||||
LOGGER.debug("%s: Retrieving current device date/time", self.name)
|
||||
try:
|
||||
system_date = dt_util.utcnow()
|
||||
device_time = await device_mgmt.GetSystemDateAndTime()
|
||||
if not device_time:
|
||||
LOGGER.debug(
|
||||
"""Couldn't get device '%s' date/time.
|
||||
GetSystemDateAndTime() return null/empty""",
|
||||
self.name,
|
||||
)
|
||||
return
|
||||
|
||||
LOGGER.debug("%s: Device time: %s", self.name, device_time)
|
||||
|
||||
tzone = dt_util.DEFAULT_TIME_ZONE
|
||||
cdate = device_time.LocalDateTime
|
||||
if device_time.UTCDateTime:
|
||||
tzone = dt_util.UTC
|
||||
cdate = device_time.UTCDateTime
|
||||
elif device_time.TimeZone:
|
||||
tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone
|
||||
|
||||
if cdate is None:
|
||||
LOGGER.warning(
|
||||
"%s: Could not retrieve date/time on this camera", self.name
|
||||
)
|
||||
else:
|
||||
cam_date = dt.datetime(
|
||||
cdate.Date.Year,
|
||||
cdate.Date.Month,
|
||||
cdate.Date.Day,
|
||||
cdate.Time.Hour,
|
||||
cdate.Time.Minute,
|
||||
cdate.Time.Second,
|
||||
0,
|
||||
tzone,
|
||||
)
|
||||
|
||||
cam_date_utc = cam_date.astimezone(dt_util.UTC)
|
||||
|
||||
LOGGER.debug(
|
||||
"%s: Device date/time: %s | System date/time: %s",
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
|
||||
dt_diff = cam_date - system_date
|
||||
self._dt_diff_seconds = dt_diff.total_seconds()
|
||||
|
||||
# It could be off either direction, so we need to check the absolute value
|
||||
if abs(self._dt_diff_seconds) > 5:
|
||||
LOGGER.warning(
|
||||
(
|
||||
"The date/time on %s (UTC) is '%s', "
|
||||
"which is different from the system '%s', "
|
||||
"this could lead to authentication issues"
|
||||
),
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
if device_time.DateTimeType == "Manual":
|
||||
# Set Date and Time ourselves if Date and Time is set manually in the camera.
|
||||
await self.async_manually_set_date_and_time()
|
||||
except RequestError as err:
|
||||
LOGGER.warning(
|
||||
"Couldn't get device '%s' date/time. Error: %s", self.name, err
|
||||
)
|
||||
return
|
||||
|
||||
if not device_time:
|
||||
LOGGER.debug(
|
||||
"""Couldn't get device '%s' date/time.
|
||||
GetSystemDateAndTime() return null/empty""",
|
||||
self.name,
|
||||
)
|
||||
return
|
||||
|
||||
LOGGER.debug("%s: Device time: %s", self.name, device_time)
|
||||
|
||||
tzone = dt_util.DEFAULT_TIME_ZONE
|
||||
cdate = device_time.LocalDateTime
|
||||
if device_time.UTCDateTime:
|
||||
tzone = dt_util.UTC
|
||||
cdate = device_time.UTCDateTime
|
||||
elif device_time.TimeZone:
|
||||
tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone
|
||||
|
||||
if cdate is None:
|
||||
LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name)
|
||||
return
|
||||
|
||||
cam_date = dt.datetime(
|
||||
cdate.Date.Year,
|
||||
cdate.Date.Month,
|
||||
cdate.Date.Day,
|
||||
cdate.Time.Hour,
|
||||
cdate.Time.Minute,
|
||||
cdate.Time.Second,
|
||||
0,
|
||||
tzone,
|
||||
)
|
||||
|
||||
cam_date_utc = cam_date.astimezone(dt_util.UTC)
|
||||
|
||||
LOGGER.debug(
|
||||
"%s: Device date/time: %s | System date/time: %s",
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
|
||||
dt_diff = cam_date - system_date
|
||||
self._dt_diff_seconds = dt_diff.total_seconds()
|
||||
|
||||
# It could be off either direction, so we need to check the absolute value
|
||||
if abs(self._dt_diff_seconds) < 5:
|
||||
return
|
||||
|
||||
LOGGER.warning(
|
||||
(
|
||||
"The date/time on %s (UTC) is '%s', "
|
||||
"which is different from the system '%s', "
|
||||
"this could lead to authentication issues"
|
||||
),
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
|
||||
if device_time.DateTimeType != "Manual":
|
||||
return
|
||||
|
||||
# Set Date and Time ourselves if Date and Time is set manually in the camera.
|
||||
try:
|
||||
await self.async_manually_set_date_and_time()
|
||||
except (RequestError, TransportError):
|
||||
LOGGER.warning("%s: Could not sync date/time on this camera", self.name)
|
||||
|
||||
async def async_get_device_info(self) -> DeviceInfo:
|
||||
"""Obtain information about this device."""
|
||||
device_mgmt = self.device.create_devicemgmt_service()
|
||||
device_info = await device_mgmt.GetDeviceInformation()
|
||||
manufacturer = None
|
||||
model = None
|
||||
firmware_version = None
|
||||
serial_number = None
|
||||
try:
|
||||
device_info = await device_mgmt.GetDeviceInformation()
|
||||
except (XMLParseError, XMLSyntaxError, TransportError) as ex:
|
||||
# Some cameras have invalid UTF-8 in their device information (TransportError)
|
||||
# and others have completely invalid XML (XMLParseError, XMLSyntaxError)
|
||||
LOGGER.warning("%s: Failed to fetch device information: %s", self.name, ex)
|
||||
else:
|
||||
manufacturer = device_info.Manufacturer
|
||||
model = device_info.Model
|
||||
firmware_version = device_info.FirmwareVersion
|
||||
serial_number = device_info.SerialNumber
|
||||
|
||||
# Grab the last MAC address for backwards compatibility
|
||||
mac = None
|
||||
@@ -272,10 +320,10 @@ class ONVIFDevice:
|
||||
)
|
||||
|
||||
return DeviceInfo(
|
||||
device_info.Manufacturer,
|
||||
device_info.Model,
|
||||
device_info.FirmwareVersion,
|
||||
device_info.SerialNumber,
|
||||
manufacturer,
|
||||
model,
|
||||
firmware_version,
|
||||
serial_number,
|
||||
mac,
|
||||
)
|
||||
|
||||
@@ -297,16 +345,31 @@ class ONVIFDevice:
|
||||
self.device.create_imaging_service()
|
||||
imaging = True
|
||||
|
||||
events = False
|
||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError):
|
||||
events = await self.events.async_start()
|
||||
return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging)
|
||||
|
||||
return Capabilities(snapshot, events, ptz, imaging)
|
||||
async def async_start_events(self):
|
||||
"""Start the event handler."""
|
||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError):
|
||||
onvif_capabilities = self.onvif_capabilities or {}
|
||||
pull_point_support = (onvif_capabilities.get("Events") or {}).get(
|
||||
"WSPullPointSupport"
|
||||
)
|
||||
LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support)
|
||||
return await self.events.async_start(pull_point_support is not False, True)
|
||||
|
||||
return False
|
||||
|
||||
async def async_get_profiles(self) -> list[Profile]:
|
||||
"""Obtain media profiles for this device."""
|
||||
media_service = self.device.create_media_service()
|
||||
result = await media_service.GetProfiles()
|
||||
LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr)
|
||||
try:
|
||||
result = await media_service.GetProfiles()
|
||||
except GET_CAPABILITIES_EXCEPTIONS:
|
||||
LOGGER.debug(
|
||||
"%s: Could not get profiles from ONVIF device", self.name, exc_info=True
|
||||
)
|
||||
raise
|
||||
profiles: list[Profile] = []
|
||||
|
||||
if not isinstance(result, list):
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx import RemoteProtocolError, RequestError, TransportError
|
||||
from onvif import ONVIFCamera, ONVIFService
|
||||
from onvif.client import NotificationManager
|
||||
from onvif.exceptions import ONVIFError
|
||||
from zeep.exceptions import Fault, XMLParseError
|
||||
from zeep.exceptions import Fault, ValidationError, XMLParseError
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -35,7 +35,7 @@ from .util import stringify_onvif_error
|
||||
UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"}
|
||||
|
||||
SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError)
|
||||
CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError)
|
||||
CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError)
|
||||
SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError)
|
||||
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||
@@ -123,11 +123,13 @@ class EventManager:
|
||||
if not self._listeners:
|
||||
self.pullpoint_manager.async_cancel_pull_messages()
|
||||
|
||||
async def async_start(self) -> bool:
|
||||
async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool:
|
||||
"""Start polling events."""
|
||||
# Always start pull point first, since it will populate the event list
|
||||
event_via_pull_point = await self.pullpoint_manager.async_start()
|
||||
events_via_webhook = await self.webhook_manager.async_start()
|
||||
event_via_pull_point = (
|
||||
try_pullpoint and await self.pullpoint_manager.async_start()
|
||||
)
|
||||
events_via_webhook = try_webhook and await self.webhook_manager.async_start()
|
||||
return events_via_webhook or event_via_pull_point
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
@@ -655,16 +657,34 @@ class WebHookManager:
|
||||
|
||||
async def _async_create_webhook_subscription(self) -> None:
|
||||
"""Create webhook subscription."""
|
||||
LOGGER.debug("%s: Creating webhook subscription", self._name)
|
||||
LOGGER.debug(
|
||||
"%s: Creating webhook subscription with URL: %s",
|
||||
self._name,
|
||||
self._webhook_url,
|
||||
)
|
||||
self._notification_manager = self._device.create_notification_manager(
|
||||
{
|
||||
"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME,
|
||||
"ConsumerReference": {"Address": self._webhook_url},
|
||||
}
|
||||
)
|
||||
self._webhook_subscription = await self._notification_manager.setup()
|
||||
try:
|
||||
self._webhook_subscription = await self._notification_manager.setup()
|
||||
except ValidationError as err:
|
||||
# This should only happen if there is a problem with the webhook URL
|
||||
# that is causing it to not be well formed.
|
||||
LOGGER.exception(
|
||||
"%s: validation error while creating webhook subscription: %s",
|
||||
self._name,
|
||||
err,
|
||||
)
|
||||
raise
|
||||
await self._notification_manager.start()
|
||||
LOGGER.debug("%s: Webhook subscription created", self._name)
|
||||
LOGGER.debug(
|
||||
"%s: Webhook subscription created with URL: %s",
|
||||
self._name,
|
||||
self._webhook_url,
|
||||
)
|
||||
|
||||
async def _async_start_webhook(self) -> bool:
|
||||
"""Start webhook."""
|
||||
@@ -769,6 +789,7 @@ class WebHookManager:
|
||||
return
|
||||
|
||||
webhook_id = self._webhook_unique_id
|
||||
self._async_unregister_webhook()
|
||||
webhook.async_register(
|
||||
self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/onvif",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": ["onvif-zeep-async==1.3.0", "WSDiscovery==2.0.0"]
|
||||
"requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"sqlalchemy==2.0.11",
|
||||
"sqlalchemy==2.0.12",
|
||||
"fnv-hash-fast==0.3.1",
|
||||
"psutil-home-assistant==0.0.1"
|
||||
]
|
||||
|
||||
@@ -1158,23 +1158,23 @@ def _wipe_old_string_time_columns(
|
||||
elif engine.dialect.name == SupportedDialect.MYSQL:
|
||||
#
|
||||
# Since this is only to save space we limit the number of rows we update
|
||||
# to 10,000,000 per table since we do not want to block the database for too long
|
||||
# to 100,000 per table since we do not want to block the database for too long
|
||||
# or run out of innodb_buffer_pool_size on MySQL. The old data will eventually
|
||||
# be cleaned up by the recorder purge if we do not do it now.
|
||||
#
|
||||
session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;"))
|
||||
session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;"))
|
||||
session.commit()
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE states set last_updated=NULL, last_changed=NULL "
|
||||
" LIMIT 10000000;"
|
||||
" LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
|
||||
#
|
||||
# Since this is only to save space we limit the number of rows we update
|
||||
# to 250,000 per table since we do not want to block the database for too long
|
||||
# to 100,000 per table since we do not want to block the database for too long
|
||||
# or run out ram with postgresql. The old data will eventually
|
||||
# be cleaned up by the recorder purge if we do not do it now.
|
||||
#
|
||||
@@ -1182,7 +1182,7 @@ def _wipe_old_string_time_columns(
|
||||
text(
|
||||
"UPDATE events set time_fired=NULL "
|
||||
"where event_id in "
|
||||
"(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);"
|
||||
"(select event_id from events where time_fired_ts is NOT NULL LIMIT 100000);"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -1190,7 +1190,7 @@ def _wipe_old_string_time_columns(
|
||||
text(
|
||||
"UPDATE states set last_updated=NULL, last_changed=NULL "
|
||||
"where state_id in "
|
||||
"(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);"
|
||||
"(select state_id from states where last_updated_ts is NOT NULL LIMIT 100000);"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -1236,7 +1236,7 @@ def _migrate_columns_to_timestamp(
|
||||
"UNIX_TIMESTAMP(time_fired)"
|
||||
") "
|
||||
"where time_fired_ts is NULL "
|
||||
"LIMIT 250000;"
|
||||
"LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
result = None
|
||||
@@ -1251,7 +1251,7 @@ def _migrate_columns_to_timestamp(
|
||||
"last_changed_ts="
|
||||
"UNIX_TIMESTAMP(last_changed) "
|
||||
"where last_updated_ts is NULL "
|
||||
"LIMIT 250000;"
|
||||
"LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
|
||||
@@ -1266,7 +1266,7 @@ def _migrate_columns_to_timestamp(
|
||||
"time_fired_ts= "
|
||||
"(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) "
|
||||
"WHERE event_id IN ( "
|
||||
"SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 "
|
||||
"SELECT event_id FROM events where time_fired_ts is NULL LIMIT 100000 "
|
||||
" );"
|
||||
)
|
||||
)
|
||||
@@ -1279,7 +1279,7 @@ def _migrate_columns_to_timestamp(
|
||||
"(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), "
|
||||
"last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) "
|
||||
"where state_id IN ( "
|
||||
"SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 "
|
||||
"SELECT state_id FROM states where last_updated_ts is NULL LIMIT 100000 "
|
||||
" );"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2314,7 +2314,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool:
|
||||
session.connection()
|
||||
.execute(
|
||||
text(
|
||||
f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 250000;"
|
||||
f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
.rowcount
|
||||
@@ -2330,7 +2330,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool:
|
||||
.execute(
|
||||
text(
|
||||
f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec
|
||||
f"where id in (select id from {table} where start is not NULL LIMIT 250000)"
|
||||
f"where id in (select id from {table} where start is not NULL LIMIT 100000)"
|
||||
)
|
||||
)
|
||||
.rowcount
|
||||
|
||||
@@ -10,7 +10,7 @@ from sense_energy import (
|
||||
)
|
||||
|
||||
DOMAIN = "sense"
|
||||
DEFAULT_TIMEOUT = 10
|
||||
DEFAULT_TIMEOUT = 30
|
||||
ACTIVE_UPDATE_RATE = 60
|
||||
DEFAULT_NAME = "Sense"
|
||||
SENSE_DATA = "sense_data"
|
||||
|
||||
@@ -91,6 +91,11 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema(
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Activate Snips component."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return False
|
||||
|
||||
async def async_set_feedback(site_ids, state):
|
||||
"""Set Feedback sound state."""
|
||||
site_ids = site_ids if site_ids else config[DOMAIN].get(CONF_SITE_IDS)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""The sql component."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.recorder import CONF_DB_URL, get_instance
|
||||
@@ -24,6 +26,9 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS
|
||||
from .util import redact_credentials
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_sql_select(value: str) -> str:
|
||||
@@ -85,6 +90,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up SQL from a config entry."""
|
||||
_LOGGER.debug(
|
||||
"Comparing %s and %s",
|
||||
redact_credentials(entry.options.get(CONF_DB_URL)),
|
||||
redact_credentials(get_instance(hass).db_url),
|
||||
)
|
||||
if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url:
|
||||
remove_configured_db_url_if_not_needed(hass, entry)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.recorder import CONF_DB_URL
|
||||
from homeassistant.components.recorder import CONF_DB_URL, get_instance
|
||||
from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
@@ -159,13 +159,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
class SQLOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
|
||||
"""Handle SQL options."""
|
||||
|
||||
def __init__(self, entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize SQL options flow."""
|
||||
self.entry = entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -177,7 +173,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
db_url = user_input.get(CONF_DB_URL)
|
||||
query = user_input[CONF_QUERY]
|
||||
column = user_input[CONF_COLUMN_NAME]
|
||||
name = self.entry.options.get(CONF_NAME, self.entry.title)
|
||||
name = self.options.get(CONF_NAME, self.config_entry.title)
|
||||
|
||||
try:
|
||||
validate_sql_select(query)
|
||||
@@ -193,21 +189,26 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
except ValueError:
|
||||
errors["query"] = "query_invalid"
|
||||
else:
|
||||
new_user_input = user_input
|
||||
if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation:
|
||||
new_user_input.pop(CONF_DB_URL)
|
||||
recorder_db = get_instance(self.hass).db_url
|
||||
_LOGGER.debug(
|
||||
"db_url: %s, resolved db_url: %s, recorder: %s",
|
||||
db_url,
|
||||
db_url_for_validation,
|
||||
recorder_db,
|
||||
)
|
||||
if db_url and db_url_for_validation == recorder_db:
|
||||
user_input.pop(CONF_DB_URL)
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_NAME: name,
|
||||
**new_user_input,
|
||||
**user_input,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
OPTIONS_SCHEMA, user_input or self.entry.options
|
||||
OPTIONS_SCHEMA, user_input or self.options
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sql",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["sqlalchemy==2.0.11"]
|
||||
"requirements": ["sqlalchemy==2.0.12"]
|
||||
}
|
||||
|
||||
@@ -42,20 +42,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN
|
||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
|
||||
from .models import SQLData
|
||||
from .util import resolve_db_url
|
||||
from .util import redact_credentials, resolve_db_url
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000)
|
||||
|
||||
|
||||
def redact_credentials(data: str) -> str:
|
||||
"""Redact credentials from string data."""
|
||||
return DB_URL_RE.sub("//****:****@", data)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
"""Utils for sql."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DB_URL_RE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def redact_credentials(data: str | None) -> str:
|
||||
"""Redact credentials from string data."""
|
||||
if not data:
|
||||
return "none"
|
||||
return DB_URL_RE.sub("//****:****@", data)
|
||||
|
||||
|
||||
def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
|
||||
"""Return the db_url provided if not empty, otherwise return the recorder db_url."""
|
||||
_LOGGER.debug("db_url: %s", redact_credentials(db_url))
|
||||
if db_url and not db_url.isspace():
|
||||
return db_url
|
||||
return get_instance(hass).db_url
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_SIP_PORT, DOMAIN
|
||||
from .devices import VoIPDevices
|
||||
from .voip import HassVoipDatagramProtocol
|
||||
|
||||
@@ -39,6 +39,7 @@ class DomainData:
|
||||
"""Domain data."""
|
||||
|
||||
transport: asyncio.DatagramTransport
|
||||
protocol: HassVoipDatagramProtocol
|
||||
devices: VoIPDevices
|
||||
|
||||
|
||||
@@ -56,41 +57,57 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry, data={**entry.data, "user": voip_user.id}
|
||||
)
|
||||
|
||||
sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT)
|
||||
devices = VoIPDevices(hass, entry)
|
||||
devices.async_setup()
|
||||
transport = await _create_sip_server(
|
||||
transport, protocol = await _create_sip_server(
|
||||
hass,
|
||||
lambda: HassVoipDatagramProtocol(hass, devices),
|
||||
sip_port,
|
||||
)
|
||||
_LOGGER.debug("Listening for VoIP calls on port %s", SIP_PORT)
|
||||
_LOGGER.debug("Listening for VoIP calls on port %s", sip_port)
|
||||
|
||||
hass.data[DOMAIN] = DomainData(transport, devices)
|
||||
hass.data[DOMAIN] = DomainData(transport, protocol, devices)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def _create_sip_server(
|
||||
hass: HomeAssistant,
|
||||
protocol_factory: Callable[
|
||||
[],
|
||||
asyncio.DatagramProtocol,
|
||||
],
|
||||
) -> asyncio.DatagramTransport:
|
||||
transport, _protocol = await hass.loop.create_datagram_endpoint(
|
||||
sip_port: int,
|
||||
) -> tuple[asyncio.DatagramTransport, HassVoipDatagramProtocol]:
|
||||
transport, protocol = await hass.loop.create_datagram_endpoint(
|
||||
protocol_factory,
|
||||
local_addr=(_IP_WILDCARD, SIP_PORT),
|
||||
local_addr=(_IP_WILDCARD, sip_port),
|
||||
)
|
||||
|
||||
return transport
|
||||
if not isinstance(protocol, HassVoipDatagramProtocol):
|
||||
raise TypeError(f"Expected HassVoipDatagramProtocol, got {protocol}")
|
||||
|
||||
return transport, protocol
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload VoIP."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
_LOGGER.debug("Shut down VoIP server")
|
||||
hass.data.pop(DOMAIN).transport.close()
|
||||
_LOGGER.debug("Shutting down VoIP server")
|
||||
data = hass.data.pop(DOMAIN)
|
||||
data.transport.close()
|
||||
await data.protocol.wait_closed()
|
||||
_LOGGER.debug("VoIP server shut down successfully")
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from voip_utils import SIP_PORT
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import DOMAIN
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import CONF_SIP_PORT, DOMAIN
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
@@ -22,9 +27,49 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title="Voice over IP",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return VoipOptionsFlowHandler(config_entry)
|
||||
|
||||
|
||||
class VoipOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle VoIP options."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_SIP_PORT,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_SIP_PORT,
|
||||
SIP_PORT,
|
||||
),
|
||||
): cv.port
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,3 +11,5 @@ RTP_AUDIO_SETTINGS = {
|
||||
"channels": CHANNELS,
|
||||
"sleep_ratio": 0.99,
|
||||
}
|
||||
|
||||
CONF_SIP_PORT = "sip_port"
|
||||
|
||||
@@ -139,6 +139,7 @@ class VoIPDevices:
|
||||
manufacturer=manuf,
|
||||
model=model,
|
||||
sw_version=fw_version,
|
||||
configuration_url=f"http://{call_info.caller_ip}",
|
||||
)
|
||||
voip_device = self.devices[voip_id] = VoIPDevice(
|
||||
voip_id=voip_id,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/voip",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["voip-utils==0.0.5"]
|
||||
"requirements": ["voip-utils==0.0.7"]
|
||||
}
|
||||
|
||||
@@ -28,5 +28,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"sip_port": "SIP port"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,11 @@ def make_protocol(
|
||||
or (pipeline.tts_engine is None)
|
||||
):
|
||||
# Play pre-recorded message instead of failing
|
||||
return PreRecordMessageProtocol(hass, "problem.pcm")
|
||||
return PreRecordMessageProtocol(
|
||||
hass,
|
||||
"problem.pcm",
|
||||
opus_payload_type=call_info.opus_payload_type,
|
||||
)
|
||||
|
||||
# Pipeline is properly configured
|
||||
return PipelineRtpDatagramProtocol(
|
||||
@@ -60,6 +64,7 @@ def make_protocol(
|
||||
hass.config.language,
|
||||
voip_device,
|
||||
Context(user_id=devices.config_entry.data["user"]),
|
||||
opus_payload_type=call_info.opus_payload_type,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,17 +84,28 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol):
|
||||
hass, devices, call_info
|
||||
),
|
||||
invalid_protocol_factory=lambda call_info: PreRecordMessageProtocol(
|
||||
hass, "not_configured.pcm"
|
||||
hass,
|
||||
"not_configured.pcm",
|
||||
opus_payload_type=call_info.opus_payload_type,
|
||||
),
|
||||
)
|
||||
self.hass = hass
|
||||
self.devices = devices
|
||||
self._closed_event = asyncio.Event()
|
||||
|
||||
def is_valid_call(self, call_info: CallInfo) -> bool:
|
||||
"""Filter calls."""
|
||||
device = self.devices.async_get_or_create(call_info)
|
||||
return device.async_allow_call(self.hass)
|
||||
|
||||
def connection_lost(self, exc):
|
||||
"""Signal wait_closed when transport is completely closed."""
|
||||
self.hass.loop.call_soon_threadsafe(self._closed_event.set)
|
||||
|
||||
async def wait_closed(self) -> None:
|
||||
"""Wait for connection_lost to be called."""
|
||||
await self._closed_event.wait()
|
||||
|
||||
|
||||
class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
"""Run a voice assistant pipeline in a loop for a VoIP call."""
|
||||
@@ -100,6 +116,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
language: str,
|
||||
voip_device: VoIPDevice,
|
||||
context: Context,
|
||||
opus_payload_type: int,
|
||||
pipeline_timeout: float = 30.0,
|
||||
audio_timeout: float = 2.0,
|
||||
buffered_chunks_before_speech: int = 100,
|
||||
@@ -110,7 +127,12 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
tts_extra_timeout: float = 1.0,
|
||||
) -> None:
|
||||
"""Set up pipeline RTP server."""
|
||||
super().__init__(rate=RATE, width=WIDTH, channels=CHANNELS)
|
||||
super().__init__(
|
||||
rate=RATE,
|
||||
width=WIDTH,
|
||||
channels=CHANNELS,
|
||||
opus_payload_type=opus_payload_type,
|
||||
)
|
||||
|
||||
self.hass = hass
|
||||
self.language = language
|
||||
@@ -341,9 +363,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
|
||||
async with async_timeout.timeout(tts_seconds + self.tts_extra_timeout):
|
||||
# Assume TTS audio is 16Khz 16-bit mono
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS)
|
||||
)
|
||||
await self._async_send_audio(audio_bytes)
|
||||
except asyncio.TimeoutError as err:
|
||||
_LOGGER.warning("TTS timeout")
|
||||
raise err
|
||||
@@ -351,6 +371,12 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
# Signal pipeline to restart
|
||||
self._tts_done.set()
|
||||
|
||||
async def _async_send_audio(self, audio_bytes: bytes, **kwargs):
|
||||
"""Send audio in executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS, **kwargs)
|
||||
)
|
||||
|
||||
async def _play_listening_tone(self) -> None:
|
||||
"""Play a tone to indicate that Home Assistant is listening."""
|
||||
if self._tone_bytes is None:
|
||||
@@ -360,13 +386,9 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
"tone.pcm",
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.send_audio,
|
||||
self._tone_bytes,
|
||||
silence_before=self.tone_delay,
|
||||
**RTP_AUDIO_SETTINGS,
|
||||
)
|
||||
await self._async_send_audio(
|
||||
self._tone_bytes,
|
||||
silence_before=self.tone_delay,
|
||||
)
|
||||
|
||||
async def _play_processing_tone(self) -> None:
|
||||
@@ -378,13 +400,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
"processing.pcm",
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.send_audio,
|
||||
self._processing_bytes,
|
||||
**RTP_AUDIO_SETTINGS,
|
||||
)
|
||||
)
|
||||
await self._async_send_audio(self._processing_bytes)
|
||||
|
||||
async def _play_error_tone(self) -> None:
|
||||
"""Play a tone to indicate a pipeline error occurred."""
|
||||
@@ -395,13 +411,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
"error.pcm",
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.send_audio,
|
||||
self._error_bytes,
|
||||
**RTP_AUDIO_SETTINGS,
|
||||
)
|
||||
)
|
||||
await self._async_send_audio(self._error_bytes)
|
||||
|
||||
def _load_pcm(self, file_name: str) -> bytes:
|
||||
"""Load raw audio (16Khz, 16-bit mono)."""
|
||||
@@ -415,11 +425,17 @@ class PreRecordMessageProtocol(RtpDatagramProtocol):
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
file_name: str,
|
||||
opus_payload_type: int,
|
||||
message_delay: float = 1.0,
|
||||
loop_delay: float = 2.0,
|
||||
) -> None:
|
||||
"""Set up RTP server."""
|
||||
super().__init__(rate=RATE, width=WIDTH, channels=CHANNELS)
|
||||
super().__init__(
|
||||
rate=RATE,
|
||||
width=WIDTH,
|
||||
channels=CHANNELS,
|
||||
opus_payload_type=opus_payload_type,
|
||||
)
|
||||
self.hass = hass
|
||||
self.file_name = file_name
|
||||
self.message_delay = message_delay
|
||||
|
||||
@@ -117,6 +117,7 @@ class WindowCoveringClient(ClientClusterHandler):
|
||||
"""Window client cluster handler."""
|
||||
|
||||
|
||||
@registries.BINDABLE_CLUSTERS.register(closures.WindowCovering.cluster_id)
|
||||
@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(closures.WindowCovering.cluster_id)
|
||||
class WindowCovering(ClusterHandler):
|
||||
"""Window cluster handler."""
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"bellows==0.35.2",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.98",
|
||||
"zha-quirks==0.0.99",
|
||||
"zigpy-deconz==0.21.0",
|
||||
"zigpy==0.55.0",
|
||||
"zigpy-xbee==0.18.0",
|
||||
|
||||
@@ -248,13 +248,16 @@ class Battery(Sensor):
|
||||
return state_attrs
|
||||
|
||||
|
||||
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT)
|
||||
@MULTI_MATCH(
|
||||
cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
|
||||
stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
|
||||
models={"VZM31-SN", "SP 234", "outletv4"},
|
||||
)
|
||||
class ElectricalMeasurement(Sensor):
|
||||
"""Active power measurement."""
|
||||
|
||||
SENSOR_ATTR = "active_power"
|
||||
_attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER
|
||||
_attr_should_poll = True # BaseZhaEntity defaults to False
|
||||
_attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT
|
||||
_attr_name: str = "Active power"
|
||||
_attr_native_unit_of_measurement: str = UnitOfPower.WATT
|
||||
@@ -284,6 +287,16 @@ class ElectricalMeasurement(Sensor):
|
||||
return round(value, self._decimals)
|
||||
return round(value)
|
||||
|
||||
|
||||
@MULTI_MATCH(
|
||||
cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
|
||||
stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT,
|
||||
)
|
||||
class PolledElectricalMeasurement(ElectricalMeasurement):
|
||||
"""Polled active power measurement."""
|
||||
|
||||
_attr_should_poll = True # BaseZhaEntity defaults to False
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Retrieve latest state."""
|
||||
if not self.available:
|
||||
@@ -299,7 +312,6 @@ class ElectricalMeasurementApparentPower(
|
||||
|
||||
SENSOR_ATTR = "apparent_power"
|
||||
_attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER
|
||||
_attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor
|
||||
_attr_name: str = "Apparent power"
|
||||
_attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE
|
||||
_div_mul_prefix = "ac_power"
|
||||
@@ -311,7 +323,6 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr
|
||||
|
||||
SENSOR_ATTR = "rms_current"
|
||||
_attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT
|
||||
_attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor
|
||||
_attr_name: str = "RMS current"
|
||||
_attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE
|
||||
_div_mul_prefix = "ac_current"
|
||||
@@ -323,7 +334,6 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt
|
||||
|
||||
SENSOR_ATTR = "rms_voltage"
|
||||
_attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE
|
||||
_attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor
|
||||
_attr_name: str = "RMS voltage"
|
||||
_attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT
|
||||
_div_mul_prefix = "ac_voltage"
|
||||
@@ -335,7 +345,6 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque
|
||||
|
||||
SENSOR_ATTR = "ac_frequency"
|
||||
_attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY
|
||||
_attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor
|
||||
_attr_name: str = "AC frequency"
|
||||
_attr_native_unit_of_measurement = UnitOfFrequency.HERTZ
|
||||
_div_mul_prefix = "ac_frequency"
|
||||
@@ -347,7 +356,6 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f
|
||||
|
||||
SENSOR_ATTR = "power_factor"
|
||||
_attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR
|
||||
_attr_should_poll = False # Poll indirectly by ElectricalMeasurementSensor
|
||||
_attr_name: str = "Power factor"
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def async_setup_entry(
|
||||
entities: list[ZWaveBaseEntity] = []
|
||||
if info.platform_hint == "motorized_barrier":
|
||||
entities.append(ZwaveMotorizedBarrier(config_entry, driver, info))
|
||||
elif info.platform_hint == "window_shutter_tilt":
|
||||
elif info.platform_hint and info.platform_hint.endswith("tilt"):
|
||||
entities.append(ZWaveTiltCover(config_entry, driver, info))
|
||||
else:
|
||||
entities.append(ZWaveCover(config_entry, driver, info))
|
||||
@@ -99,6 +99,12 @@ def zwave_tilt_to_percent(value: int) -> int:
|
||||
class ZWaveCover(ZWaveBaseEntity, CoverEntity):
|
||||
"""Representation of a Z-Wave Cover device."""
|
||||
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ConfigEntry,
|
||||
@@ -108,11 +114,20 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity):
|
||||
"""Initialize a ZWaveCover entity."""
|
||||
super().__init__(config_entry, driver, info)
|
||||
|
||||
self._stop_cover_value = (
|
||||
self.get_zwave_value(COVER_OPEN_PROPERTY)
|
||||
or self.get_zwave_value(COVER_UP_PROPERTY)
|
||||
or self.get_zwave_value(COVER_ON_PROPERTY)
|
||||
)
|
||||
|
||||
if self._stop_cover_value:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
# Entity class attributes
|
||||
self._attr_device_class = CoverDeviceClass.WINDOW
|
||||
if self.info.platform_hint in ("window_shutter", "window_shutter_tilt"):
|
||||
if self.info.platform_hint and self.info.platform_hint.startswith("shutter"):
|
||||
self._attr_device_class = CoverDeviceClass.SHUTTER
|
||||
if self.info.platform_hint == "window_blind":
|
||||
if self.info.platform_hint and self.info.platform_hint.startswith("blind"):
|
||||
self._attr_device_class = CoverDeviceClass.BLIND
|
||||
|
||||
@property
|
||||
@@ -153,28 +168,13 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity):
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop cover."""
|
||||
cover_property = (
|
||||
self.get_zwave_value(COVER_OPEN_PROPERTY)
|
||||
or self.get_zwave_value(COVER_UP_PROPERTY)
|
||||
or self.get_zwave_value(COVER_ON_PROPERTY)
|
||||
)
|
||||
if cover_property:
|
||||
# Stop the cover, will stop regardless of the actual direction of travel.
|
||||
await self.info.node.async_set_value(cover_property, False)
|
||||
assert self._stop_cover_value
|
||||
# Stop the cover, will stop regardless of the actual direction of travel.
|
||||
await self.info.node.async_set_value(self._stop_cover_value, False)
|
||||
|
||||
|
||||
class ZWaveTiltCover(ZWaveCover):
|
||||
"""Representation of a Z-Wave Cover device with tilt."""
|
||||
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.STOP
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
| CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
| CoverEntityFeature.SET_TILT_POSITION
|
||||
)
|
||||
"""Representation of a Z-Wave cover device with tilt."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -184,8 +184,15 @@ class ZWaveTiltCover(ZWaveCover):
|
||||
) -> None:
|
||||
"""Initialize a ZWaveCover entity."""
|
||||
super().__init__(config_entry, driver, info)
|
||||
self.data_template = cast(
|
||||
|
||||
self._current_tilt_value = cast(
|
||||
CoverTiltDataTemplate, self.info.platform_data_template
|
||||
).current_tilt_value(self.info.platform_data)
|
||||
|
||||
self._attr_supported_features |= (
|
||||
CoverEntityFeature.OPEN_TILT
|
||||
| CoverEntityFeature.CLOSE_TILT
|
||||
| CoverEntityFeature.SET_TILT_POSITION
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -194,19 +201,18 @@ class ZWaveTiltCover(ZWaveCover):
|
||||
|
||||
None is unknown, 0 is closed, 100 is fully open.
|
||||
"""
|
||||
value = self.data_template.current_tilt_value(self.info.platform_data)
|
||||
value = self._current_tilt_value
|
||||
if value is None or value.value is None:
|
||||
return None
|
||||
return zwave_tilt_to_percent(int(value.value))
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
tilt_value = self.data_template.current_tilt_value(self.info.platform_data)
|
||||
if tilt_value:
|
||||
await self.info.node.async_set_value(
|
||||
tilt_value,
|
||||
percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]),
|
||||
)
|
||||
assert self._current_tilt_value
|
||||
await self.info.node.async_set_value(
|
||||
self._current_tilt_value,
|
||||
percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]),
|
||||
)
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Open the cover tilt."""
|
||||
|
||||
@@ -347,7 +347,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# Fibaro Shutter Fibaro FGR222
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_shutter_tilt",
|
||||
hint="shutter_tilt",
|
||||
manufacturer_id={0x010F},
|
||||
product_id={0x1000, 0x1001},
|
||||
product_type={0x0301, 0x0302},
|
||||
@@ -371,7 +371,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# Qubino flush shutter
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_shutter",
|
||||
hint="shutter",
|
||||
manufacturer_id={0x0159},
|
||||
product_id={0x0052, 0x0053},
|
||||
product_type={0x0003},
|
||||
@@ -380,7 +380,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# Graber/Bali/Spring Fashion Covers
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_blind",
|
||||
hint="blind",
|
||||
manufacturer_id={0x026E},
|
||||
product_id={0x5A31},
|
||||
product_type={0x4353},
|
||||
@@ -389,7 +389,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# iBlinds v2 window blind motor
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_blind",
|
||||
hint="blind",
|
||||
manufacturer_id={0x0287},
|
||||
product_id={0x000D},
|
||||
product_type={0x0003},
|
||||
@@ -398,7 +398,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# Merten 507801 Connect Roller Shutter
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_shutter",
|
||||
hint="shutter",
|
||||
manufacturer_id={0x007A},
|
||||
product_id={0x0001},
|
||||
product_type={0x8003},
|
||||
@@ -414,7 +414,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# Disable endpoint 2, as it has no practical function. CC: Switch_Multilevel
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_shutter",
|
||||
hint="shutter",
|
||||
manufacturer_id={0x007A},
|
||||
product_id={0x0001},
|
||||
product_type={0x8003},
|
||||
@@ -807,7 +807,7 @@ DISCOVERY_SCHEMAS = [
|
||||
# window coverings
|
||||
ZWaveDiscoverySchema(
|
||||
platform=Platform.COVER,
|
||||
hint="window_cover",
|
||||
hint="cover",
|
||||
device_class_generic={"Multilevel Switch"},
|
||||
device_class_specific={
|
||||
"Motor Control Class A",
|
||||
|
||||
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 5
|
||||
PATCH_VERSION: Final = "0b4"
|
||||
PATCH_VERSION: Final = "1"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
|
||||
|
||||
@@ -307,26 +307,6 @@ class RegistryEntry:
|
||||
|
||||
hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs)
|
||||
|
||||
def async_friendly_name(self, hass: HomeAssistant) -> str | None:
|
||||
"""Return the friendly name.
|
||||
|
||||
If self.name is not None, this returns self.name
|
||||
If has_entity_name is False, self.original_name
|
||||
If has_entity_name is True, this returns device.name + self.original_name
|
||||
"""
|
||||
if not self.has_entity_name or self.name is not None:
|
||||
return self.name or self.original_name
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
if not (device_id := self.device_id) or not (
|
||||
device_entry := device_registry.async_get(device_id)
|
||||
):
|
||||
return self.original_name
|
||||
|
||||
if not (original_name := self.original_name):
|
||||
return device_entry.name_by_user or device_entry.name
|
||||
return f"{device_entry.name_by_user or device_entry.name} {original_name}"
|
||||
|
||||
|
||||
class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
|
||||
"""Store entity registry data."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, TypeVar
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_ID,
|
||||
@@ -65,6 +66,7 @@ async def async_handle(
|
||||
text_input: str | None = None,
|
||||
context: Context | None = None,
|
||||
language: str | None = None,
|
||||
assistant: str | None = None,
|
||||
) -> IntentResponse:
|
||||
"""Handle an intent."""
|
||||
handler: IntentHandler = hass.data.get(DATA_KEY, {}).get(intent_type)
|
||||
@@ -79,7 +81,14 @@ async def async_handle(
|
||||
language = hass.config.language
|
||||
|
||||
intent = Intent(
|
||||
hass, platform, intent_type, slots or {}, text_input, context, language
|
||||
hass,
|
||||
platform=platform,
|
||||
intent_type=intent_type,
|
||||
slots=slots or {},
|
||||
text_input=text_input,
|
||||
context=context,
|
||||
language=language,
|
||||
assistant=assistant,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -208,6 +217,7 @@ def async_match_states(
|
||||
entities: entity_registry.EntityRegistry | None = None,
|
||||
areas: area_registry.AreaRegistry | None = None,
|
||||
devices: device_registry.DeviceRegistry | None = None,
|
||||
assistant: str | None = None,
|
||||
) -> Iterable[State]:
|
||||
"""Find states that match the constraints."""
|
||||
if states is None:
|
||||
@@ -258,6 +268,14 @@ def async_match_states(
|
||||
|
||||
states_and_entities = list(_filter_by_area(states_and_entities, area, devices))
|
||||
|
||||
if assistant is not None:
|
||||
# Filter by exposure
|
||||
states_and_entities = [
|
||||
(state, entity)
|
||||
for state, entity in states_and_entities
|
||||
if async_should_expose(hass, assistant, state.entity_id)
|
||||
]
|
||||
|
||||
if name is not None:
|
||||
if devices is None:
|
||||
devices = device_registry.async_get(hass)
|
||||
@@ -387,6 +405,7 @@ class ServiceIntentHandler(IntentHandler):
|
||||
area=area,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -496,6 +515,7 @@ class Intent:
|
||||
"context",
|
||||
"language",
|
||||
"category",
|
||||
"assistant",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -508,6 +528,7 @@ class Intent:
|
||||
context: Context,
|
||||
language: str,
|
||||
category: IntentCategory | None = None,
|
||||
assistant: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize an intent."""
|
||||
self.hass = hass
|
||||
@@ -518,6 +539,7 @@ class Intent:
|
||||
self.context = context
|
||||
self.language = language
|
||||
self.category = category
|
||||
self.assistant = assistant
|
||||
|
||||
@callback
|
||||
def create_response(self) -> IntentResponse:
|
||||
|
||||
@@ -12,9 +12,9 @@ attrs==22.2.0
|
||||
awesomeversion==22.9.0
|
||||
bcrypt==4.0.1
|
||||
bleak-retry-connector==3.0.2
|
||||
bleak==0.20.1
|
||||
bleak==0.20.2
|
||||
bluetooth-adapters==0.15.3
|
||||
bluetooth-auto-recovery==1.0.3
|
||||
bluetooth-auto-recovery==1.1.2
|
||||
bluetooth-data-tools==0.4.0
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.0
|
||||
@@ -25,7 +25,7 @@ ha-av==10.0.0
|
||||
hass-nabucasa==0.66.2
|
||||
hassil==1.0.6
|
||||
home-assistant-bluetooth==1.10.0
|
||||
home-assistant-frontend==20230428.0
|
||||
home-assistant-frontend==20230503.2
|
||||
home-assistant-intents==2023.4.26
|
||||
httpx==0.24.0
|
||||
ifaddr==0.1.7
|
||||
@@ -45,9 +45,9 @@ pyudev==0.23.2
|
||||
pyyaml==6.0
|
||||
requests==2.28.2
|
||||
scapy==2.5.0
|
||||
sqlalchemy==2.0.11
|
||||
sqlalchemy==2.0.12
|
||||
typing-extensions>=4.5.0,<5.0
|
||||
ulid-transform==0.7.0
|
||||
ulid-transform==0.7.2
|
||||
voluptuous-serialize==2.6.0
|
||||
voluptuous==0.13.1
|
||||
webrtcvad==2.0.10
|
||||
@@ -176,3 +176,6 @@ websockets>=11.0.1
|
||||
# https://github.com/pysnmp/pysnmp/issues/51
|
||||
pyasn1==0.4.8
|
||||
pysnmplib==5.0.21
|
||||
# pysnmp is no longer maintained and does not work with newer
|
||||
# python
|
||||
pysnmp==1000000000.0.0
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.5.0b4"
|
||||
version = "2023.5.1"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
@@ -50,7 +50,7 @@ dependencies = [
|
||||
"pyyaml==6.0",
|
||||
"requests==2.28.2",
|
||||
"typing-extensions>=4.5.0,<5.0",
|
||||
"ulid-transform==0.7.0",
|
||||
"ulid-transform==0.7.2",
|
||||
"voluptuous==0.13.1",
|
||||
"voluptuous-serialize==2.6.0",
|
||||
"yarl==1.9.2",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ python-slugify==4.0.1
|
||||
pyyaml==6.0
|
||||
requests==2.28.2
|
||||
typing-extensions>=4.5.0,<5.0
|
||||
ulid-transform==0.7.0
|
||||
ulid-transform==0.7.2
|
||||
voluptuous==0.13.1
|
||||
voluptuous-serialize==2.6.0
|
||||
yarl==1.9.2
|
||||
|
||||
+13
-13
@@ -223,7 +223,7 @@ aionanoleaf==0.2.1
|
||||
aionotify==0.2.0
|
||||
|
||||
# homeassistant.components.notion
|
||||
aionotion==2023.04.2
|
||||
aionotion==2023.05.0
|
||||
|
||||
# homeassistant.components.oncue
|
||||
aiooncue==0.3.4
|
||||
@@ -386,7 +386,7 @@ asyncpysupla==0.0.5
|
||||
asyncsleepiq==1.3.4
|
||||
|
||||
# homeassistant.components.aten_pe
|
||||
atenpdu==0.3.2
|
||||
# atenpdu==0.3.2
|
||||
|
||||
# homeassistant.components.aurora
|
||||
auroranoaa==0.0.3
|
||||
@@ -419,7 +419,7 @@ base36==0.1.1
|
||||
batinfo==0.4.2
|
||||
|
||||
# homeassistant.components.eddystone_temperature
|
||||
# beacontools[scan]==1.2.3
|
||||
# beacontools[scan]==2.1.0
|
||||
|
||||
# homeassistant.components.scrape
|
||||
beautifulsoup4==4.11.1
|
||||
@@ -440,7 +440,7 @@ bizkaibus==0.1.1
|
||||
bleak-retry-connector==3.0.2
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak==0.20.1
|
||||
bleak==0.20.2
|
||||
|
||||
# homeassistant.components.blebox
|
||||
blebox_uniapi==2.1.4
|
||||
@@ -465,7 +465,7 @@ bluemaestro-ble==0.2.3
|
||||
bluetooth-adapters==0.15.3
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bluetooth-auto-recovery==1.0.3
|
||||
bluetooth-auto-recovery==1.1.2
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
# homeassistant.components.esphome
|
||||
@@ -911,7 +911,7 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230428.0
|
||||
home-assistant-frontend==20230503.2
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.4.26
|
||||
@@ -1264,7 +1264,7 @@ ondilo==0.2.0
|
||||
onkyo-eiscp==1.2.7
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==1.3.0
|
||||
onvif-zeep-async==1.3.1
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
@@ -1533,7 +1533,7 @@ pyblackbird==0.6
|
||||
pybotvac==0.0.23
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.2
|
||||
pybravia==0.3.3
|
||||
|
||||
# homeassistant.components.nissan_leaf
|
||||
pycarwings2==2.14
|
||||
@@ -1812,7 +1812,7 @@ pynetgear==0.10.9
|
||||
pynetio==0.1.9.1
|
||||
|
||||
# homeassistant.components.nina
|
||||
pynina==0.2.0
|
||||
pynina==0.3.0
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.6.0
|
||||
@@ -2406,7 +2406,7 @@ spotipy==2.23.0
|
||||
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.sql
|
||||
sqlalchemy==2.0.11
|
||||
sqlalchemy==2.0.12
|
||||
|
||||
# homeassistant.components.srp_energy
|
||||
srpenergy==1.3.6
|
||||
@@ -2594,7 +2594,7 @@ venstarcolortouch==0.19
|
||||
vilfo-api-client==0.3.2
|
||||
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.0.5
|
||||
voip-utils==0.0.7
|
||||
|
||||
# homeassistant.components.volkszaehler
|
||||
volkszaehler==0.4.0
|
||||
@@ -2688,7 +2688,7 @@ yalesmartalarmclient==0.3.9
|
||||
yalexs-ble==2.1.16
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.3.2
|
||||
yalexs==1.3.3
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.10
|
||||
@@ -2718,7 +2718,7 @@ zeroconf==0.58.2
|
||||
zeversolar==0.3.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.98
|
||||
zha-quirks==0.0.99
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
|
||||
+11
-11
@@ -204,7 +204,7 @@ aiomusiccast==0.14.8
|
||||
aionanoleaf==0.2.1
|
||||
|
||||
# homeassistant.components.notion
|
||||
aionotion==2023.04.2
|
||||
aionotion==2023.05.0
|
||||
|
||||
# homeassistant.components.oncue
|
||||
aiooncue==0.3.4
|
||||
@@ -370,7 +370,7 @@ bimmer_connected==0.13.2
|
||||
bleak-retry-connector==3.0.2
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak==0.20.1
|
||||
bleak==0.20.2
|
||||
|
||||
# homeassistant.components.blebox
|
||||
blebox_uniapi==2.1.4
|
||||
@@ -385,7 +385,7 @@ bluemaestro-ble==0.2.3
|
||||
bluetooth-adapters==0.15.3
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bluetooth-auto-recovery==1.0.3
|
||||
bluetooth-auto-recovery==1.1.2
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
# homeassistant.components.esphome
|
||||
@@ -700,7 +700,7 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230428.0
|
||||
home-assistant-frontend==20230503.2
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.4.26
|
||||
@@ -945,7 +945,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.2.0
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==1.3.0
|
||||
onvif-zeep-async==1.3.1
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
@@ -1130,7 +1130,7 @@ pyblackbird==0.6
|
||||
pybotvac==0.0.23
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.2
|
||||
pybravia==0.3.3
|
||||
|
||||
# homeassistant.components.cloudflare
|
||||
pycfdns==2.0.1
|
||||
@@ -1316,7 +1316,7 @@ pymysensors==0.24.0
|
||||
pynetgear==0.10.9
|
||||
|
||||
# homeassistant.components.nina
|
||||
pynina==0.2.0
|
||||
pynina==0.3.0
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.6.0
|
||||
@@ -1730,7 +1730,7 @@ spotipy==2.23.0
|
||||
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.sql
|
||||
sqlalchemy==2.0.11
|
||||
sqlalchemy==2.0.12
|
||||
|
||||
# homeassistant.components.srp_energy
|
||||
srpenergy==1.3.6
|
||||
@@ -1870,7 +1870,7 @@ venstarcolortouch==0.19
|
||||
vilfo-api-client==0.3.2
|
||||
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.0.5
|
||||
voip-utils==0.0.7
|
||||
|
||||
# homeassistant.components.volvooncall
|
||||
volvooncall==0.10.2
|
||||
@@ -1943,7 +1943,7 @@ yalesmartalarmclient==0.3.9
|
||||
yalexs-ble==2.1.16
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.3.2
|
||||
yalexs==1.3.3
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.10
|
||||
@@ -1964,7 +1964,7 @@ zeroconf==0.58.2
|
||||
zeversolar==0.3.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.98
|
||||
zha-quirks==0.0.99
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.21.0
|
||||
|
||||
@@ -21,6 +21,7 @@ else:
|
||||
|
||||
COMMENT_REQUIREMENTS = (
|
||||
"Adafruit_BBIO",
|
||||
"atenpdu", # depends on pysnmp which is not maintained at this time
|
||||
"avea", # depends on bluepy
|
||||
"avion",
|
||||
"azure-servicebus", # depends on uamqp, which requires OpenSSL 1.1
|
||||
@@ -180,6 +181,9 @@ websockets>=11.0.1
|
||||
# https://github.com/pysnmp/pysnmp/issues/51
|
||||
pyasn1==0.4.8
|
||||
pysnmplib==5.0.21
|
||||
# pysnmp is no longer maintained and does not work with newer
|
||||
# python
|
||||
pysnmp==1000000000.0.0
|
||||
"""
|
||||
|
||||
IGNORE_PRE_COMMIT_HOOK_ID = (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""The tests for the Air Quality component."""
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.air_quality import ATTR_N2O, ATTR_OZONE, ATTR_PM_10
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
@@ -9,6 +11,12 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
async def test_state(hass: HomeAssistant) -> None:
|
||||
"""Test Air Quality state."""
|
||||
config = {"air_quality": {"platform": "demo"}}
|
||||
|
||||
@@ -39,6 +39,7 @@ def events(hass: HomeAssistant) -> list[Event]:
|
||||
@pytest.fixture
|
||||
async def mock_camera(hass: HomeAssistant) -> None:
|
||||
"""Initialize a demo camera platform."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(
|
||||
hass, "camera", {camera.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
@@ -1539,6 +1539,7 @@ async def test_automation_restore_last_triggered_with_initial_state(
|
||||
|
||||
async def test_extraction_functions(hass: HomeAssistant) -> None:
|
||||
"""Test extraction functions."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}})
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Test fixtures for calendar sensor platforms."""
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
@@ -1,6 +1,8 @@
|
||||
"""The tests for calendar recorder."""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.recorder.history import get_significant_states
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
@@ -12,9 +14,15 @@ from tests.common import async_fire_time_changed
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant():
|
||||
"""Override the fixture in calendar.conftest."""
|
||||
|
||||
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test sensor attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -5,11 +5,18 @@ import pytest
|
||||
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.components.camera.const import StreamType
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .common import WEBRTC_ANSWER
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_camera")
|
||||
async def mock_camera_fixture(hass):
|
||||
"""Initialize a demo camera platform."""
|
||||
|
||||
@@ -370,6 +370,7 @@ async def test_websocket_update_orientation_prefs(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_camera
|
||||
) -> None:
|
||||
"""Test updating camera preferences."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import camera
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.recorder.history import get_significant_states
|
||||
@@ -20,9 +22,15 @@ from tests.common import async_fire_time_changed
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant():
|
||||
"""Override the fixture in calendar.conftest."""
|
||||
|
||||
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test camera registered attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(
|
||||
hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
@@ -1888,6 +1888,7 @@ async def test_failed_cast_other_url(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test warning when casting from internal_url fails."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
with assert_setup_component(1, tts.DOMAIN):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
@@ -1911,6 +1912,7 @@ async def test_failed_cast_internal_url(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test warning when casting from internal_url fails."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_process_ha_core_config(
|
||||
hass,
|
||||
{"internal_url": "http://example.local:8123"},
|
||||
@@ -1939,6 +1941,7 @@ async def test_failed_cast_external_url(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test warning when casting from external_url fails."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_process_ha_core_config(
|
||||
hass,
|
||||
{"external_url": "http://example.com:8123"},
|
||||
@@ -1969,6 +1972,7 @@ async def test_failed_cast_tts_base_url(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Test warning when casting from tts.base_url fails."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
with assert_setup_component(1, tts.DOMAIN):
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
|
||||
@@ -29,6 +29,7 @@ from tests.components.recorder.common import async_wait_recording_done
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test climate registered attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(
|
||||
hass, climate.DOMAIN, {climate.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
@@ -15,10 +15,15 @@ from homeassistant.components.cloud.prefs import CloudPreferences
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
DATA_EXPOSED_ENTITIES,
|
||||
ExposedEntities,
|
||||
async_expose_entity,
|
||||
async_get_entity_settings,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -41,8 +46,7 @@ def expose_new(hass, 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_expose_entity(hass, "cloud.alexa", entity_id, should_expose)
|
||||
|
||||
|
||||
async def test_alexa_config_expose_entity_prefs(
|
||||
@@ -102,10 +106,9 @@ async def test_alexa_config_expose_entity_prefs(
|
||||
)
|
||||
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")
|
||||
# an entity which is not in the entity registry can be exposed
|
||||
expose_entity(hass, "light.kitchen", 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)
|
||||
@@ -368,6 +371,8 @@ async def test_alexa_update_expose_trigger_sync(
|
||||
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with patch_sync_helper() as (to_update, to_remove):
|
||||
expose_entity(hass, light_entry.entity_id, True)
|
||||
@@ -544,8 +549,10 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Alexa entity config."""
|
||||
hass.state = CoreState.starting
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
hass.states.async_set("light.state_only", "on")
|
||||
entity_exposed = entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"test",
|
||||
@@ -593,6 +600,9 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
||||
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.unknown"] = {
|
||||
PREF_SHOULD_EXPOSE: True
|
||||
}
|
||||
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = {
|
||||
PREF_SHOULD_EXPOSE: False
|
||||
}
|
||||
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_exposed.entity_id] = {
|
||||
PREF_SHOULD_EXPOSE: True
|
||||
}
|
||||
@@ -603,21 +613,32 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
||||
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
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}}
|
||||
assert async_get_entity_settings(hass, "light.unknown") == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, "light.state_only") == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_exposed.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_config.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_default.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_blocked.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
|
||||
|
||||
async def test_alexa_config_migrate_expose_entity_prefs_default_none(
|
||||
@@ -627,6 +648,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Alexa entity config."""
|
||||
hass.state = CoreState.starting
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
entity_default = entity_registry.async_get_or_create(
|
||||
@@ -647,9 +669,14 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none(
|
||||
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_default = entity_registry.async_get(entity_default.entity_id)
|
||||
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}
|
||||
assert async_get_entity_settings(hass, entity_default.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
|
||||
|
||||
async def test_alexa_config_migrate_expose_entity_prefs_default(
|
||||
@@ -659,6 +686,7 @@ async def test_alexa_config_migrate_expose_entity_prefs_default(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Alexa entity config."""
|
||||
hass.state = CoreState.starting
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
@@ -724,27 +752,26 @@ async def test_alexa_config_migrate_expose_entity_prefs_default(
|
||||
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
binary_sensor_supported = entity_registry.async_get(
|
||||
binary_sensor_supported.entity_id
|
||||
)
|
||||
assert binary_sensor_supported.options == {"cloud.alexa": {"should_expose": True}}
|
||||
|
||||
binary_sensor_unsupported = entity_registry.async_get(
|
||||
binary_sensor_unsupported.entity_id
|
||||
)
|
||||
assert binary_sensor_unsupported.options == {
|
||||
assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, binary_sensor_unsupported.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, light.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, sensor_supported.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, sensor_unsupported.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, water_heater.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
|
||||
light = entity_registry.async_get(light.entity_id)
|
||||
assert light.options == {"cloud.alexa": {"should_expose": True}}
|
||||
|
||||
sensor_supported = entity_registry.async_get(sensor_supported.entity_id)
|
||||
assert sensor_supported.options == {"cloud.alexa": {"should_expose": True}}
|
||||
|
||||
sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id)
|
||||
assert sensor_unsupported.options == {"cloud.alexa": {"should_expose": False}}
|
||||
|
||||
water_heater = entity_registry.async_get(water_heater.entity_id)
|
||||
assert water_heater.options == {"cloud.alexa": {"should_expose": False}}
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.cloud.const import (
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
DATA_EXPOSED_ENTITIES,
|
||||
ExposedEntities,
|
||||
async_expose_entity,
|
||||
)
|
||||
from homeassistant.const import CONTENT_TYPE_JSON
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
@@ -267,9 +268,7 @@ async def test_google_config_expose_entity(
|
||||
|
||||
assert gconf.should_expose(state)
|
||||
|
||||
exposed_entities.async_expose_entity(
|
||||
"cloud.google_assistant", entity_entry.entity_id, False
|
||||
)
|
||||
async_expose_entity(hass, "cloud.google_assistant", entity_entry.entity_id, False)
|
||||
|
||||
assert not gconf.should_expose(state)
|
||||
|
||||
|
||||
@@ -18,10 +18,15 @@ from homeassistant.components.google_assistant import helpers as ga_helpers
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
DATA_EXPOSED_ENTITIES,
|
||||
ExposedEntities,
|
||||
async_expose_entity,
|
||||
async_get_entity_settings,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EntityCategory,
|
||||
)
|
||||
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
|
||||
@@ -49,10 +54,7 @@ def expose_new(hass, 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_expose_entity(hass, "cloud.google_assistant", entity_id, should_expose)
|
||||
|
||||
|
||||
async def test_google_update_report_state(
|
||||
@@ -146,6 +148,8 @@ async def test_google_update_expose_trigger_sync(
|
||||
Mock(claims={"cognito:username": "abcdefghjkl"}),
|
||||
)
|
||||
await config.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
await config.async_connect_agent_user("mock-user-id")
|
||||
|
||||
with patch.object(config, "async_sync_entities") as mock_sync, patch.object(
|
||||
@@ -395,10 +399,9 @@ async def test_google_config_expose_entity_prefs(
|
||||
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)
|
||||
# an entity which is not in the entity registry can be exposed
|
||||
expose_entity(hass, "light.kitchen", 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)
|
||||
@@ -486,8 +489,10 @@ async def test_google_config_migrate_expose_entity_prefs(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Google entity config."""
|
||||
hass.state = CoreState.starting
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
hass.states.async_set("light.state_only", "on")
|
||||
entity_exposed = entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"test",
|
||||
@@ -540,7 +545,11 @@ async def test_google_config_migrate_expose_entity_prefs(
|
||||
expose_entity(hass, entity_migrated.entity_id, False)
|
||||
|
||||
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.unknown"] = {
|
||||
PREF_SHOULD_EXPOSE: True
|
||||
PREF_SHOULD_EXPOSE: True,
|
||||
PREF_DISABLE_2FA: True,
|
||||
}
|
||||
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = {
|
||||
PREF_SHOULD_EXPOSE: False
|
||||
}
|
||||
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_exposed.entity_id] = {
|
||||
PREF_SHOULD_EXPOSE: True
|
||||
@@ -556,28 +565,33 @@ async def test_google_config_migrate_expose_entity_prefs(
|
||||
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
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 == {
|
||||
assert async_get_entity_settings(hass, "light.unknown") == {
|
||||
"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 == {
|
||||
assert async_get_entity_settings(hass, "light.state_only") == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_exposed.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_no_2fa_exposed.entity_id) == {
|
||||
"cloud.google_assistant": {"disable_2fa": True, "should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_config.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_default.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_blocked.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
}
|
||||
|
||||
@@ -588,6 +602,7 @@ async def test_google_config_migrate_expose_entity_prefs_default_none(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Google entity config."""
|
||||
hass.state = CoreState.starting
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
entity_default = entity_registry.async_get_or_create(
|
||||
@@ -608,9 +623,14 @@ async def test_google_config_migrate_expose_entity_prefs_default_none(
|
||||
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_default = entity_registry.async_get(entity_default.entity_id)
|
||||
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}
|
||||
assert async_get_entity_settings(hass, entity_default.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
|
||||
|
||||
async def test_google_config_migrate_expose_entity_prefs_default(
|
||||
@@ -619,6 +639,7 @@ async def test_google_config_migrate_expose_entity_prefs_default(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Google entity config."""
|
||||
hass.state = CoreState.starting
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
@@ -684,33 +705,26 @@ async def test_google_config_migrate_expose_entity_prefs_default(
|
||||
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||
)
|
||||
await conf.async_initialize()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||
await hass.async_block_till_done()
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
binary_sensor_supported = entity_registry.async_get(
|
||||
binary_sensor_supported.entity_id
|
||||
)
|
||||
assert binary_sensor_supported.options == {
|
||||
assert async_get_entity_settings(hass, binary_sensor_supported.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
|
||||
binary_sensor_unsupported = entity_registry.async_get(
|
||||
binary_sensor_unsupported.entity_id
|
||||
)
|
||||
assert binary_sensor_unsupported.options == {
|
||||
assert async_get_entity_settings(hass, binary_sensor_unsupported.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
}
|
||||
|
||||
light = entity_registry.async_get(light.entity_id)
|
||||
assert light.options == {"cloud.google_assistant": {"should_expose": True}}
|
||||
|
||||
sensor_supported = entity_registry.async_get(sensor_supported.entity_id)
|
||||
assert sensor_supported.options == {
|
||||
assert async_get_entity_settings(hass, light.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
|
||||
sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id)
|
||||
assert sensor_unsupported.options == {
|
||||
assert async_get_entity_settings(hass, sensor_supported.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, sensor_unsupported.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
}
|
||||
assert async_get_entity_settings(hass, water_heater.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
}
|
||||
|
||||
water_heater = entity_registry.async_get(water_heater.entity_id)
|
||||
assert water_heater.options == {"cloud.google_assistant": {"should_expose": False}}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.alexa import errors as alexa_errors
|
||||
from homeassistant.components.alexa.entities import LightCapabilities
|
||||
from homeassistant.components.cloud.const import DOMAIN
|
||||
from homeassistant.components.google_assistant.helpers import GoogleEntity
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -761,7 +762,17 @@ async def test_list_google_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
|
||||
assert len(response["result"]) == 2
|
||||
assert response["result"][0] == {
|
||||
"entity_id": "light.kitchen",
|
||||
"might_2fa": False,
|
||||
"traits": ["action.devices.traits.OnOff"],
|
||||
}
|
||||
assert response["result"][1] == {
|
||||
"entity_id": "cover.garage",
|
||||
"might_2fa": True,
|
||||
"traits": ["action.devices.traits.OpenClose"],
|
||||
}
|
||||
|
||||
# Add the entities to the entity registry
|
||||
entity_registry.async_get_or_create(
|
||||
@@ -809,7 +820,7 @@ async def test_get_google_entity(
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_found",
|
||||
"message": "light.kitchen unknown or not in the entity registry",
|
||||
"message": "light.kitchen unknown",
|
||||
}
|
||||
|
||||
# Test getting a blocked entity
|
||||
@@ -830,9 +841,6 @@ async def test_get_google_entity(
|
||||
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"})
|
||||
|
||||
@@ -842,6 +850,7 @@ async def test_get_google_entity(
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"disable_2fa": None,
|
||||
"entity_id": "light.kitchen",
|
||||
"might_2fa": False,
|
||||
"traits": ["action.devices.traits.OnOff"],
|
||||
@@ -853,6 +862,30 @@ async def test_get_google_entity(
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"disable_2fa": None,
|
||||
"entity_id": "cover.garage",
|
||||
"might_2fa": True,
|
||||
"traits": ["action.devices.traits.OpenClose"],
|
||||
}
|
||||
|
||||
# Set the disable 2fa flag
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "cloud/google_assistant/entities/update",
|
||||
"entity_id": "cover.garage",
|
||||
"disable_2fa": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
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"] == {
|
||||
"disable_2fa": True,
|
||||
"entity_id": "cover.garage",
|
||||
"might_2fa": True,
|
||||
"traits": ["action.devices.traits.OpenClose"],
|
||||
@@ -867,9 +900,6 @@ async def test_update_google_entity(
|
||||
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_auto_id(
|
||||
{
|
||||
@@ -885,16 +915,16 @@ async def test_update_google_entity(
|
||||
{
|
||||
"type": "homeassistant/expose_entity",
|
||||
"assistants": ["cloud.google_assistant"],
|
||||
"entity_ids": [entry.entity_id],
|
||||
"entity_ids": ["light.kitchen"],
|
||||
"should_expose": False,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
assert entity_registry.async_get(entry.entity_id).options[
|
||||
"cloud.google_assistant"
|
||||
] == {"disable_2fa": False, "should_expose": False}
|
||||
assert exposed_entities.async_get_entity_settings(hass, "light.kitchen") == {
|
||||
"cloud.google_assistant": {"disable_2fa": False, "should_expose": False}
|
||||
}
|
||||
|
||||
|
||||
async def test_list_alexa_entities(
|
||||
@@ -916,7 +946,12 @@ async def test_list_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
|
||||
assert len(response["result"]) == 1
|
||||
assert response["result"][0] == {
|
||||
"entity_id": "light.kitchen",
|
||||
"display_categories": ["LIGHT"],
|
||||
"interfaces": ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"],
|
||||
}
|
||||
|
||||
# Add the entity to the entity registry
|
||||
entity_registry.async_get_or_create(
|
||||
@@ -953,10 +988,18 @@ async def test_get_alexa_entity(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] is None
|
||||
|
||||
# Test getting an unknown sensor
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "sensor.temperature"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_found",
|
||||
"message": "light.kitchen not in the entity registry",
|
||||
"code": "not_supported",
|
||||
"message": "sensor.temperature not supported by Alexa",
|
||||
}
|
||||
|
||||
# Test getting a blocked entity
|
||||
@@ -1022,8 +1065,8 @@ async def test_update_alexa_entity(
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert entity_registry.async_get(entry.entity_id).options["cloud.alexa"] == {
|
||||
"should_expose": False
|
||||
assert exposed_entities.async_get_entity_settings(hass, entry.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ LIGHT_ENTITY = "light.kitchen_lights"
|
||||
CLOSE_THRESHOLD = 10
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
def _close_enough(actual_rgb, testing_rgb):
|
||||
"""Validate the given RGB value is in acceptable tolerance."""
|
||||
# Convert the given RGB values to hue / saturation and then back again
|
||||
|
||||
@@ -7,6 +7,7 @@ from homeassistant.components import conversation
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
DATA_EXPOSED_ENTITIES,
|
||||
ExposedEntities,
|
||||
async_expose_entity,
|
||||
)
|
||||
from homeassistant.helpers import intent
|
||||
|
||||
@@ -53,5 +54,4 @@ def expose_new(hass, expose_new):
|
||||
|
||||
def expose_entity(hass, entity_id, should_expose):
|
||||
"""Expose an entity to the default agent."""
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
exposed_entities.async_expose_entity(conversation.DOMAIN, entity_id, should_expose)
|
||||
async_expose_entity(hass, conversation.DOMAIN, entity_id, should_expose)
|
||||
|
||||
@@ -91,21 +91,21 @@ async def test_exposed_areas(
|
||||
)
|
||||
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
|
||||
|
||||
kitchen_light = entity_registry.async_get_or_create(
|
||||
"light", "demo", "1234", original_name="kitchen light"
|
||||
)
|
||||
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
|
||||
entity_registry.async_update_entity(
|
||||
kitchen_light.entity_id, device_id=kitchen_device.id
|
||||
)
|
||||
hass.states.async_set(kitchen_light.entity_id, "on")
|
||||
|
||||
bedroom_light = entity_registry.async_get_or_create(
|
||||
"light", "demo", "5678", original_name="bedroom light"
|
||||
hass.states.async_set(
|
||||
kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"}
|
||||
)
|
||||
|
||||
bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678")
|
||||
entity_registry.async_update_entity(
|
||||
bedroom_light.entity_id, area_id=area_bedroom.id
|
||||
)
|
||||
hass.states.async_set(bedroom_light.entity_id, "on")
|
||||
hass.states.async_set(
|
||||
bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"}
|
||||
)
|
||||
|
||||
# Hide the bedroom light
|
||||
expose_entity(hass, bedroom_light.entity_id, False)
|
||||
@@ -156,6 +156,8 @@ async def test_expose_flag_automatically_set(
|
||||
|
||||
assert await async_setup_component(hass, "conversation", {})
|
||||
await hass.async_block_till_done()
|
||||
with patch("homeassistant.components.http.start_http_server_and_save_config"):
|
||||
await hass.async_start()
|
||||
|
||||
# After setting up conversation, the expose flag should now be set on all entities
|
||||
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
|
||||
@@ -164,10 +166,60 @@ async def test_expose_flag_automatically_set(
|
||||
}
|
||||
|
||||
# New entities will automatically have the expose flag set
|
||||
new_light = entity_registry.async_get_or_create("light", "demo", "2345")
|
||||
new_light = "light.demo_2345"
|
||||
hass.states.async_set(new_light, "test")
|
||||
await hass.async_block_till_done()
|
||||
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
|
||||
light.entity_id: {"should_expose": True},
|
||||
new_light.entity_id: {"should_expose": True},
|
||||
new_light: {"should_expose": True},
|
||||
test.entity_id: {"should_expose": False},
|
||||
}
|
||||
|
||||
|
||||
async def test_unexposed_entities_skipped(
|
||||
hass: HomeAssistant,
|
||||
init_components,
|
||||
area_registry: ar.AreaRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test that unexposed entities are skipped in exposed areas."""
|
||||
area_kitchen = area_registry.async_get_or_create("kitchen")
|
||||
|
||||
# Both lights are in the kitchen
|
||||
exposed_light = entity_registry.async_get_or_create("light", "demo", "1234")
|
||||
entity_registry.async_update_entity(
|
||||
exposed_light.entity_id,
|
||||
area_id=area_kitchen.id,
|
||||
)
|
||||
hass.states.async_set(exposed_light.entity_id, "off")
|
||||
|
||||
unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678")
|
||||
entity_registry.async_update_entity(
|
||||
unexposed_light.entity_id,
|
||||
area_id=area_kitchen.id,
|
||||
)
|
||||
hass.states.async_set(unexposed_light.entity_id, "off")
|
||||
|
||||
# On light is exposed, the other is not
|
||||
expose_entity(hass, exposed_light.entity_id, True)
|
||||
expose_entity(hass, unexposed_light.entity_id, False)
|
||||
|
||||
# Only one light should be turned on
|
||||
calls = async_mock_service(hass, "light", "turn_on")
|
||||
result = await conversation.async_converse(
|
||||
hass, "turn on kitchen lights", None, Context(), None
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
|
||||
# Only one light should be returned
|
||||
hass.states.async_set(exposed_light.entity_id, "on")
|
||||
hass.states.async_set(unexposed_light.entity_id, "on")
|
||||
result = await conversation.async_converse(
|
||||
hass, "how many lights are on in the kitchen", None, Context(), None
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(result.response.matched_states) == 1
|
||||
assert result.response.matched_states[0].entity_id == exposed_light.entity_id
|
||||
|
||||
@@ -202,11 +202,7 @@ async def test_http_processing_intent_entity_added_removed(
|
||||
|
||||
# Add an entity
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"5678",
|
||||
suggested_object_id="late",
|
||||
original_name="friendly light",
|
||||
"light", "demo", "5678", suggested_object_id="late"
|
||||
)
|
||||
hass.states.async_set("light.late", "off", {"friendly_name": "friendly light"})
|
||||
|
||||
@@ -274,7 +270,7 @@ async def test_http_processing_intent_entity_added_removed(
|
||||
}
|
||||
|
||||
# Now delete the entity
|
||||
entity_registry.async_remove("light.late")
|
||||
hass.states.async_remove("light.late")
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.post(
|
||||
@@ -313,11 +309,7 @@ async def test_http_processing_intent_alias_added_removed(
|
||||
so that the new alias is available.
|
||||
"""
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="kitchen",
|
||||
original_name="kitchen light",
|
||||
"light", "demo", "1234", suggested_object_id="kitchen"
|
||||
)
|
||||
hass.states.async_set("light.kitchen", "off", {"friendly_name": "kitchen light"})
|
||||
|
||||
@@ -438,7 +430,6 @@ async def test_http_processing_intent_entity_renamed(
|
||||
LIGHT_DOMAIN,
|
||||
{LIGHT_DOMAIN: [{"platform": "test"}]},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
||||
client = await hass_client()
|
||||
@@ -882,20 +873,9 @@ async def test_http_processing_intent_conversion_not_expose_new(
|
||||
@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS)
|
||||
@pytest.mark.parametrize("sentence", ("turn on kitchen", "turn kitchen on"))
|
||||
async def test_turn_on_intent(
|
||||
hass: HomeAssistant,
|
||||
init_components,
|
||||
entity_registry: er.EntityRegistry,
|
||||
sentence,
|
||||
agent_id,
|
||||
hass: HomeAssistant, init_components, sentence, agent_id
|
||||
) -> None:
|
||||
"""Test calling the turn on intent."""
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="kitchen",
|
||||
original_name="kitchen",
|
||||
)
|
||||
hass.states.async_set("light.kitchen", "off")
|
||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
||||
|
||||
@@ -913,17 +893,8 @@ async def test_turn_on_intent(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentence", ("turn off kitchen", "turn kitchen off"))
|
||||
async def test_turn_off_intent(
|
||||
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry, sentence
|
||||
) -> None:
|
||||
async def test_turn_off_intent(hass: HomeAssistant, init_components, sentence) -> None:
|
||||
"""Test calling the turn on intent."""
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="kitchen",
|
||||
original_name="kitchen",
|
||||
)
|
||||
hass.states.async_set("light.kitchen", "on")
|
||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_off")
|
||||
|
||||
@@ -969,21 +940,11 @@ async def test_http_api_no_match(
|
||||
|
||||
|
||||
async def test_http_api_handle_failure(
|
||||
hass: HomeAssistant,
|
||||
init_components,
|
||||
entity_registry: er.EntityRegistry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
hass: HomeAssistant, init_components, hass_client: ClientSessionGenerator
|
||||
) -> None:
|
||||
"""Test the HTTP conversation API with an error during handling."""
|
||||
client = await hass_client()
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="kitchen",
|
||||
original_name="kitchen",
|
||||
)
|
||||
hass.states.async_set("light.kitchen", "off")
|
||||
|
||||
# Raise an error during intent handling
|
||||
@@ -1020,19 +981,11 @@ async def test_http_api_handle_failure(
|
||||
async def test_http_api_unexpected_failure(
|
||||
hass: HomeAssistant,
|
||||
init_components,
|
||||
entity_registry: er.EntityRegistry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test the HTTP conversation API with an unexpected error during handling."""
|
||||
client = await hass_client()
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="kitchen",
|
||||
original_name="kitchen",
|
||||
)
|
||||
hass.states.async_set("light.kitchen", "off")
|
||||
|
||||
# Raise an "unexpected" error during intent handling
|
||||
@@ -1351,17 +1304,8 @@ async def test_prepare_fail(hass: HomeAssistant) -> None:
|
||||
assert not agent._lang_intents.get("not-a-language")
|
||||
|
||||
|
||||
async def test_language_region(
|
||||
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
async def test_language_region(hass: HomeAssistant, init_components) -> None:
|
||||
"""Test calling the turn on intent."""
|
||||
entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="kitchen",
|
||||
original_name="kitchen",
|
||||
)
|
||||
hass.states.async_set("light.kitchen", "off")
|
||||
calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
|
||||
|
||||
@@ -1409,17 +1353,8 @@ async def test_reload_on_new_component(hass: HomeAssistant) -> None:
|
||||
assert {"light"} == (lang_intents.loaded_components - loaded_components)
|
||||
|
||||
|
||||
async def test_non_default_response(
|
||||
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
async def test_non_default_response(hass: HomeAssistant, init_components) -> None:
|
||||
"""Test intent response that is not the default."""
|
||||
entity_registry.async_get_or_create(
|
||||
"cover",
|
||||
"demo",
|
||||
"1234",
|
||||
suggested_object_id="front_door",
|
||||
original_name="front door",
|
||||
)
|
||||
hass.states.async_set("cover.front_door", "closed")
|
||||
calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER)
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
"""demo conftest."""
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401
|
||||
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
@@ -218,6 +218,7 @@ async def test_discover_platform(
|
||||
mock_demo_setup_scanner, mock_see, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Test discovery of device_tracker demo platform."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
with patch("homeassistant.components.device_tracker.legacy.update_config"):
|
||||
await discovery.async_load_platform(
|
||||
hass, device_tracker.DOMAIN, "demo", {"test_key": "test_val"}, {"bla": {}}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import math
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import emulated_kasa
|
||||
from homeassistant.components.emulated_kasa.const import (
|
||||
CONF_POWER,
|
||||
@@ -132,6 +134,12 @@ CONFIG_SENSOR = {
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
def nested_value(ndict, *keys):
|
||||
"""Return a nested dict value or None if it doesn't exist."""
|
||||
if len(keys) == 0:
|
||||
|
||||
@@ -75,6 +75,12 @@ VALID_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_healthybox():
|
||||
"""Mock fb.check_box_health."""
|
||||
|
||||
@@ -19,6 +19,7 @@ from tests.components.recorder.common import async_wait_recording_done
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test fan registered attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "demo"}})
|
||||
await hass.async_block_till_done()
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5))
|
||||
|
||||
@@ -20,6 +20,7 @@ async def test_diagnostics(
|
||||
await setup.async_setup_component(
|
||||
hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]}
|
||||
)
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
|
||||
@@ -451,6 +451,7 @@ async def test_execute(
|
||||
hass: HomeAssistant, report_state, on, brightness, value
|
||||
) -> None:
|
||||
"""Test an execute command."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(hass, "light", {"light": {"platform": "demo"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -635,6 +636,7 @@ async def test_execute_times_out(
|
||||
orig_execute_limit = sh.EXECUTE_LIMIT
|
||||
sh.EXECUTE_LIMIT = 0.02 # Decrease timeout to 20ms
|
||||
await async_setup_component(hass, "light", {"light": {"platform": "demo"}})
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
|
||||
@@ -209,6 +209,7 @@ async def test_send_text_command_expired_token_refresh_failure(
|
||||
requires_reauth: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test failure refreshing token in send_text_command."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await setup_integration()
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
@@ -1,2 +1,13 @@
|
||||
"""group conftest."""
|
||||
import pytest
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import group
|
||||
from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER
|
||||
from homeassistant.components.recorder import Recorder
|
||||
@@ -16,6 +18,11 @@ from tests.common import async_fire_time_changed
|
||||
from tests.components.recorder.common import async_wait_recording_done
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant():
|
||||
"""Override the fixture in group.conftest."""
|
||||
|
||||
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test number registered attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# serializer version: 1
|
||||
# name: test_get_assistant_settings
|
||||
dict({
|
||||
'climate.test_unique1': mappingproxy({
|
||||
'should_expose': True,
|
||||
}),
|
||||
'light.not_in_registry': dict({
|
||||
'should_expose': True,
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_get_assistant_settings.1
|
||||
dict({
|
||||
})
|
||||
# ---
|
||||
# name: test_listeners
|
||||
dict({
|
||||
'light.kitchen': dict({
|
||||
'should_expose': True,
|
||||
}),
|
||||
'switch.test_unique1': mappingproxy({
|
||||
'should_expose': True,
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Test Home Assistant exposed entities helper."""
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
DATA_EXPOSED_ENTITIES,
|
||||
ExposedEntities,
|
||||
ExposedEntity,
|
||||
async_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
async_get_entity_settings,
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
@@ -18,6 +22,76 @@ from tests.common import flush_store
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture(name="entities")
|
||||
def entities_fixture(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> dict[str, str]:
|
||||
"""Set up the test environment."""
|
||||
if request.param == "entities_unique_id":
|
||||
return entities_unique_id(entity_registry)
|
||||
elif request.param == "entities_no_unique_id":
|
||||
return entities_no_unique_id(hass)
|
||||
else:
|
||||
raise RuntimeError("Invalid setup fixture")
|
||||
|
||||
|
||||
def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]:
|
||||
"""Create some entities in the entity registry."""
|
||||
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]
|
||||
entry_lock = entity_registry.async_get_or_create("lock", "test", "unique1")
|
||||
entry_binary_sensor = entity_registry.async_get_or_create(
|
||||
"binary_sensor", "test", "unique1"
|
||||
)
|
||||
entry_binary_sensor_door = entity_registry.async_get_or_create(
|
||||
"binary_sensor",
|
||||
"test",
|
||||
"unique2",
|
||||
original_device_class="door",
|
||||
)
|
||||
entry_sensor = entity_registry.async_get_or_create("sensor", "test", "unique1")
|
||||
entry_sensor_temperature = entity_registry.async_get_or_create(
|
||||
"sensor",
|
||||
"test",
|
||||
"unique2",
|
||||
original_device_class="temperature",
|
||||
)
|
||||
return {
|
||||
"blocked": entry_blocked.entity_id,
|
||||
"lock": entry_lock.entity_id,
|
||||
"binary_sensor": entry_binary_sensor.entity_id,
|
||||
"door_sensor": entry_binary_sensor_door.entity_id,
|
||||
"sensor": entry_sensor.entity_id,
|
||||
"temperature_sensor": entry_sensor_temperature.entity_id,
|
||||
}
|
||||
|
||||
|
||||
def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Create some entities not in the entity registry."""
|
||||
blocked = CLOUD_NEVER_EXPOSED_ENTITIES[0]
|
||||
lock = "lock.test"
|
||||
binary_sensor = "binary_sensor.test"
|
||||
door_sensor = "binary_sensor.door"
|
||||
sensor = "sensor.test"
|
||||
sensor_temperature = "sensor.temperature"
|
||||
hass.states.async_set(binary_sensor, "on", {})
|
||||
hass.states.async_set(door_sensor, "on", {"device_class": "door"})
|
||||
hass.states.async_set(sensor, "on", {})
|
||||
hass.states.async_set(sensor_temperature, "on", {"device_class": "temperature"})
|
||||
return {
|
||||
"blocked": blocked,
|
||||
"lock": lock,
|
||||
"binary_sensor": binary_sensor,
|
||||
"door_sensor": door_sensor,
|
||||
"sensor": sensor,
|
||||
"temperature_sensor": sensor_temperature,
|
||||
}
|
||||
|
||||
|
||||
async def test_load_preferences(hass: HomeAssistant) -> None:
|
||||
"""Make sure that we can load/save data correctly."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
@@ -28,13 +102,21 @@ async def test_load_preferences(hass: HomeAssistant) -> None:
|
||||
exposed_entities.async_set_expose_new_entities("test1", True)
|
||||
exposed_entities.async_set_expose_new_entities("test2", False)
|
||||
|
||||
async_expose_entity(hass, "test1", "light.kitchen", True)
|
||||
async_expose_entity(hass, "test1", "light.living_room", True)
|
||||
async_expose_entity(hass, "test2", "light.kitchen", True)
|
||||
async_expose_entity(hass, "test2", "light.kitchen", True)
|
||||
|
||||
assert list(exposed_entities._assistants) == ["test1", "test2"]
|
||||
assert list(exposed_entities.entities) == ["light.kitchen", "light.living_room"]
|
||||
|
||||
await flush_store(exposed_entities._store)
|
||||
|
||||
exposed_entities2 = ExposedEntities(hass)
|
||||
await flush_store(exposed_entities._store)
|
||||
await exposed_entities2.async_load()
|
||||
await exposed_entities2.async_initialize()
|
||||
|
||||
assert exposed_entities._assistants == exposed_entities2._assistants
|
||||
assert exposed_entities.entities == exposed_entities2.entities
|
||||
|
||||
|
||||
async def test_expose_entity(
|
||||
@@ -50,6 +132,9 @@ async def test_expose_entity(
|
||||
entry1 = entity_registry.async_get_or_create("test", "test", "unique1")
|
||||
entry2 = entity_registry.async_get_or_create("test", "test", "unique2")
|
||||
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
assert len(exposed_entities.entities) == 0
|
||||
|
||||
# Set options
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
@@ -67,6 +152,7 @@ async def test_expose_entity(
|
||||
assert entry1.options == {"cloud.alexa": {"should_expose": True}}
|
||||
entry2 = entity_registry.async_get(entry2.entity_id)
|
||||
assert entry2.options == {}
|
||||
assert len(exposed_entities.entities) == 0
|
||||
|
||||
# Update options
|
||||
await ws_client.send_json_auto_id(
|
||||
@@ -91,6 +177,7 @@ async def test_expose_entity(
|
||||
"cloud.alexa": {"should_expose": False},
|
||||
"cloud.google_assistant": {"should_expose": False},
|
||||
}
|
||||
assert len(exposed_entities.entities) == 0
|
||||
|
||||
|
||||
async def test_expose_entity_unknown(
|
||||
@@ -103,6 +190,7 @@ async def test_expose_entity_unknown(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
assert len(exposed_entities.entities) == 0
|
||||
|
||||
# Set options
|
||||
await ws_client.send_json_auto_id(
|
||||
@@ -115,14 +203,41 @@ async def test_expose_entity_unknown(
|
||||
)
|
||||
|
||||
response = await ws_client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_found",
|
||||
"message": "can't expose 'test.test'",
|
||||
assert response["success"]
|
||||
|
||||
assert len(exposed_entities.entities) == 1
|
||||
assert exposed_entities.entities == {
|
||||
"test.test": ExposedEntity({"cloud.alexa": {"should_expose": True}})
|
||||
}
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
exposed_entities.async_expose_entity("cloud.alexa", "test.test", True)
|
||||
# Update options
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "homeassistant/expose_entity",
|
||||
"assistants": ["cloud.alexa", "cloud.google_assistant"],
|
||||
"entity_ids": ["test.test", "test.test2"],
|
||||
"should_expose": False,
|
||||
}
|
||||
)
|
||||
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
assert len(exposed_entities.entities) == 2
|
||||
assert exposed_entities.entities == {
|
||||
"test.test": ExposedEntity(
|
||||
{
|
||||
"cloud.alexa": {"should_expose": False},
|
||||
"cloud.google_assistant": {"should_expose": False},
|
||||
}
|
||||
),
|
||||
"test.test2": ExposedEntity(
|
||||
{
|
||||
"cloud.alexa": {"should_expose": False},
|
||||
"cloud.google_assistant": {"should_expose": False},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def test_expose_entity_blocked(
|
||||
@@ -220,55 +335,57 @@ async def test_listen_updates(
|
||||
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
|
||||
)
|
||||
async_expose_entity(hass, "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)
|
||||
async_expose_entity(hass, "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)
|
||||
async_expose_entity(hass, "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)
|
||||
async_expose_entity(hass, "cloud.alexa", entry.entity_id, False)
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
async def test_get_assistant_settings(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> 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_expose_entity(hass, "cloud.alexa", entry.entity_id, True)
|
||||
async_expose_entity(hass, "cloud.alexa", "light.not_in_registry", True)
|
||||
assert async_get_assistant_settings(hass, "cloud.alexa") == snapshot
|
||||
assert async_get_assistant_settings(hass, "cloud.google_assistant") == snapshot
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
async_get_entity_settings(hass, "light.unknown")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entities", ["entities_unique_id", "entities_no_unique_id"], indirect=True
|
||||
)
|
||||
async def test_should_expose(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entities: dict[str, str],
|
||||
) -> None:
|
||||
"""Test expose entity."""
|
||||
ws_client = await hass_ws_client(hass)
|
||||
@@ -290,59 +407,141 @@ async def test_should_expose(
|
||||
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
|
||||
assert async_should_expose(hass, "cloud.alexa", entities["blocked"]) 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
|
||||
assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is True
|
||||
|
||||
# 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
|
||||
assert async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) 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
|
||||
assert async_should_expose(hass, "cloud.alexa", entities["door_sensor"]) 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
|
||||
assert async_should_expose(hass, "cloud.alexa", entities["sensor"]) is False
|
||||
|
||||
# Sensor with certain device class is exposed
|
||||
sensor2 = entity_registry.async_get_or_create(
|
||||
"sensor",
|
||||
"test",
|
||||
"unique2",
|
||||
original_device_class="temperature",
|
||||
assert (
|
||||
async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True
|
||||
)
|
||||
assert entry_blocked.entity_id == CLOUD_NEVER_EXPOSED_ENTITIES[0]
|
||||
assert async_should_expose(hass, "cloud.alexa", sensor2.entity_id) is True
|
||||
|
||||
# The second time we check, it should load it from storage
|
||||
assert (
|
||||
async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True
|
||||
)
|
||||
|
||||
# Check with a different assistant
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
exposed_entities.async_set_expose_new_entities("cloud.no_default_expose", False)
|
||||
assert (
|
||||
async_should_expose(
|
||||
hass, "cloud.no_default_expose", entities["temperature_sensor"]
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def test_should_expose_hidden_categorized(
|
||||
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"]
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"lock", "test", "unique2", hidden_by=er.RegistryEntryHider.USER
|
||||
)
|
||||
assert async_should_expose(hass, "cloud.alexa", "lock.test_unique2") is False
|
||||
|
||||
# Entity with category is not exposed
|
||||
entity_registry.async_get_or_create(
|
||||
"lock", "test", "unique3", entity_category=EntityCategory.CONFIG
|
||||
)
|
||||
assert async_should_expose(hass, "cloud.alexa", "lock.test_unique3") is False
|
||||
|
||||
|
||||
async def test_list_exposed_entities(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test list exposed entities."""
|
||||
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 for registered entities
|
||||
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": True,
|
||||
}
|
||||
)
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
# Set options for entities not in the entity registry
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "homeassistant/expose_entity",
|
||||
"assistants": ["cloud.alexa", "cloud.google_assistant"],
|
||||
"entity_ids": [
|
||||
"test.test",
|
||||
"test.test2",
|
||||
],
|
||||
"should_expose": False,
|
||||
}
|
||||
)
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
# List exposed entities
|
||||
await ws_client.send_json_auto_id({"type": "homeassistant/expose_entity/list"})
|
||||
response = await ws_client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"exposed_entities": {
|
||||
"test.test": {"cloud.alexa": False, "cloud.google_assistant": False},
|
||||
"test.test2": {"cloud.alexa": False, "cloud.google_assistant": False},
|
||||
"test.test_unique1": {"cloud.alexa": True, "cloud.google_assistant": True},
|
||||
"test.test_unique2": {"cloud.alexa": True, "cloud.google_assistant": True},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_listeners(
|
||||
hass: HomeAssistant, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Make sure we call entity listeners."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
|
||||
callbacks = []
|
||||
exposed_entities.async_listen_entity_updates("test1", lambda: callbacks.append(1))
|
||||
|
||||
async_expose_entity(hass, "test1", "light.kitchen", True)
|
||||
assert len(callbacks) == 1
|
||||
|
||||
entry1 = entity_registry.async_get_or_create("switch", "test", "unique1")
|
||||
async_expose_entity(hass, "test1", entry1.entity_id, True)
|
||||
|
||||
@@ -427,6 +427,7 @@ async def test_options_flow_devices(
|
||||
demo_config_entry = MockConfigEntry(domain="domain")
|
||||
demo_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "demo", {"demo": {}})
|
||||
assert await async_setup_component(hass, "homekit", {"homekit": {}})
|
||||
|
||||
|
||||
@@ -319,6 +319,7 @@ async def test_config_entry_with_trigger_accessory(
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test generating diagnostics for a bridge config entry with a trigger accessory."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "demo", {"demo": {}})
|
||||
hk_driver.publish = MagicMock()
|
||||
|
||||
|
||||
@@ -747,6 +747,7 @@ async def test_homekit_start_with_a_device(
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345}
|
||||
)
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "demo", {"demo": {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ async def test_bridge_with_triggers(
|
||||
an above or below additional configuration which we have no way
|
||||
to input, we ignore them.
|
||||
"""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "demo", {"demo": {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ MOCK_START_STREAM_SESSION_UUID = UUID("3303d503-17cc-469a-b672-92436a71a2f6")
|
||||
PID_THAT_WILL_NEVER_BE_ALIVE = 2147483647
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
async def _async_start_streaming(hass, acc):
|
||||
"""Start streaming a camera."""
|
||||
acc.set_selected_stream_configuration(MOCK_START_STREAM_TLV)
|
||||
|
||||
@@ -25,6 +25,7 @@ async def test_programmable_switch_button_fires_on_trigger(
|
||||
|
||||
demo_config_entry = MockConfigEntry(domain="domain")
|
||||
demo_config_entry.add_to_hass(hass)
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
assert await async_setup_component(hass, "demo", {"demo": {}})
|
||||
await hass.async_block_till_done()
|
||||
hass.states.async_set("light.ceiling_lights", STATE_OFF)
|
||||
|
||||
@@ -16,6 +16,12 @@ from tests.common import assert_setup_component, async_capture_events
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aiohttp_unused_port(event_loop, aiohttp_unused_port, socket_enabled):
|
||||
"""Return aiohttp_unused_port and allow opening sockets."""
|
||||
|
||||
@@ -36,6 +36,7 @@ from homeassistant.components.light import (
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -1741,3 +1742,46 @@ async def test_set_hev_cycle_state_fails_for_color_bulb(hass: HomeAssistant) ->
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_POWER: True},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None:
|
||||
"""Test a light strip were zones are not populated initially."""
|
||||
already_migrated_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL
|
||||
)
|
||||
already_migrated_config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_light_strip()
|
||||
bulb.power_level = 65535
|
||||
bulb.color_zones = None
|
||||
bulb.color = [65535, 65535, 65535, 65535]
|
||||
with _patch_discovery(device=bulb), _patch_config_flow_try_connect(
|
||||
device=bulb
|
||||
), _patch_device(device=bulb):
|
||||
await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "light.my_bulb"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "on"
|
||||
attributes = state.attributes
|
||||
assert attributes[ATTR_BRIGHTNESS] == 255
|
||||
assert attributes[ATTR_COLOR_MODE] == ColorMode.HS
|
||||
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [
|
||||
ColorMode.COLOR_TEMP,
|
||||
ColorMode.HS,
|
||||
]
|
||||
assert attributes[ATTR_HS_COLOR] == (360.0, 100.0)
|
||||
assert attributes[ATTR_RGB_COLOR] == (255, 0, 0)
|
||||
assert attributes[ATTR_XY_COLOR] == (0.701, 0.299)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert bulb.set_power.calls[0][0][0] is True
|
||||
bulb.set_power.reset_mock()
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
@@ -26,6 +26,7 @@ from tests.components.recorder.common import async_wait_recording_done
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test light registered attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(
|
||||
hass, light.DOMAIN, {light.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
@@ -21,6 +21,12 @@ from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
async def test_get_image_http(
|
||||
hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator
|
||||
) -> None:
|
||||
|
||||
@@ -25,6 +25,7 @@ from tests.components.recorder.common import async_wait_recording_done
|
||||
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
|
||||
"""Test media_player registered attributes to be excluded."""
|
||||
now = dt_util.utcnow()
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
await async_setup_component(
|
||||
hass, media_player.DOMAIN, {media_player.DOMAIN: {"platform": "demo"}}
|
||||
)
|
||||
|
||||
@@ -25,6 +25,12 @@ from tests.common import assert_setup_component, load_fixture
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
def create_group(hass, name):
|
||||
"""Create a new person group.
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ CONFIG = {
|
||||
ENDPOINT_URL = f"https://westus.{mf.FACE_API_URL}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_mock():
|
||||
"""Mock update store."""
|
||||
|
||||
@@ -14,6 +14,12 @@ from tests.components.image_processing import common
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def setup_homeassistant(hass: HomeAssistant):
|
||||
"""Set up the homeassistant integration."""
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_mock():
|
||||
"""Mock update store."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user